Active user attribute set, latest activities, guarded changes to used values, artefact links, durations and averages; irreversible showcase step
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:
parent
6716cee921
commit
8d62c1fbe8
22 changed files with 1025 additions and 35 deletions
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal 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")
|
||||
93
tests/test_activity_artefacts.py
Normal file
93
tests/test_activity_artefacts.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
263
tests/test_ui_round3.py
Normal 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
51
tests/test_user.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue