2026-09-29 17:38:06 +02:00
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
2026-09-29 17:40:27 +02:00
assert " ' /attribute-sets ' :[ ' /attribute-set/ ' ] " in body and " ' /projects ' :[ ' /project/ ' , ' /template/ ' ] " in body # detail pages mark their section too
2026-09-29 17:38:06 +02:00
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 " , " / " )
2026-09-29 18:03:12 +02:00
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
2026-09-29 17:38:06 +02:00
# ---- 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