Active user attribute set, latest activities, guarded changes to used values, artefact links, durations and averages; irreversible showcase step
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Work Records / validate (push) Has been cancelled

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 15:50:56 +02:00
parent 6716cee921
commit 8d62c1fbe8
22 changed files with 1025 additions and 35 deletions

9
tests/conftest.py Normal file
View file

@ -0,0 +1,9 @@
import pytest
@pytest.fixture(autouse=True)
def _isolated_user(tmp_path_factory, monkeypatch):
"""Tests never read or write the real user's app settings."""
monkeypatch.setenv("RTUT_CONFIG_DIR", str(tmp_path_factory.mktemp("rtut-config")))
monkeypatch.delenv("RTUT_USER", raising=False)
monkeypatch.setattr("getpass.getuser", lambda: "tester-login")

View file

@ -0,0 +1,93 @@
import os
import shutil
from pathlib import Path
import pytest
from rtut import activity, artefacts as A, maturity as M, pack as P, run as R, run2
ROOT = Path(__file__).resolve().parent.parent
def repo_with(tmp_path, *names):
packs = []
for n in names:
d = tmp_path / "runbooks" / n; d.mkdir(parents=True)
shutil.copy(ROOT / ("runbooks/showcase-lighthouse-v2/pack.yaml" if n.endswith("2") else "runbooks/showcase-lighthouse/pack.yaml"), d / "pack.yaml")
packs.append(P.load(d / "pack.yaml"))
return packs
# ---- activity -----------------------------------------------------------------------------------------------------
def test_latest_activities_are_newest_first_across_packs_and_cover_the_main_events(tmp_path):
v1, v2 = repo_with(tmp_path, "one", "two2")
r = R.create(v1, "ann")
R.start(r, "read-the-sign"); R.verify(r, "read-the-sign", manual_ok=True); R.complete(r, "read-the-sign")
R.add_feedback(r, "good", "smooth")
M.add_review(v1, "bob", "read it")
q = run2.create(v2, "cy", {"logdir": "/tmp/rtut-act-x"})
run2.set_datapoint(q, "weather", "clear")
run2.start(q, "read-the-sky"); run2.fail(q, "read-the-sky", "x")
R.finalize(r, "abandoned")
ev = activity.latest([v1, v2], 50)
assert [e["at"] for e in ev] == sorted((e["at"] for e in ev), reverse=True)
text = " | ".join(f"{e['who']}: {e['text']}" for e in ev)
for frag in ("ann: started a run (manual mode)", "ann: finished step “Read the poem over the door”", "ann: rated the run good: smooth",
"bob: reviewed the runbook: read it", "cy: started a run (manual mode)", "cy: failed step “Read the sky”",
"ann: finished the run as abandoned"):
assert frag in text, frag
assert all(e["link"] for e in ev) and {e["pack"] for e in ev} == {v1.data["title"], v2.data["title"]}
assert len(activity.latest([v1, v2], 3)) == 3
def test_overrides_and_changed_values_are_visible_in_the_feed(tmp_path):
(v2,) = repo_with(tmp_path, "two2")
q = run2.create(v2, "cy", {"logdir": "/tmp/rtut-act-y"})
run2.start(q, "warn", alt="sound-horn", override_reason="want to see", acknowledge_risk=True)
text = " | ".join(e["text"] for e in activity.latest([v2], 20))
assert "override: warn (want to see)" in text
# ---- artefact serving rules ----------------------------------------------------------------------------------------
def test_a_plain_text_file_may_be_served(tmp_path):
f = tmp_path / "logbook.txt"; f.write_text("Night watch by Ada\n")
path, why = A.check(str(f))
assert path == f.resolve() and why == ""
data, ctype, is_text = A.content(path)
assert data == b"Night watch by Ada\n" and ctype.startswith("text/plain") and is_text
@pytest.mark.parametrize("make,frag", [
(lambda t: None, "not known yet"),
(lambda t: str(t / "missing.txt"), "does not exist"),
(lambda t: str(t), "not a regular file"),
(lambda t: "relative/file.txt", "absolute"),
(lambda t: "https://example.org/x", "link"),
])
def test_refused_locations(tmp_path, make, frag):
path, why = A.check(make(tmp_path))
assert path is None and frag in why
def test_sensitive_files_secrets_and_big_files_are_never_served(tmp_path):
ssh = tmp_path / ".ssh"; ssh.mkdir(); (ssh / "notes.txt").write_text("hello")
assert "sensitive" in A.check(str(ssh / "notes.txt"))[1]
key = tmp_path / "server.key"; key.write_text("x")
assert "sensitive" in A.check(str(key))[1]
runs = tmp_path / "runbook-runs"; runs.mkdir(); (runs / "r.json").write_text("{}")
assert "sensitive" in A.check(str(runs / "r.json"))[1]
leaky = tmp_path / "out.txt"; leaky.write_text("token " + "hvs." + "ABCDEFGHIJKLMN")
assert "secret" in A.check(str(leaky))[1]
big = tmp_path / "big.bin"; big.write_bytes(b"0" * (A.MAX_BYTES + 1))
assert "larger" in A.check(str(big))[1]
link = tmp_path / "link.txt"; os.symlink(str(ssh / "notes.txt"), link)
assert "sensitive" in A.check(str(link))[1] # a symlink is judged by where it really points
def test_binary_files_are_offered_only_as_downloads(tmp_path):
f = tmp_path / "blob.bin"; f.write_bytes(b"\xff\xfe\x00\x01")
path, _ = A.check(str(f))
_, ctype, is_text = A.content(path)
assert not is_text and "text/plain" not in ctype

View file

@ -426,3 +426,42 @@ def test_captures_and_pasted_verification_work_for_v02_runs(tmp_path):
R1.add_capture(r, "read-the-sky", "Weather tonight - fog.", "echo ...")
res = R1.verify_pasted(r, "read-the-sky", "Weather tonight - fog.")
assert res["passed"] and r.data["steps"]["read-the-sky"]["captures"][0]["output"].startswith("Weather")
# ---- changing datapoints after steps used them ------------------------------------------------------------------------
def test_changing_an_unused_datapoint_is_free_and_a_used_one_is_a_recorded_deviation(tmp_path):
r = new(tmp_path, values={"weather": "fog"})
assert R.set_datapoint(r, "weather", "clear") == "set" and r.data["deviations"] == [] # nobody used it yet
do_step(r, "read-the-sky") # uses weather
assert R.datapoint_users(r, "weather") == ["read-the-sky"]
with pytest.raises(R.RunError, match="already used by read-the-sky"):
R.set_datapoint(r, "weather", "storm")
with pytest.raises(R.RunError, match="acknowledge"):
R.set_datapoint(r, "weather", "storm", override_reason="the forecast changed")
assert r.data["params"]["weather"] == "clear" # refused: nothing changed
R.set_datapoint(r, "weather", "storm", override_reason="the forecast changed", acknowledge_risk=True)
assert r.data["params"]["weather"] == "storm"
dev = r.data["deviations"][0]
assert dev["kind"] == "changed-after-use" and dev["item"] == "weather" and "read-the-sky" in dev["unmet"][0] and "'clear'" in dev["unmet"][0]
assert dev["reason"] == "the forecast changed" and dev["risk_acknowledged"] is True
assert R.set_datapoint(r, "weather", "storm") == "set" and len(r.data["deviations"]) == 1 # the same value is not a change
def test_used_datapoints_cannot_be_changed_in_auto_confirm_mode(tmp_path):
r = new(tmp_path, mode="auto-confirm")
R.auto_confirm_tick(r); R.auto_confirm_tick(r)
r.data["steps"]["read-the-sky"]["state"] = "done"
with pytest.raises(R.RunError, match="take over"):
R.set_datapoint(r, "weather", "clear", override_reason="x", acknowledge_risk=True)
def test_a_run_that_changed_a_used_value_never_counts_towards_maturity(tmp_path):
r = new(tmp_path)
do_step(r, "read-the-sky")
R.set_datapoint(r, "weather", "clear", override_reason="changed my mind", acknowledge_risk=True)
assert r.data["deviations"]
with pytest.raises(R1.RunError, match="deviations"):
for k in r.data["steps"]:
r.data["steps"][k]["state"] = "done"
R1.exercise_update(r)

View file

@ -115,3 +115,39 @@ def test_runs_of_different_packs_in_one_repository_are_not_mixed(tmp_path):
assert R.scorecard(pa)["tried"] == 2 and R.scorecard(pb)["tried"] == 1
assert R.list_runs(pb) != R.list_runs(pa) and len(R.list_runs(pb)) == 1
assert R.scorecard(pb)["feedback"]["good"] == 0 and R.all_feedback(pb) == []
# ---- durations -----------------------------------------------------------------------------------------------
def test_fmt_duration():
assert [R.fmt_duration(x) for x in (None, 0, 45, 60, 200, 3600, 3900, 90000)] == ["-", "0 s", "45 s", "1 min", "3 min 20 s", "1 h", "1 h 5 min", "1 d 1 h"]
def test_run_times_and_average_of_succeeded_runs_only(pk):
from datetime import datetime, timedelta, timezone
import json
def timed(operator, seconds, outcome):
r = R.create(pk, operator)
start = datetime(2026, 9, 29, 10, 0, tzinfo=timezone.utc)
r.data["created"] = start.isoformat()
r.data["outcome"] = outcome
r.data["finished"] = (start + timedelta(seconds=seconds)).isoformat()
first = next(iter(r.data["steps"]))
r.data["steps"][first].update(state="done", started=(start + timedelta(seconds=10)).isoformat(),
finished=(start + timedelta(seconds=40)).isoformat())
r.path.write_text(json.dumps(r.data))
return r
a = timed("a", 600, "succeeded"); timed("b", 1200, "succeeded"); timed("c", 9000, "failed"); timed("d", 300, "abandoned")
t = R.run_times(R.load(pk, a.data["run_id"]))
assert t["elapsed"] == 600 and t["finished_run"] and t["step_seconds"] == {next(iter(a.data["steps"])): 30.0} and t["working"] == 30.0
c = R.scorecard(pk)
assert c["avg_seconds"] == 900 and c["median_seconds"] == 900 and c["fastest_seconds"] == 600 and c["timed_runs"] == 2 # the failed 9000 s run is not counted
assert R.scorecard(pk)["succeeded"] == 2
def test_unfinished_run_elapsed_runs_to_now_and_no_average_without_succeeded_runs(pk):
from datetime import datetime, timedelta, timezone
r = R.create(pk, "x")
t = R.run_times(r, now=datetime.now(timezone.utc) + timedelta(seconds=90))
assert not t["finished_run"] and 89 <= t["elapsed"] <= 91
assert R.scorecard(pk)["avg_seconds"] is None and R.scorecard(pk)["timed_runs"] == 0

263
tests/test_ui_round3.py Normal file
View file

@ -0,0 +1,263 @@
import http.client
import json
import shutil
import subprocess
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode
import pytest
import yaml
from rtut import run as R1, run2 as R, ui
ROOT = Path(__file__).resolve().parent.parent
_scratch = []
@pytest.fixture
def srv(tmp_path):
for name, src in (("v1", "showcase-lighthouse"), ("v2", "showcase-lighthouse-v2")):
d = tmp_path / "runbooks" / name; d.mkdir(parents=True)
shutil.copy(ROOT / f"runbooks/{src}/pack.yaml", d / "pack.yaml")
(tmp_path / "projects/watch").mkdir(parents=True)
(tmp_path / "projects/watch/project.yaml").write_text(yaml.safe_dump({
"spec": "project/v0.1", "id": "watch", "title": "Watch project", "environment": "rehearsal",
"attribute_sets": ["user-attributes"], "attributes": {"logdir": "/tmp/rtut-r3-proj"},
"runbooks": [{"pack": "rtut.showcase-lighthouse"}]}))
s = ui.serve([tmp_path], 0)
threading.Thread(target=s.serve_forever, daemon=True).start()
s.repo = tmp_path
yield s
s.shutdown()
while _scratch:
shutil.rmtree(_scratch.pop(), ignore_errors=True)
def raw(s, method, path, form=None):
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
body = urlencode(form) if form is not None else None
c.request(method, path, body, {"Host": "127.0.0.1", "Content-Type": "application/x-www-form-urlencoded"})
r = c.getresponse()
return r.status, r.getheader("Location"), r.read().decode(errors="replace"), {k.lower(): v for k, v in r.getheaders()}
def req(s, method, path, form=None):
return raw(s, method, path, form)[:3]
def post(s, path, **form):
return req(s, "POST", path, {"_t": s.app.token, **form})
def pidx(s, pack_id):
return s.app.pack_index(pack_id)
def new_v2(s, **extra):
logdir = f"/tmp/rtut-r3-{uuid.uuid4().hex[:8]}"
_scratch.append(logdir)
st, loc, _ = post(s, f"/run/new/{pidx(s, 'rtut.showcase-lighthouse-v2')}", p_logdir=logdir, **extra) # note: no operator posted
assert st == 303
return loc.split("#")[0], logdir
def step(s, base, sid, **form):
assert post(s, f"{base}/{sid}/start", **form)[0] == 303
run = R1.load(s.app.packs[int(base.split("/")[2])], base.rsplit("/", 1)[1]) # reload: the chosen alternative is known now
cmd = R.command_for(run, sid)
if cmd:
subprocess.run(cmd, shell=True, check=True, capture_output=True)
eff = R.step_def(run, sid)
manual = (eff["verify"].get("expect") or "manual") == "manual" and not eff["verify"].get("datapoints_ok")
assert (post(s, f"{base}/{sid}/verify", ok="1") if manual else post(s, f"{base}/{sid}/verify", yes="1"))[0] == 303
assert post(s, f"{base}/{sid}/done")[0] == 303
# ---- (a) the active user instead of asking for a name ----------------------------------------------------------
def test_user_page_shows_source_and_saves(srv):
_, _, body = req(srv, "GET", "/user")
assert "tester-login" in body and "your operating-system login" in body and "user-attributes" in body
assert post(srv, "/user", operator="bernd", display_name="Bernd W.")[0] == 303
_, _, body = req(srv, "GET", "/user")
assert "<b>bernd</b>" in body and "your saved settings" in body and "Bernd W." in body
st, _, body = post(srv, "/user", operator="two words")
assert st == 400 and "one word" in body
def test_forms_use_the_active_user_and_do_not_ask_for_a_name(srv):
post(srv, "/user", operator="bernd", display_name="Bernd W.")
i1, i2 = pidx(srv, "rtut.showcase-lighthouse"), pidx(srv, "rtut.showcase-lighthouse-v2")
for path in (f"/pack/{i1}", f"/pack/{i2}"):
_, _, body = req(srv, "GET", path)
assert "Running as <b>Bernd W. (bernd)</b>" in body and "<a href='/user'>change</a>" in body
assert "Your name (one word)" not in body and "<input name=operator" not in body
_, _, body = req(srv, "GET", "/project/watch")
assert "Start run as bernd" in body and "name=operator value='bernd'" in body
def test_a_run_started_without_a_posted_name_belongs_to_the_active_user_and_reviews_too(srv):
post(srv, "/user", operator="carol")
base, _ = new_v2(srv)
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert run.data["operator"] == "carol"
i1 = pidx(srv, "rtut.showcase-lighthouse")
assert post(srv, f"/pack/{i1}/review", note="ok")[0] == 303
_, _, body = req(srv, "GET", f"/pack/{i1}")
assert "carol" in body and "Reviewed: <b>yes</b>" in body
def test_user_attributes_are_reusable_by_projects_through_the_app_level_set(srv):
post(srv, "/user", operator="dave")
_, _, body = req(srv, "GET", "/project/watch") # the project lists attribute_sets: [user-attributes]
assert "This project has problems" not in body and "Watch project" in body
from rtut import project as J
reg = srv.app.registry()
assert J.validate(reg, reg.projects["watch"]) == [] # the set is found although no repository defines it
member = reg.projects["watch"].data["runbooks"][0]
res = J.member_attributes(reg, reg.projects["watch"].data, member)
assert "operator" in res["unused"] # v0.1 showcase has no operator input: reported, harmless
assert "Unused attributes: " in body and "operator" in body.split("Unused attributes: ")[1].split("</p>")[0]
# ---- (b) the irreversible example is irreversible -------------------------------------------------------------------
def test_the_showcase_logbook_entry_carries_the_time_and_close_log_says_why_it_cannot_be_undone(srv):
base, logdir = new_v2(srv)
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert "$(date -u" in run.pack.data["stages"][1]["steps"][1]["command"]
_, _, body = req(srv, "GET", base)
assert "cannot be undone" in body and "to the second" in body
# ---- (c) latest activities ---------------------------------------------------------------------------------------------
def test_main_page_lists_latest_activities_newest_first_with_links(srv):
_, _, body = req(srv, "GET", "/")
assert "Latest activities" in body and "Nothing has happened yet" in body
post(srv, "/user", operator="erin")
base, _ = new_v2(srv, p_weather="fog")
step(srv, base, "read-the-sign")
step(srv, base, "light-lamp")
_, _, body = req(srv, "GET", "/")
feed = body.split("Latest activities")[1].split("<h2>Runbooks</h2>")[0]
assert "<b>erin</b> started a run (manual mode)" in feed and "finished step “Light the lamp”" in feed
assert feed.index("Light the lamp") < feed.index("Read the poem over the door") < feed.index("started a run") # newest first
assert f"<a href='{base}'>open</a>" in feed
# ---- (d) changing values that steps already used --------------------------------------------------------------------------
def test_changing_a_value_in_use_needs_an_override_shows_the_lock_and_is_recorded(srv):
base, _ = new_v2(srv, p_weather="fog")
_, _, body = req(srv, "GET", base + "?view=data")
assert "in use by" not in body and "Changing a value that steps already used" not in body # nothing used yet: no lock, no override
assert post(srv, f"{base}/data", d_weather="clear")[0] == 303 # free change before use
step(srv, base, "read-the-sky")
_, _, body = req(srv, "GET", base + "?view=data")
assert "in use by read-the-sky" in body and "Changing a value that steps already used (override)" in body
st, _, err = post(srv, f"{base}/data", d_weather="storm")
assert st == 400 and "already in use" in err and "read-the-sky" in err
st, _, err = post(srv, f"{base}/data", d_weather="storm", override_reason="forecast changed")
assert st == 400 and "acknowledge" in err
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert run.data["params"]["weather"] == "clear" and run.data["deviations"] == [] # refused: nothing changed
assert post(srv, f"{base}/data", d_weather="storm", override_reason="forecast changed", acknowledge_risk="1")[0] == 303
_, _, ov = req(srv, "GET", base + "?view=overview")
assert ">changed-after-use<" in ov and "forecast changed" in ov and "read-the-sky already used weather" in ov
def test_a_refused_save_applies_none_of_its_changes(srv):
base, _ = new_v2(srv, p_weather="fog")
step(srv, base, "read-the-sky")
st, _, _ = post(srv, f"{base}/data", d_weather="storm", d_motto="Hold fast") # weather is locked, motto is free
assert st == 400
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert run.data["params"]["motto"] != "Hold fast" and run.data["params"]["weather"] == "fog"
def test_in_auto_confirm_mode_used_values_cannot_be_changed_until_taking_over(srv):
base, _ = new_v2(srv, mode="auto-confirm", p_weather="fog")
post(srv, f"{base}/tick", n="3")
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
if not R.datapoint_users(run, "weather"):
run.data["steps"]["read-the-sky"]["state"] = "done"; run.save()
_, _, body = req(srv, "GET", base + "?view=data")
assert "can only be changed in manual mode" in body and "name=override_reason" not in body
st, _, err = post(srv, f"{base}/data", d_weather="clear", override_reason="x", acknowledge_risk="1")
assert st == 400 and "take over" in err
# ---- (e) artefacts can be opened and downloaded ---------------------------------------------------------------------------
def finish_logbook(srv, base):
for sid in ("read-the-sign", "light-lamp", "read-the-sky"):
step(srv, base, sid)
for i in (0, 1):
ck = post(srv, f"{base}/gate/lamp-check/check", idx=str(i), ok="1") if i == 0 else post(srv, f"{base}/gate/lamp-check/check", idx="1", yes="1")
assert ck[0] == 303
step(srv, base, "warn")
step(srv, base, "write-logbook")
def test_artefact_is_linked_and_can_be_opened_and_downloaded(srv):
base, logdir = new_v2(srv, p_weather="fog")
_, _, body = req(srv, "GET", base + "?view=data")
assert "the file does not exist (yet)" in body and "artefact/logbook-file" not in body # nothing to open before it exists
assert raw(srv, "GET", f"{base}/artefact/logbook-file")[0] == 403
finish_logbook(srv, base)
_, _, body = req(srv, "GET", base + "?view=data")
assert f"<a href='{base}/artefact/logbook-file' target=_blank rel=noopener>Open</a>" in body and "?download=1'>Download</a>" in body
st, _, text, hdr = raw(srv, "GET", f"{base}/artefact/logbook-file")
assert st == 200 and "Night watch by Ada at " in text and hdr["content-type"].startswith("text/plain")
assert hdr["x-content-type-options"] == "nosniff" and "sandbox" in hdr["content-security-policy"] and "content-disposition" not in hdr
st, _, text2, hdr = raw(srv, "GET", f"{base}/artefact/logbook-file?download=1")
assert st == 200 and text2 == text and 'attachment; filename="artefact-logbook-file"' in hdr["content-disposition"]
def test_artefact_route_refuses_unknown_ids_secrets_and_v01_runs(srv):
base, logdir = new_v2(srv, p_weather="fog")
finish_logbook(srv, base)
assert raw(srv, "GET", f"{base}/artefact/nope")[0] == 404
(Path(logdir) / "logbook.txt").write_text("token " + "hvs." + "ABCDEFGHIJKLMN")
st, _, body, _ = raw(srv, "GET", f"{base}/artefact/logbook-file")
assert st == 403 and "secret" in body
_, _, dbody = req(srv, "GET", base + "?view=data")
assert "looks like it contains a secret" in dbody and "artefact/logbook-file" not in dbody
st, loc, _ = post(srv, f"/run/new/{pidx(srv, 'rtut.showcase-lighthouse')}", p_logdir="/tmp/rtut-r3-v1")
assert raw(srv, "GET", loc.split("#")[0] + "/artefact/logbook-file")[0] == 404
# ---- (f) timestamps, durations and the average ---------------------------------------------------------------------------------
def test_run_shows_start_finish_and_duration_and_steps_show_how_long_they_took(srv):
base, _ = new_v2(srv, p_weather="fog")
_, _, body = req(srv, "GET", base)
assert "Started 20" in body and "UTC · running for <b>" in body
step(srv, base, "read-the-sign")
_, _, body = req(srv, "GET", base)
assert "took 0 s" in body or "took 1 s" in body
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
_, _, body = req(srv, "GET", base)
assert "finished 20" in body and "· took <b>" in body and "working" in body
def test_pack_page_and_list_show_the_average_duration_of_successful_runs(srv):
i1 = pidx(srv, "rtut.showcase-lighthouse")
pack = srv.app.packs[i1]
for secs, who in ((300, "a"), (900, "b")):
r = R1.create(pack, who)
start = datetime(2026, 9, 29, 10, 0, tzinfo=timezone.utc)
r.data.update(created=start.isoformat(), outcome="succeeded", finished=(start + timedelta(seconds=secs)).isoformat())
r.path.write_text(json.dumps(r.data))
slow = R1.create(pack, "c")
slow.data.update(created="2026-09-29T10:00:00+00:00", outcome="failed", finished="2026-09-29T20:00:00+00:00")
slow.path.write_text(json.dumps(slow.data))
_, _, body = req(srv, "GET", f"/pack/{i1}")
assert "average 10 min per successful run (fastest 5 min, 2 timed)" in body # the failed 10-hour run is not in the average
_, _, body = req(srv, "GET", "/")
assert "average 10 min per successful run" in body
assert "took 5 min" in req(srv, "GET", f"/pack/{i1}")[2] # the runs list shows each duration

51
tests/test_user.py Normal file
View file

@ -0,0 +1,51 @@
from pathlib import Path
import pytest
import yaml
from rtut import project as J, user
from rtut.attrs import Registry, fold, set_layers
@pytest.fixture(autouse=True)
def cfg(tmp_path, monkeypatch):
monkeypatch.setenv("RTUT_CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.delenv("RTUT_USER", raising=False)
def test_operator_comes_from_the_login_until_saved_then_from_the_file_and_the_environment_wins(monkeypatch):
monkeypatch.setattr("getpass.getuser", lambda: "Bernd Worsch")
assert user.current() == {"operator": "Bernd-Worsch", "display_name": "", "source": "login"} # always one word
user.save("bernd", "Bernd W.")
assert user.current() == {"operator": "bernd", "display_name": "Bernd W.", "source": "saved"}
monkeypatch.setenv("RTUT_USER", "alice@example.org")
assert user.current()["operator"] == "alice-example.org" and user.current()["source"] == "environment"
def test_save_validates_and_refuses_secrets():
for bad in ("two words", "", "x" * 41, "-lead"):
with pytest.raises(ValueError):
user.save(bad)
with pytest.raises(ValueError, match="secret"):
user.save("ok", "hvs.ABCDEFGHIJKLMN")
assert not user.user_file().exists()
def test_user_attributes_are_a_reusable_attribute_set_outside_projects(tmp_path):
user.save("bernd")
repo = tmp_path / "repo"
(repo / "runbooks/h").mkdir(parents=True)
(repo / "runbooks/h/pack.yaml").write_text((Path(__file__).resolve().parent.parent / "runbooks/example-hello/pack.yaml").read_text())
reg = Registry([repo], extra_sets={user.USER_SET_ID: user.attribute_set()})
f = fold(set_layers(reg, "user-attributes"))
assert f["operator"]["value"] == "bernd" and f["operator"]["requirement"] == "required"
d = {"spec": "project/v0.1", "id": "p", "title": "Project p", "environment": "rehearsal", "attribute_sets": ["user-attributes"],
"runbooks": [{"pack": "rtut.example-hello"}]}
(repo / "projects/p").mkdir(parents=True)
(repo / "projects/p/project.yaml").write_text(yaml.safe_dump(d))
reg = Registry([repo], extra_sets={user.USER_SET_ID: user.attribute_set()})
assert J.validate(reg, reg.projects["p"]) == [] # a project can reuse the user's attributes
(repo / "attribute-sets").mkdir()
(repo / "attribute-sets/user-attributes.yaml").write_text(yaml.safe_dump({"spec": "attribute-set/v0.1", "id": "user-attributes", "title": "Repo override", "attributes": {"operator": "someone"}}))
reg = Registry([repo], extra_sets={user.USER_SET_ID: user.attribute_set()})
assert fold(set_layers(reg, "user-attributes"))["operator"]["value"] == "someone" # a repository's own set takes precedence