App shell with sidebar and theme switch; attribute-set management; project rename; new pages for projects, runbooks and activity
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
a6ee6a0a6e
commit
6579618737
9 changed files with 663 additions and 13 deletions
|
|
@ -249,3 +249,67 @@ def test_project_without_a_given_name_uses_its_id_never_the_templates_title(repo
|
|||
J.instantiate(reg, "lighthouse-watch", "ab", "staging")
|
||||
with pytest.raises(ValueError, match="secret"):
|
||||
J.instantiate(reg, "lighthouse-watch", "leaky-one", "staging", description="token hvs.ABCDEFGHIJKLMN")
|
||||
|
||||
|
||||
# ---- managing attribute sets and editing projects --------------------------------------------------------------------
|
||||
|
||||
def test_used_by_finds_projects_templates_targets_and_extending_sets(repo):
|
||||
from rtut.attrs import used_by
|
||||
reg = project(repo) # north-watch uses stormy-coast, which extends harbour-defaults
|
||||
u = used_by(reg, "stormy-coast")
|
||||
assert u == {"projects": ["north-watch"], "templates": [], "sets": []}
|
||||
u = used_by(reg, "harbour-defaults")
|
||||
assert u["templates"] == ["lighthouse-watch"] and u["sets"] == ["stormy-coast"]
|
||||
|
||||
|
||||
def test_save_set_writes_validates_and_normalizes(repo):
|
||||
from rtut.attrs import save_set
|
||||
reg = Registry([repo])
|
||||
path = save_set(reg, repo, "team-defaults", "Team defaults", ["harbour-defaults"],
|
||||
{"lamp": {"value": "electric"}, "keeper": {"requirement": "required", "note": "who"}, "empty_note": {"note": "later"}})
|
||||
made = yaml.safe_load(path.read_text())
|
||||
assert path == repo / "attribute-sets/team-defaults.yaml" and made["extends"] == ["harbour-defaults"]
|
||||
assert made["attributes"]["lamp"] == "electric" # a lone value is written as a scalar
|
||||
assert made["attributes"]["keeper"] == {"requirement": "required", "note": "who"}
|
||||
reg = Registry([repo])
|
||||
assert reg.sets["team-defaults"].ok and fold(set_layers(reg, "team-defaults"))["lamp"]["value"] == "electric"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs,frag", [
|
||||
({"set_id": "Bad Id"}, "lower-case"), ({"title": "ab"}, "3 characters"), ({"extends": ["ghost"]}, "unknown attribute set"),
|
||||
({"extends": ["team-x"]}, "itself"), ({"attributes": {"Bad-Name": {"value": "x"}}}, "bad attribute name"),
|
||||
({"attributes": {"a": {"value": "hvs.ABCDEFGHIJKLMN"}}}, "secret"), ({"attributes": {"a": {"requirement": "maybe", "value": "x"}}}, "requirement"),
|
||||
({"attributes": {"a": {}}}, "give a value"),
|
||||
])
|
||||
def test_save_set_refusals(repo, kwargs, frag):
|
||||
from rtut.attrs import save_set
|
||||
args = {"set_id": "team-x", "title": "Team x", "extends": [], "attributes": {"a": {"value": "1"}}, **kwargs}
|
||||
with pytest.raises(ValueError, match=frag):
|
||||
save_set(Registry([repo]), repo, **args)
|
||||
assert not (repo / "attribute-sets/team-x.yaml").exists()
|
||||
|
||||
|
||||
def test_save_set_refuses_a_cycle(repo):
|
||||
from rtut.attrs import save_set
|
||||
reg = Registry([repo])
|
||||
with pytest.raises(ValueError, match="circular"):
|
||||
save_set(reg, repo, "harbour-defaults", "Harbour defaults", ["stormy-coast"], {"lamp": {"value": "x"}}, reg.sets["harbour-defaults"].path)
|
||||
|
||||
|
||||
def test_update_project_renames_and_keeps_everything_else(repo):
|
||||
reg = project(repo)
|
||||
doc = reg.projects["north-watch"]
|
||||
before = yaml.safe_load(doc.path.read_text())
|
||||
J.update_project(doc, "North harbour watch", "Our harbour", "Both lights burn")
|
||||
after = yaml.safe_load(doc.path.read_text())
|
||||
assert after["title"] == "North harbour watch" and after["description"] == "Our harbour" and after["goal"] == "Both lights burn"
|
||||
assert after["runbooks"] == before["runbooks"] and after["attributes"] == before["attributes"] and after["id"] == "north-watch"
|
||||
assert list(after)[:5] == ["spec", "id", "title", "description", "goal"]
|
||||
J.update_project(Registry([repo]).projects["north-watch"], "North harbour watch", "", "")
|
||||
assert "description" not in yaml.safe_load(doc.path.read_text())
|
||||
for bad, frag in (("ab", "3 characters"), ("x" * 5 + "hvs.ABCDEFGHIJKLMN", "secret")):
|
||||
with pytest.raises(ValueError, match=frag):
|
||||
J.update_project(Registry([repo]).projects["north-watch"], bad)
|
||||
reg = Registry([repo])
|
||||
with pytest.raises(ValueError, match="only a project"):
|
||||
J.update_project(reg.templates["lighthouse-watch"], "Renamed template")
|
||||
|
|
|
|||
197
tests/test_ui_shell.py
Normal file
197
tests/test_ui_shell.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import http.client
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from rtut import ui, user
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def write(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def srv(tmp_path):
|
||||
shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", tmp_path / "runbooks/show/pack.yaml") if (tmp_path / "runbooks/show").mkdir(parents=True) is None else None
|
||||
for f in (ROOT / "attribute-sets").glob("*.yaml"):
|
||||
write(tmp_path / "attribute-sets" / f.name, yaml.safe_load(f.read_text()))
|
||||
(tmp_path / "project-templates").mkdir()
|
||||
shutil.copy(ROOT / "project-templates/lighthouse-watch.yaml", tmp_path / "project-templates/lighthouse-watch.yaml")
|
||||
write(tmp_path / "projects/watch/project.yaml", {
|
||||
"spec": "project/v0.1", "id": "watch", "title": "Watch project", "description": "Old description", "environment": "rehearsal",
|
||||
"attribute_sets": ["stormy-coast", "user-attributes"], "attributes": {"logdir": "/tmp/rtut-shell-x", "keeper": "Ada"},
|
||||
"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()
|
||||
|
||||
|
||||
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})
|
||||
|
||||
|
||||
# ---- shell: sidebar, user, theme ---------------------------------------------------------------------------------
|
||||
|
||||
PAGES = ["/", "/projects", "/runbooks", "/activity", "/attribute-sets", "/user", "/pack/0", "/project/watch",
|
||||
"/template/lighthouse-watch", "/attribute-set/harbour-defaults"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_every_page_has_the_sidebar_with_sections_user_and_theme_switch(srv, path):
|
||||
st, _, body = req(srv, "GET", path)
|
||||
assert st == 200
|
||||
assert "<aside class=side>" in body and "<a class=brand href='/'>runbook-tutorials</a>" in body
|
||||
for href, label in (("/", "Home"), ("/projects", "Projects"), ("/runbooks", "Runbooks"), ("/attribute-sets", "Attribute sets"), ("/activity", "Activity")):
|
||||
assert f"<a href='{href}' data-nav>{label}</a>" in body
|
||||
assert "href='/user' data-nav" in body and "tester-login" in body # who you are, linking to your settings
|
||||
assert "data-theme-toggle" in body and "Theme: system" in body
|
||||
|
||||
|
||||
def test_the_theme_can_be_chosen_and_is_remembered_and_dark_styles_exist(srv):
|
||||
_, _, body = req(srv, "GET", "/")
|
||||
assert ":root[data-theme=dark]" in body and "prefers-color-scheme:dark" in body and ":root:not([data-theme=light])" in body
|
||||
head = body.split("</head>")[0]
|
||||
assert "localStorage.getItem('rtut-theme')" in head and "URLSearchParams" in head # applied before painting; ?theme=dark also works
|
||||
assert "system:'light',light:'dark',dark:'system'" in body # the button cycles system, light, dark
|
||||
assert "aria-current" in body # the current section is marked
|
||||
|
||||
|
||||
def test_the_sidebar_shows_the_saved_name(srv):
|
||||
post(srv, "/user", operator="bernd", display_name="Bernd W.")
|
||||
_, _, body = req(srv, "GET", "/projects")
|
||||
assert "<span class=avatar>B</span><span>Bernd W.</span>" in body
|
||||
|
||||
|
||||
def test_sections_are_separate_pages(srv):
|
||||
_, _, body = req(srv, "GET", "/projects")
|
||||
assert "<h1>Projects</h1>" in body and "Project templates" in body and "<h2>Runbooks</h2>" not in body and "<h1>Runbooks</h1>" not in body
|
||||
_, _, body = req(srv, "GET", "/runbooks")
|
||||
assert "<h1>Runbooks</h1>" in body and "Showcase: a night at the lighthouse" in body and "Project templates" not in body
|
||||
_, _, body = req(srv, "GET", "/activity")
|
||||
assert "<h1>Latest activities</h1>" in body and "Nothing has happened yet" in body
|
||||
assert post(srv, "/run/new/0", operator="bernd", p_logdir="/tmp/rtut-shell-act")[0] == 303
|
||||
_, _, 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
|
||||
|
||||
|
||||
# ---- attribute sets ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_attribute_set_list_shows_where_they_live_extends_and_who_reuses_them(srv):
|
||||
_, _, body = req(srv, "GET", "/attribute-sets")
|
||||
assert "<h1>Attribute sets</h1>" in body and "harbour-defaults" in body and "stormy-coast" in body
|
||||
assert "app (you)" in body and "user-attributes" in body # the app-level set is listed with the others
|
||||
assert "<a href='/project/watch'>watch</a>" in body and "(extends it)" in body and "(template)" in body
|
||||
assert "New attribute set" in body and "action='/attribute-sets/new'" in body
|
||||
|
||||
|
||||
def test_attribute_set_page_resolves_layers_with_sources_and_offers_editing(srv):
|
||||
_, _, body = req(srv, "GET", "/attribute-set/stormy-coast")
|
||||
assert "What this set resolves to" in body and "set:harbour-defaults" in body and "set:stormy-coast" in body
|
||||
assert ">modified<" in body # lamp/signal replaced a base value
|
||||
assert "<b>Reused by:</b> <a href='/project/watch'>watch</a>" in body and "Other things reuse this set" in body
|
||||
assert "<h2>Edit</h2>" in body and "name=n0 value='weather'" in body and "name=n3 placeholder='new_attribute'" in body # own rows + three empty ones
|
||||
assert "name=extends value='harbour-defaults'" in body
|
||||
assert req(srv, "GET", "/attribute-set/nope")[0] == 404
|
||||
|
||||
|
||||
def test_the_users_own_set_is_shown_read_only_with_a_link_to_settings(srv):
|
||||
_, _, body = req(srv, "GET", "/attribute-set/user-attributes")
|
||||
assert "tester-login" in body and "<a href='/user'>your settings page</a>" in body and "<h2>Edit</h2>" not in body
|
||||
st, _, err = post(srv, "/attribute-set/user-attributes", title="Hacked", rows="0")
|
||||
assert st == 400 and "settings page" in err
|
||||
|
||||
|
||||
def test_editing_a_set_changes_values_adds_and_removes_attributes(srv):
|
||||
path = srv.repo / "attribute-sets/stormy-coast.yaml"
|
||||
st, loc, _ = post(srv, "/attribute-set/stormy-coast", title="Stormy coast", extends="harbour-defaults", rows="6",
|
||||
n0="weather", v0="fog", r0="", t0="", n1="signal", v1="flags", r1="", t1="", x1="1", # remove signal
|
||||
n2="lamp", v2="electric", r2="optional", t2="the lamp",
|
||||
n3="cape", v3="Cape Wrath", r3="", t3="") # a new attribute
|
||||
assert st == 303 and loc == "/attribute-set/stormy-coast"
|
||||
made = yaml.safe_load(path.read_text())
|
||||
assert made["attributes"] == {"weather": "fog", "lamp": {"value": "electric", "requirement": "optional", "note": "the lamp"}, "cape": "Cape Wrath"}
|
||||
_, _, body = req(srv, "GET", "/attribute-set/stormy-coast")
|
||||
assert "Cape Wrath" in body and "the lamp" in body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fields,frag", [
|
||||
({"title": "ab"}, "3 characters"),
|
||||
({"extends": "ghost"}, "unknown attribute set"),
|
||||
({"n0": "Bad-Name", "v0": "x"}, "bad attribute name"),
|
||||
({"n0": "a", "v0": "hvs.ABCDEFGHIJKLMN"}, "secret"),
|
||||
({"n0": "a", "v0": "1", "n1": "a", "v1": "2"}, "twice"),
|
||||
({"extends": "stormy-coast"}, "itself"),
|
||||
])
|
||||
def test_edit_refusals_change_nothing_and_say_why(srv, fields, frag):
|
||||
path = srv.repo / "attribute-sets/stormy-coast.yaml"
|
||||
before = path.read_text()
|
||||
form = {"title": "Stormy coast", "extends": "harbour-defaults", "rows": "2", "n0": "weather", "v0": "storm", **fields}
|
||||
st, _, body = post(srv, "/attribute-set/stormy-coast", **form)
|
||||
assert st == 400 and frag in body and path.read_text() == before
|
||||
|
||||
|
||||
def test_a_cycle_is_refused(srv):
|
||||
st, _, body = post(srv, "/attribute-set/harbour-defaults", title="Harbour defaults", extends="stormy-coast", rows="1", n0="lamp", v0="x")
|
||||
assert st == 400 and "circular" in body
|
||||
|
||||
|
||||
def test_new_attribute_set_is_created_in_the_chosen_repository(srv):
|
||||
st, loc, _ = post(srv, "/attribute-sets/new", set_id="team-defaults", title="Team defaults", root="0", extends="harbour-defaults")
|
||||
assert st == 303 and loc == "/attribute-set/team-defaults"
|
||||
made = yaml.safe_load((srv.repo / "attribute-sets/team-defaults.yaml").read_text())
|
||||
assert made["extends"] == ["harbour-defaults"] and made["attributes"] == {}
|
||||
assert "Team defaults" in req(srv, "GET", "/attribute-sets")[2]
|
||||
st, _, err = post(srv, "/attribute-sets/new", set_id="team-defaults", title="Again", root="0")
|
||||
assert st == 400 and "already exists" in err
|
||||
assert post(srv, "/attribute-sets/new", set_id="x-set", title="X set", root="9")[0] == 400
|
||||
|
||||
|
||||
def test_attribute_pages_escape_user_text_and_need_the_token(srv):
|
||||
post(srv, "/attribute-set/harbour-defaults", title="Harbour defaults", extends="", rows="1", n0="lamp", v0="paraffin", t0="<script>alert(1)</script>")
|
||||
_, _, body = req(srv, "GET", "/attribute-set/harbour-defaults")
|
||||
assert "<script>alert(1)" not in body and "<script>alert(1)" in body
|
||||
assert req(srv, "POST", "/attribute-set/harbour-defaults", {"title": "x"})[0] == 403
|
||||
assert req(srv, "POST", "/attribute-sets/new", {"set_id": "z-set"})[0] == 403
|
||||
|
||||
|
||||
# ---- editing a project ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_a_project_can_be_renamed_and_described_from_its_page(srv):
|
||||
_, _, body = req(srv, "GET", "/project/watch")
|
||||
assert "Edit name, description and goal" in body and "name=title value='Watch project'" in body
|
||||
st, loc, _ = post(srv, "/project/watch/edit", title="North harbour watch", description="Our harbour", goal="Both lights burn")
|
||||
assert st == 303 and loc == "/project/watch"
|
||||
_, _, body = req(srv, "GET", "/project/watch")
|
||||
assert "<h1>North harbour watch</h1>" in body and "Our harbour" in body and "<b>Goal:</b> Both lights burn" in body
|
||||
assert "North harbour watch" in req(srv, "GET", "/projects")[2]
|
||||
made = yaml.safe_load((srv.repo / "projects/watch/project.yaml").read_text())
|
||||
assert made["id"] == "watch" and made["attributes"]["keeper"] == "Ada" and made["runbooks"] # nothing else changed
|
||||
|
||||
|
||||
def test_project_edit_refusals(srv):
|
||||
path = srv.repo / "projects/watch/project.yaml"
|
||||
before = path.read_text()
|
||||
st, _, body = post(srv, "/project/watch/edit", title="ab")
|
||||
assert st == 400 and "3 characters" in body
|
||||
st, _, body = post(srv, "/project/watch/edit", title="Fine name", description="hvs.ABCDEFGHIJKLMN")
|
||||
assert st == 400 and "secret" in body and path.read_text() == before
|
||||
assert req(srv, "POST", "/project/watch/edit", {"title": "Fine name"})[0] == 403
|
||||
Loading…
Add table
Add a link
Reference in a new issue