Lock and seal closed runs; feedback with the closing form; Home headlines link to their sections; drop the back link
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
b7edb8f023
commit
711c4e1149
13 changed files with 381 additions and 42 deletions
|
|
@ -42,16 +42,17 @@ def test_finalize_rules(pk):
|
|||
R.finalize(r, "abandoned") # already finished
|
||||
|
||||
|
||||
def test_finished_run_is_read_only_but_takes_feedback(pk):
|
||||
def test_finished_run_is_read_only_and_feedback_is_locked_too(pk):
|
||||
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
|
||||
for call in (lambda: R.start(r, "read-the-sign"), lambda: R.add_capture(r, "read-the-sign", "x")):
|
||||
with pytest.raises(R.RunError, match="finished"):
|
||||
call()
|
||||
R.add_feedback(r, "poor", "gave up: unclear") # feedback stays possible
|
||||
with pytest.raises(R.RunError, match="closed and locked"):
|
||||
R.add_feedback(r, "poor", "gave up: unclear") # feedback goes in before closing, or with the closing form
|
||||
|
||||
|
||||
def test_scorecard_counts_every_outcome_and_stale_open_runs(pk):
|
||||
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded"); R.add_feedback(ok, "good")
|
||||
ok = R.create(pk, "a"); play_all(ok); R.finalize(ok, "succeeded", rating="good")
|
||||
bad = R.create(pk, "b"); R.start(bad, "read-the-sign"); R.fail(bad, "read-the-sign", "x"); R.finalize(bad, "failed")
|
||||
gone = R.create(pk, "c"); R.finalize(gone, "abandoned")
|
||||
stalled = R.create(pk, "d")
|
||||
|
|
@ -151,3 +152,72 @@ def test_unfinished_run_elapsed_runs_to_now_and_no_average_without_succeeded_run
|
|||
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
|
||||
|
||||
|
||||
# ---- a closed run is locked and sealed --------------------------------------------------------------------------
|
||||
|
||||
def test_closing_records_feedback_with_the_outcome_and_validates_before_changing_anything(pk):
|
||||
r = R.create(pk, "bernd")
|
||||
with pytest.raises(R.RunError, match="rating must be"):
|
||||
R.finalize(r, "abandoned", rating="great")
|
||||
assert not r.data.get("outcome") and "seal" not in r.data # a refused close changed nothing
|
||||
R.finalize(r, "abandoned", "gave up", rating="poor", comment="step 2 was unclear")
|
||||
assert R.latest_feedback(r)["comment"] == "step 2 was unclear" and r.data["outcome"] == "abandoned"
|
||||
assert R.load(pk, r.data["run_id"]).data["feedback"][0]["rating"] == "poor"
|
||||
|
||||
|
||||
def test_a_closed_record_cannot_be_saved_with_changes_but_the_exercise_marker_is_allowed(pk):
|
||||
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
|
||||
r2 = R.load(pk, r.data["run_id"])
|
||||
r2.data["steps"]["read-the-sign"]["state"] = "done"
|
||||
with pytest.raises(R.RunError, match="closed and locked"):
|
||||
r2.save()
|
||||
r3 = R.load(pk, r.data["run_id"])
|
||||
r3.data["operator"] = "someone-else"
|
||||
with pytest.raises(R.RunError, match="operator"):
|
||||
r3.save()
|
||||
assert R.load(pk, r.data["run_id"]).data["operator"] == "bernd" # nothing reached the disk
|
||||
r4 = R.load(pk, r.data["run_id"])
|
||||
r4.data["exercise_recorded"] = "x"
|
||||
r4.save(allow=("exercise_recorded",)) # the one permitted later update
|
||||
assert R.load(pk, r.data["run_id"]).data["exercise_recorded"] == "x" and R.seal_state(R.load(pk, r.data["run_id"])) == "sealed"
|
||||
|
||||
|
||||
def test_closing_seals_the_file_read_only_and_tampering_is_detected(pk):
|
||||
import json, stat
|
||||
r = R.create(pk, "bernd")
|
||||
assert R.seal_state(r) == "open" and r.path.stat().st_mode & stat.S_IWUSR
|
||||
R.finalize(r, "abandoned")
|
||||
assert R.seal_state(r) == "sealed" and not (r.path.stat().st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
|
||||
assert len(r.data["seal"]) == 64
|
||||
r.path.chmod(0o644) # someone edits the file by hand
|
||||
data = json.loads(r.path.read_text()); data["operator"] = "forged"
|
||||
r.path.write_text(json.dumps(data))
|
||||
assert R.seal_state(R.load(pk, r.data["run_id"])) == "broken"
|
||||
|
||||
|
||||
def test_a_run_closed_before_sealing_existed_is_reported_as_unsealed_not_broken(pk):
|
||||
import json
|
||||
r = R.create(pk, "bernd"); R.finalize(r, "abandoned")
|
||||
r.path.chmod(0o644)
|
||||
data = json.loads(r.path.read_text()); data.pop("seal"); r.path.write_text(json.dumps(data))
|
||||
assert R.seal_state(R.load(pk, r.data["run_id"])) == "unsealed"
|
||||
|
||||
|
||||
def test_a_tampered_run_no_longer_counts_as_maturity_evidence(pk):
|
||||
import json
|
||||
from rtut import maturity as M
|
||||
M.add_review(pk, "alice")
|
||||
r = R.create(pk, "bob", {"logdir": "/tmp/rtut-lock-mat"}, {"project": "lab", "environment": "rehearsal"})
|
||||
for s in pk.data["steps"]:
|
||||
sid = s["id"]; R.start(r, sid)
|
||||
cmd = R.command_for(r, sid)
|
||||
if cmd:
|
||||
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
||||
R.verify(r, sid, confirm=True, manual_ok=True if s["verify"]["expect"] == "manual" else None)
|
||||
R.complete(r, sid, {n: "1" for n in s.get("evidence", [])})
|
||||
R.finalize(r, "succeeded")
|
||||
assert M.maturity(pk)["evidence"]["rehearsal_runs"] == 1
|
||||
r.path.chmod(0o644)
|
||||
data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data))
|
||||
assert M.maturity(pk)["evidence"]["clean_runs"] == 0 # changed after closing: not evidence
|
||||
|
|
|
|||
|
|
@ -205,18 +205,18 @@ def test_feedback_form_after_all_steps_done_and_recording(srv):
|
|||
t, loc, _ = _showcase_run(srv)
|
||||
base = loc.split("#")[0]
|
||||
_, _, body = req(srv, "GET", base)
|
||||
assert "How was this runbook?" not in body # not before the run is complete
|
||||
assert "<h2>How was this runbook?</h2>" not in body # not before the run is complete
|
||||
_finish_showcase(srv, t, base)
|
||||
_, _, body = req(srv, "GET", base)
|
||||
assert "All steps done." in body and "How was this runbook?" in body
|
||||
assert body.index("All steps done.") < body.index("How was this runbook?")
|
||||
assert "All steps done." in body and "<h2>How was this runbook?</h2>" in body
|
||||
assert body.index("All steps done.") < body.index("<h2>How was this runbook?</h2>")
|
||||
assert "🙂" in body and "😐" in body and "🙁" in body and "name=comment" in body
|
||||
st, loc2, _ = req(srv, "POST", base + "/feedback", {"_t": t, "rating": "neutral", "comment": "step 3 was unclear"})
|
||||
assert st == 303 and loc2.endswith("#feedback")
|
||||
_, _, body = req(srv, "GET", base)
|
||||
assert "Thank you." in body and "step 3 was unclear" in body
|
||||
assert "How was this runbook?" not in body # the section closed once feedback was collected
|
||||
assert "<a href='/'>← Back to the runbook list</a>" in body # the probable next step
|
||||
assert "<h2>How was this runbook?</h2>" not in body # the section closed once feedback was collected
|
||||
assert "Back to the runbook list" not in body # the sidebar's Home replaces that link
|
||||
assert "Change my feedback" in body # the form is still reachable, folded away
|
||||
assert req(srv, "GET", "/")[0] == 200
|
||||
|
||||
|
|
|
|||
152
tests/test_ui_locked.py
Normal file
152
tests/test_ui_locked.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import http.client
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
|
||||
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")
|
||||
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 req(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()
|
||||
|
||||
|
||||
def post(s, path, **form):
|
||||
return req(s, "POST", path, {"_t": s.app.token, **form})
|
||||
|
||||
|
||||
def start(s, pack_id):
|
||||
i = s.app.pack_index(pack_id)
|
||||
logdir = f"/tmp/rtut-lock-{uuid.uuid4().hex[:8]}"
|
||||
_scratch.append(logdir)
|
||||
st, loc, _ = post(s, f"/run/new/{i}", operator="bernd", p_logdir=logdir)
|
||||
assert st == 303
|
||||
return loc.split("#")[0], i
|
||||
|
||||
|
||||
def run_of(s, base):
|
||||
return R1.load(s.app.packs[int(base.split("/")[2])], base.rsplit("/", 1)[1])
|
||||
|
||||
|
||||
# ---- Home headlines are shortcuts ---------------------------------------------------------------------------
|
||||
|
||||
def test_home_headlines_link_to_the_sidebar_sections_and_the_section_pages_do_not(srv):
|
||||
_, _, home = req(srv, "GET", "/")
|
||||
assert "<h1 class=hl><a href='/projects'>Projects</a></h1>" in home
|
||||
assert "<h2 class=hl><a href='/projects'>Project templates</a></h2>" in home
|
||||
assert "<a href='/activity'>Latest activities</a>" in home and "<h2 class=hl><a href='/runbooks'>Runbooks</a></h2>" in home
|
||||
_, _, projects = req(srv, "GET", "/projects")
|
||||
assert "<h1>Projects</h1>" in projects and "class=hl" not in projects # on its own page the headline is just a headline
|
||||
assert "<h1>Runbooks</h1>" in req(srv, "GET", "/runbooks")[2]
|
||||
assert "<h1>Latest activities" in req(srv, "GET", "/activity")[2]
|
||||
|
||||
|
||||
# ---- no "back to the runbook list" -------------------------------------------------------------------------------
|
||||
|
||||
def test_there_is_no_back_to_the_runbook_list_link_in_either_run_view(srv):
|
||||
for pack in ("rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"):
|
||||
base, i = start(srv, pack)
|
||||
assert post(srv, f"{base}/feedback", rating="good", comment="fine")[0] == 303
|
||||
assert "Back to the runbook list" not in req(srv, "GET", base)[2]
|
||||
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
|
||||
assert "Back to the runbook list" not in req(srv, "GET", base)[2]
|
||||
|
||||
|
||||
# ---- closed runs are locked ------------------------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
|
||||
def test_a_closed_run_shows_its_feedback_read_only_without_a_change_option(srv, pack):
|
||||
base, _ = start(srv, pack)
|
||||
assert post(srv, f"{base}/feedback", rating="neutral", comment="a bit long")[0] == 303
|
||||
assert "Change my feedback" in req(srv, "GET", base)[2] # while open it can still be changed
|
||||
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
|
||||
body = req(srv, "GET", base)[2]
|
||||
assert "Change my feedback" not in body and f"action='{base}/feedback'" not in body
|
||||
assert "<h2>Feedback</h2>" in body and "a bit long" in body and "closed and locked, so its feedback can no longer be changed" in body
|
||||
st, _, err = post(srv, f"{base}/feedback", rating="good", comment="changed my mind")
|
||||
assert st == 400 and "closed and locked" in err
|
||||
assert run_of(srv, base).data["feedback"][0]["comment"] == "a bit long" # nothing reached the record
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
|
||||
def test_a_closed_run_without_feedback_shows_no_feedback_section(srv, pack):
|
||||
base, _ = start(srv, pack)
|
||||
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
|
||||
body = req(srv, "GET", base)[2]
|
||||
assert "<h2>Feedback</h2>" not in body and "<h2>How was this runbook?</h2>" not in body and "Change my feedback" not in body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
|
||||
def test_the_closing_form_takes_optional_feedback_so_abandoned_runs_are_not_silent(srv, pack):
|
||||
base, _ = start(srv, pack)
|
||||
body = req(srv, "GET", base)[2]
|
||||
assert "How was this runbook? <span class=note>(optional; it cannot be added after closing)</span>" in body and "name=fb_comment" in body
|
||||
assert post(srv, f"{base}/finalize", outcome="abandoned", rating="poor", fb_comment="step 1 was unclear")[0] == 303
|
||||
body = req(srv, "GET", base)[2]
|
||||
assert "<h2>Feedback</h2>" in body and "step 1 was unclear" in body
|
||||
assert run_of(srv, base).data["feedback"][0]["rating"] == "poor"
|
||||
|
||||
|
||||
def test_the_closing_form_does_not_ask_again_when_feedback_was_already_given(srv):
|
||||
base, _ = start(srv, "rtut.showcase-lighthouse-v2")
|
||||
post(srv, f"{base}/feedback", rating="good", comment="ok")
|
||||
assert "name=fb_comment" not in req(srv, "GET", base)[2]
|
||||
|
||||
|
||||
def test_a_bad_rating_on_closing_refuses_the_whole_close(srv):
|
||||
base, _ = start(srv, "rtut.showcase-lighthouse-v2")
|
||||
st, _, err = post(srv, f"{base}/finalize", outcome="abandoned", rating="great")
|
||||
assert st == 400 and "rating must be" in err
|
||||
assert not run_of(srv, base).data.get("outcome") # still open
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pack", ["rtut.showcase-lighthouse", "rtut.showcase-lighthouse-v2"])
|
||||
def test_a_closed_run_says_it_is_locked_and_refuses_every_change(srv, pack):
|
||||
base, _ = start(srv, pack)
|
||||
post(srv, f"{base}/finalize", outcome="abandoned")
|
||||
body = req(srv, "GET", base)[2]
|
||||
assert "closed and locked" in body and "its record is sealed and cannot be changed" in body
|
||||
first = "read-the-sign"
|
||||
for path, form in ((f"{base}/{first}/start", {}), (f"{base}/mode", {"to": "auto-confirm"}), (f"{base}/data", {"d_weather": "clear"}),
|
||||
(f"{base}/{first}/capture", {"output": "x"}), (f"{base}/finalize", {"outcome": "succeeded"})):
|
||||
st, _, err = post(srv, path, **form)
|
||||
assert st in (400,) and ("closed" in err or "finished" in err or "already" in err), path
|
||||
assert run_of(srv, base).data["outcome"] == "abandoned"
|
||||
|
||||
|
||||
def test_the_record_file_is_read_only_after_closing_and_tampering_is_called_out(srv):
|
||||
base, _ = start(srv, "rtut.showcase-lighthouse-v2")
|
||||
post(srv, f"{base}/finalize", outcome="abandoned")
|
||||
r = run_of(srv, base)
|
||||
assert r.path.stat().st_mode & 0o222 == 0 and R1.seal_state(r) == "sealed"
|
||||
assert "changed outside the app" not in req(srv, "GET", base)[2]
|
||||
r.path.chmod(0o644)
|
||||
data = json.loads(r.path.read_text()); data["operator"] = "forged"; r.path.write_text(json.dumps(data))
|
||||
body = req(srv, "GET", base)[2]
|
||||
assert "changed outside the app after it was sealed" in body and "no longer counts towards maturity" in body
|
||||
|
|
@ -59,7 +59,7 @@ def make_project(s, pid="harbour", host=None):
|
|||
|
||||
def test_index_has_projects_templates_runbooks_and_maturity(psrv):
|
||||
_, _, body = req(psrv, "GET", "/")
|
||||
assert "<h1>Projects</h1>" in body and "Project templates" in body and "<h2>Runbooks</h2>" in body
|
||||
assert "<h1 class=hl><a href='/projects'>Projects</a></h1>" in body and "Project templates" in body and "<a href='/runbooks'>Runbooks</a>" in body
|
||||
assert "/template/lighthouse-watch" in body
|
||||
assert ">draft<" in body # maturity pill on every native runbook
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ def test_sections_are_separate_pages(srv):
|
|||
_, _, body = req(srv, "GET", "/activity")
|
||||
assert "<h1>Latest activities" in body and "(times in UTC)" in body and "started a run" in body
|
||||
_, _, body = req(srv, "GET", "/")
|
||||
assert "<h1>Projects</h1>" in body and "Latest activities" in body and "<h2>Runbooks</h2>" in body # Home shows all three
|
||||
assert "<h1 class=hl><a href='/projects'>Projects</a></h1>" in body and "Latest activities" in body and "<h2 class=hl><a href='/runbooks'>Runbooks</a></h2>" in body # Home shows all three, as shortcuts
|
||||
|
||||
|
||||
# ---- attribute sets ----------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -314,9 +314,9 @@ def test_finishing_a_completed_run_feedback_and_the_read_only_result(s2):
|
|||
do_step(s2, base, "close-log", phrase=PHRASE)
|
||||
pass_gate(s2, base, "night-on-record")
|
||||
body = page(s2, base)
|
||||
assert "Everything is done." in body and "Finish: succeeded" in body and "How was this runbook?" in body
|
||||
assert "Everything is done." in body and "Finish: succeeded" in body and "<h2>How was this runbook?</h2>" in body
|
||||
assert post(s2, f"{base}/feedback", rating="good", comment="clear")[0] == 303
|
||||
assert "Back to the runbook list" in page(s2, base)
|
||||
assert "Back to the runbook list" not in page(s2, base) and "Change my feedback" in page(s2, base) # still open: changeable
|
||||
assert post(s2, f"{base}/finalize", outcome="succeeded", note="smooth")[0] == 303
|
||||
body = page(s2, base)
|
||||
assert "Run finished: succeeded" in body and "Start this step" not in body
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue