@@ -2039,6 +2039,103 @@ def test_a_stale_marker_with_no_listener_falls_back_to_the_hint(
20392039 assert payload ["watch_url" ] is None
20402040
20412041
2042+ def test_a_malformed_marker_degrades_to_the_hint_instead_of_crashing (
2043+ tmp_path , monkeypatch
2044+ ):
2045+ """The marker shapes JSON allows but the reader does not: null port, a
2046+ non-object document. Both used to escape `watch_url` as a TypeError
2047+ traceback from a command that only wanted to print a courtesy URL."""
2048+ from grapharc .cli .plan import watch_url
2049+
2050+ monkeypatch .chdir (tmp_path )
2051+ marker = tmp_path / ".grapharc" / "live-server.json"
2052+ marker .parent .mkdir (parents = True , exist_ok = True )
2053+ trace = tmp_path / ".grapharc" / "runs" / "r1" / "trace.jsonl"
2054+ trace .parent .mkdir (parents = True , exist_ok = True )
2055+ trace .write_text ("" , encoding = "utf-8" )
2056+
2057+ marker .write_text (
2058+ json .dumps (
2059+ {
2060+ "url" : "http://127.0.0.1:8000" ,
2061+ "host" : "127.0.0.1" ,
2062+ "port" : None ,
2063+ "live_root" : str ((tmp_path / ".grapharc" / "runs" ).resolve ()),
2064+ }
2065+ ),
2066+ encoding = "utf-8" ,
2067+ )
2068+ assert watch_url (trace ) is None
2069+
2070+ marker .write_text (json .dumps (["not" , "an" , "object" ]), encoding = "utf-8" )
2071+ assert watch_url (trace ) is None
2072+
2073+
2074+ def test_an_unresolved_marker_root_still_matches_through_a_symlink (
2075+ tmp_path , monkeypatch
2076+ ):
2077+ """`serve` writes its root resolved; a hand-edited marker may not be. The
2078+ comparison resolves both sides now, so a symlinked spelling of the same
2079+ directory is the same directory rather than a lexical mismatch."""
2080+ import socket
2081+
2082+ from grapharc .cli .plan import watch_url
2083+
2084+ monkeypatch .chdir (tmp_path )
2085+ real = tmp_path / "real-runs"
2086+ real .mkdir ()
2087+ link = tmp_path / "link-runs"
2088+ link .symlink_to (real , target_is_directory = True )
2089+ trace = real / "r1" / "trace.jsonl"
2090+ trace .parent .mkdir (parents = True )
2091+ trace .write_text ("" , encoding = "utf-8" )
2092+
2093+ listener = socket .socket ()
2094+ listener .bind (("127.0.0.1" , 0 ))
2095+ listener .listen (1 )
2096+ port = listener .getsockname ()[1 ]
2097+ try :
2098+ marker = tmp_path / ".grapharc" / "live-server.json"
2099+ marker .parent .mkdir (parents = True , exist_ok = True )
2100+ marker .write_text (
2101+ json .dumps (
2102+ {
2103+ "url" : f"http://127.0.0.1:{ port } " ,
2104+ "host" : "127.0.0.1" ,
2105+ "port" : port ,
2106+ "live_root" : str (link ), # deliberately unresolved
2107+ }
2108+ ),
2109+ encoding = "utf-8" ,
2110+ )
2111+ url = watch_url (trace )
2112+ finally :
2113+ listener .close ()
2114+ assert url is not None and "trace=r1%2Ftrace.jsonl" in url
2115+
2116+
2117+ def test_the_hint_quotes_the_marker_port_when_the_server_is_down (
2118+ tmp_path , monkeypatch , capsys
2119+ ):
2120+ """A stale marker still names the port the operator actually serves on;
2121+ an instruction quoting a different port than their own `grapharc serve`
2122+ command is a wrong instruction."""
2123+ import socket
2124+
2125+ monkeypatch .chdir (tmp_path )
2126+ probe = socket .socket ()
2127+ probe .bind (("127.0.0.1" , 0 ))
2128+ dead_port = probe .getsockname ()[1 ]
2129+ probe .close ()
2130+ _write_live_marker (tmp_path , port = dead_port )
2131+ code = main (["plan" , "look into it" , "--scripted" ])
2132+ printed = capsys .readouterr ().out
2133+ assert code == 0
2134+ watch = next (line for line in printed .splitlines () if line .startswith ("watch" ))
2135+ assert "grapharc serve --live-root .grapharc/runs" in watch
2136+ assert f"127.0.0.1:{ dead_port } /live/view?trace=" in watch
2137+
2138+
20422139def test_a_trace_outside_the_live_root_gets_no_exact_url (tmp_path , monkeypatch , capsys ):
20432140 import socket
20442141
0 commit comments