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 "bernd" 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 Bernd W. (bernd)" in body and "change" in body assert "Your name (one word)" not in body and "yes" 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("

")[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("

Runbooks

")[0] assert "erin 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"open" 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"Open" in body and "?download=1'>Download" 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 " 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 " 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