@@ -166,21 +166,29 @@ def test_launch_emits_initial_events(self) -> None:
166166 self ._launch_with_snapshots ([snap ])
167167
168168 messages = _drain_messages (self .stream )
169- # We expect: launch response, initialized, thread(s), stopped.
170- # We deliberately emit NO ``process`` event and NO ``description``
171- # on the stopped event — both surfaces (CALL STACK session row
172- # and thread row) ended up fighting for the same cell in ways
173- # VS Code wouldn't render consistently. The Tintype Snapshots
174- # sidebar group description is the single authoritative cursor
175- # surface.
169+ # We expect: launch response, initialized, thread(s), two
170+ # stopped events (bootstrap + real). The bootstrap carries a
171+ # generic ``"Snapshot"`` description so VS Code has something to
172+ # overwrite on its initial-launch race; the real event that
173+ # follows sets the actual display name the user sees in the
174+ # CALL STACK panel. We still emit NO ``process`` event — that
175+ # surface is owned by the Tintype Snapshots sidebar .
176176 event_names = [m .get ("event" ) for m in messages if m ["type" ] == "event" ]
177177 self .assertIn ("initialized" , event_names )
178178 self .assertNotIn ("process" , event_names )
179179 self .assertIn ("thread" , event_names )
180- self .assertIn ( "stopped" , event_names )
180+ self .assertEqual ( event_names . count ( "stopped" ), 2 )
181181
182- stopped_event = next (m for m in messages if m .get ("event" ) == "stopped" )
183- self .assertNotIn ("description" , stopped_event ["body" ])
182+ stopped_events = [m for m in messages if m .get ("event" ) == "stopped" ]
183+ # First is bootstrap — generic "Snapshot" label.
184+ self .assertEqual (stopped_events [0 ]["body" ]["description" ], "Snapshot" )
185+ # Second is the real one — must include a richer description.
186+ self .assertIn ("description" , stopped_events [1 ]["body" ])
187+ self .assertIn ("Snapshot" , stopped_events [1 ]["body" ]["description" ])
188+ self .assertNotEqual (
189+ stopped_events [0 ]["body" ]["description" ],
190+ stopped_events [1 ]["body" ]["description" ],
191+ )
184192
185193 def test_launch_fails_on_empty_snapshot_file (self ) -> None :
186194 reader = MagicMock ()
@@ -669,6 +677,30 @@ def test_tintype_jump_rejects_non_integer_index(self) -> None:
669677 self .assertFalse (resp ["success" ])
670678 self .assertIn ("int" , resp .get ("message" , "" ))
671679
680+ def test_tintype_jump_rejects_bool_index (self ) -> None :
681+ """``bool`` is a subclass of ``int`` in Python, so a bare
682+ ``isinstance(raw_index, int)`` check would happily accept
683+ ``True`` / ``False`` as indices 1 / 0. The handler explicitly
684+ rejects bools first; cover both values so a refactor that
685+ drops the ``isinstance(raw_index, bool)`` guard doesn't silently
686+ re-introduce the issue."""
687+ self ._first_two_snapshot_launch ()
688+ for seq_num , bad_index in ((410 , True ), (411 , False )):
689+ with self .subTest (index = bad_index ):
690+ self .stream .seek (0 )
691+ self .stream .truncate ()
692+ _send_request (
693+ self .session ,
694+ self .dispatcher ,
695+ seq = seq_num ,
696+ command = "tintypeJumpToSnapshot" ,
697+ arguments = {"index" : bad_index },
698+ )
699+ resp = _drain_messages (self .stream )[0 ]
700+ self .assertFalse (resp ["success" ])
701+ self .assertIn ("int" , resp .get ("message" , "" ))
702+ self .assertIn ("bool" , resp .get ("message" , "" ))
703+
672704 def test_attach_routes_through_launch_handler (self ) -> None :
673705 """An ``attach`` request should run the full launch flow.
674706
@@ -763,15 +795,18 @@ def test_restart_jumps_cursor_to_first_snapshot_and_re_emits_stopped(
763795 # emit one.
764796 self .assertNotIn ("process" , event_names )
765797
766- # Stopped event must NOT carry a ``description`` field (would
767- # leak into the CALL STACK session row and get stuck there).
798+ # Stopped event now carries the snapshot ``description``. Unlike
799+ # launch, restart only emits a single stopped event (no bootstrap)
800+ # — the VS Code race is an initial-launch quirk, so subsequent
801+ # stops take effect on the first try.
768802 stopped_events = [
769803 m
770804 for m in messages
771805 if m .get ("type" ) == "event" and m .get ("event" ) == "stopped"
772806 ]
773807 self .assertEqual (len (stopped_events ), 1 )
774- self .assertNotIn ("description" , stopped_events [0 ]["body" ])
808+ self .assertIn ("description" , stopped_events [0 ]["body" ])
809+ self .assertIn ("Snapshot" , stopped_events [0 ]["body" ]["description" ])
775810
776811 # The cursor must now be back on snapshot #0.
777812 self .assertEqual (self .session ._snapshot_index , 0 )
@@ -1374,5 +1409,116 @@ def test_set_default_exclude_frame_paths_replaces_wholesale(self) -> None:
13741409 self .assertEqual (hints , ["normal" , "normal" ])
13751410
13761411
1412+ class StoppedEventDescriptionTest (unittest .TestCase ):
1413+ """``stopped`` events carry a ``description`` field with the snapshot's
1414+ display name. On launch we send a bootstrap ``"Snapshot"`` event first so
1415+ VS Code has something to overwrite on its initial-launch race, followed
1416+ by the real description event the user actually sees."""
1417+
1418+ def setUp (self ) -> None :
1419+ self .stream = RecordingStream ()
1420+ self .dispatcher = Dispatcher (self .stream )
1421+ self .session = SnapshotDebugSession (self .dispatcher )
1422+ self .session .wire ()
1423+
1424+ def _launch_with_snapshots (self , snapshots : list [Any ]) -> MagicMock :
1425+ reader = MagicMock ()
1426+ reader .snapshot_count .return_value = len (snapshots )
1427+ reader .get_all_source_files .return_value = []
1428+ reader .get_all_snapshots .return_value = snapshots
1429+ reader .get_snapshot_at_index .side_effect = (
1430+ lambda i : snapshots [i ] if 0 <= i < len (snapshots ) else None
1431+ )
1432+ with (
1433+ patch ("tintype.dap.session.SnapshotReader" , return_value = reader ),
1434+ patch ("tintype.dap.session.os.path.isfile" , return_value = True ),
1435+ ):
1436+ _send_request (
1437+ self .session ,
1438+ self .dispatcher ,
1439+ seq = 1 ,
1440+ command = "launch" ,
1441+ arguments = {"pytbPath" : "/fake/snap.pytb" },
1442+ )
1443+ return reader
1444+
1445+ def test_launch_description_includes_timestamp_and_index (self ) -> None :
1446+ st = _make_stacktrace (
1447+ 100 , [_make_frame ("/a/b.py" , "foo" , 1 , {})], thread_name = "MainThread"
1448+ )
1449+ snap = _make_snapshot ([st ], ts = 1_700_000_000_000_000 )
1450+ self ._launch_with_snapshots ([snap ])
1451+
1452+ stopped_events = [
1453+ m for m in _drain_messages (self .stream ) if m .get ("event" ) == "stopped"
1454+ ]
1455+ real = stopped_events [- 1 ]["body" ]
1456+ self .assertIn ("description" , real )
1457+ self .assertIn ("Snapshot" , real ["description" ])
1458+ # HH:MM:SS.fff pattern must show up in the real-stop description.
1459+ self .assertRegex (real ["description" ], r"\d{2}:\d{2}:\d{2}\.\d{3}" )
1460+
1461+ def test_description_mentions_exception_type (self ) -> None :
1462+ st = _make_stacktrace (
1463+ 100 ,
1464+ [_make_frame ("/a/b.py" , "foo" , 1 , {})],
1465+ thread_name = "MainThread" ,
1466+ exception = KeyError ("missing" ),
1467+ )
1468+ snap = _make_snapshot ([st ])
1469+ self ._launch_with_snapshots ([snap ])
1470+
1471+ real = [m for m in _drain_messages (self .stream ) if m .get ("event" ) == "stopped" ][
1472+ - 1
1473+ ]["body" ]
1474+ self .assertIn ("KeyError" , real ["description" ])
1475+
1476+ def test_description_updates_across_snapshot_jumps (self ) -> None :
1477+ snap1 = _make_snapshot (
1478+ [
1479+ _make_stacktrace (
1480+ 100 ,
1481+ [_make_frame ("/a/b.py" , "foo" , 1 , {})],
1482+ thread_name = "MainThread" ,
1483+ )
1484+ ],
1485+ ts = 1_700_000_000_000_000 ,
1486+ )
1487+ snap2 = _make_snapshot (
1488+ [
1489+ _make_stacktrace (
1490+ 100 ,
1491+ [_make_frame ("/a/b.py" , "foo" , 2 , {})],
1492+ thread_name = "MainThread" ,
1493+ )
1494+ ],
1495+ ts = 1_700_000_005_000_000 ,
1496+ )
1497+ self ._launch_with_snapshots ([snap1 , snap2 ])
1498+
1499+ launch_desc = [
1500+ m for m in _drain_messages (self .stream ) if m .get ("event" ) == "stopped"
1501+ ][- 1 ]["body" ]["description" ]
1502+ self .assertIn ("1/2" , launch_desc )
1503+
1504+ self .stream .seek (0 )
1505+ self .stream .truncate ()
1506+ _send_request (
1507+ self .session ,
1508+ self .dispatcher ,
1509+ seq = 50 ,
1510+ command = "tintypeJumpToSnapshot" ,
1511+ arguments = {"index" : 1 },
1512+ )
1513+ jump_events = [
1514+ m for m in _drain_messages (self .stream ) if m .get ("event" ) == "stopped"
1515+ ]
1516+ # Jump emits exactly one stopped event (no bootstrap).
1517+ self .assertEqual (len (jump_events ), 1 )
1518+ jump_desc = jump_events [0 ]["body" ]["description" ]
1519+ self .assertIn ("2/2" , jump_desc )
1520+ self .assertNotEqual (launch_desc , jump_desc )
1521+
1522+
13771523if __name__ == "__main__" :
13781524 unittest .main ()
0 commit comments