@@ -319,3 +319,237 @@ def test_equivalent_inputs_produce_identical_hashes(self) -> None:
319319
320320 def test_exports_desktop_schema_version (self ) -> None :
321321 assert DESKTOP_SCHEMA_VERSION == "desktop:v1.0"
322+
323+
324+ class TestPhase4Verification :
325+ """Phase 4 verification tests for cross-platform, ANSI edge cases, and UI tree determinism."""
326+
327+ class TestCrossPlatformPathNormalization :
328+ """Cross-platform path normalization tests."""
329+
330+ def test_normalizes_unix_paths_with_dot_components (self ) -> None :
331+ result = normalize_path ("/home/user/./project/../project/src" )
332+ assert "/." not in result
333+ assert "/.." not in result
334+ assert "project" in result
335+ assert "src" in result
336+
337+ def test_handles_multiple_consecutive_slashes (self ) -> None :
338+ result = normalize_path ("/foo//bar///baz" )
339+ assert "//" not in result
340+
341+ def test_preserves_absolute_paths (self ) -> None :
342+ result = normalize_path ("/absolute/path/to/file" )
343+ assert result .startswith ("/" )
344+
345+ def test_handles_empty_path_components (self ) -> None :
346+ result = normalize_path ("/foo/./bar" )
347+ assert result == "/foo/bar"
348+
349+ def test_handles_trailing_slashes_consistently (self ) -> None :
350+ with_slash = normalize_path ("/foo/bar/" )
351+ without_slash = normalize_path ("/foo/bar" )
352+ assert with_slash .rstrip ("/" ) == without_slash .rstrip ("/" )
353+
354+ def test_handles_root_path (self ) -> None :
355+ result = normalize_path ("/" )
356+ assert result == "/"
357+
358+ class TestAnsiStrippingEdgeCases :
359+ """ANSI stripping edge case tests."""
360+
361+ def test_strips_256_color_codes (self ) -> None :
362+ assert strip_ansi ("\x1b [38;5;196mRed256\x1b [0m" ) == "Red256"
363+ assert strip_ansi ("\x1b [48;5;21mBlueBg\x1b [0m" ) == "BlueBg"
364+
365+ def test_strips_24bit_true_color_codes (self ) -> None :
366+ assert strip_ansi ("\x1b [38;2;255;100;50mOrange\x1b [0m" ) == "Orange"
367+
368+ def test_strips_bold_italic_underline_codes (self ) -> None :
369+ assert strip_ansi ("\x1b [1mBold\x1b [0m" ) == "Bold"
370+ assert strip_ansi ("\x1b [3mItalic\x1b [0m" ) == "Italic"
371+ assert strip_ansi ("\x1b [4mUnderline\x1b [0m" ) == "Underline"
372+
373+ def test_strips_cursor_movement_codes (self ) -> None :
374+ assert strip_ansi ("\x1b [5ACursor Up" ) == "Cursor Up"
375+ assert strip_ansi ("\x1b [3BCursor Down" ) == "Cursor Down"
376+ assert strip_ansi ("\x1b [2CCursor Forward" ) == "Cursor Forward"
377+ assert strip_ansi ("\x1b [1DCursor Back" ) == "Cursor Back"
378+
379+ def test_strips_erase_codes (self ) -> None :
380+ assert strip_ansi ("\x1b [2JClear Screen" ) == "Clear Screen"
381+ assert strip_ansi ("\x1b [KClear Line" ) == "Clear Line"
382+
383+ def test_strips_scroll_codes (self ) -> None :
384+ assert strip_ansi ("\x1b [3SScroll Up" ) == "Scroll Up"
385+ assert strip_ansi ("\x1b [2TScroll Down" ) == "Scroll Down"
386+
387+ def test_handles_multiple_ansi_codes_in_sequence (self ) -> None :
388+ complex_text = "\x1b [1m\x1b [31m\x1b [4mBold Red Underline\x1b [0m"
389+ assert strip_ansi (complex_text ) == "Bold Red Underline"
390+
391+ def test_handles_ansi_codes_at_start_middle_and_end (self ) -> None :
392+ text = "\x1b [32mStart\x1b [0m Middle \x1b [33mEnd\x1b [0m"
393+ assert strip_ansi (text ) == "Start Middle End"
394+
395+ def test_preserves_text_without_ansi_codes (self ) -> None :
396+ plain = "No escape codes here: [not ansi] {also not}"
397+ assert strip_ansi (plain ) == plain
398+
399+ class TestUITreeDeterminism :
400+ """UI tree determinism tests."""
401+
402+ def test_produces_same_hash_regardless_of_child_order (self ) -> None :
403+ tree1 = {
404+ "role" : "window" ,
405+ "name" : "Main" ,
406+ "children" : [
407+ {"role" : "button" , "name" : "Save" , "children" : []},
408+ {"role" : "button" , "name" : "Cancel" , "children" : []},
409+ {"role" : "textbox" , "name" : "Input" , "children" : []},
410+ ],
411+ }
412+ tree2 = {
413+ "role" : "window" ,
414+ "name" : "Main" ,
415+ "children" : [
416+ {"role" : "textbox" , "name" : "Input" , "children" : []},
417+ {"role" : "button" , "name" : "Cancel" , "children" : []},
418+ {"role" : "button" , "name" : "Save" , "children" : []},
419+ ],
420+ }
421+
422+ canonical1 = canonicalize_accessibility_node (tree1 )
423+ canonical2 = canonicalize_accessibility_node (tree2 )
424+
425+ assert canonical1 == canonical2
426+
427+ def test_normalizes_role_case (self ) -> None :
428+ upper = canonicalize_accessibility_node (
429+ {"role" : "BUTTON" , "name" : "Click" , "children" : []}
430+ )
431+ lower = canonicalize_accessibility_node (
432+ {"role" : "button" , "name" : "Click" , "children" : []}
433+ )
434+
435+ assert upper .role == lower .role
436+ assert upper .role == "button"
437+
438+ def test_normalizes_name_whitespace_and_case (self ) -> None :
439+ node1 = canonicalize_accessibility_node (
440+ {"role" : "button" , "name" : " Click Me " , "children" : []}
441+ )
442+ node2 = canonicalize_accessibility_node (
443+ {"role" : "button" , "name" : "click me" , "children" : []}
444+ )
445+
446+ assert node1 .name_norm == node2 .name_norm
447+ assert node1 .name_norm == "click me"
448+
449+ def test_handles_empty_children_list (self ) -> None :
450+ node = canonicalize_accessibility_node (
451+ {"role" : "button" , "name" : "Test" , "children" : []}
452+ )
453+ assert node .children == ()
454+
455+ def test_handles_missing_children (self ) -> None :
456+ node = canonicalize_accessibility_node ({"role" : "button" , "name" : "Test" })
457+ assert node .children == ()
458+
459+ def test_handles_none_name (self ) -> None :
460+ node = canonicalize_accessibility_node ({"role" : "button" , "name" : None , "children" : []})
461+ assert node .name_norm == ""
462+
463+ def test_produces_identical_desktop_hashes_for_same_content (self ) -> None :
464+ snap1 = {
465+ "app_name" : " FIREFOX " ,
466+ "window_title" : " GitHub - Pull Requests " ,
467+ "focused_role" : "BUTTON" ,
468+ "focused_name" : " MERGE " ,
469+ }
470+ snap2 = {
471+ "app_name" : "firefox" ,
472+ "window_title" : "github - pull requests" ,
473+ "focused_role" : "button" ,
474+ "focused_name" : "merge" ,
475+ }
476+
477+ assert compute_desktop_state_hash (snap1 ) == compute_desktop_state_hash (snap2 )
478+
479+ def test_sorts_nested_children_deterministically (self ) -> None :
480+ tree = {
481+ "role" : "window" ,
482+ "children" : [
483+ {
484+ "role" : "panel" ,
485+ "name" : "B" ,
486+ "children" : [
487+ {"role" : "button" , "name" : "Z" , "children" : []},
488+ {"role" : "button" , "name" : "A" , "children" : []},
489+ ],
490+ },
491+ {
492+ "role" : "panel" ,
493+ "name" : "A" ,
494+ "children" : [
495+ {"role" : "link" , "name" : "Y" , "children" : []},
496+ {"role" : "link" , "name" : "X" , "children" : []},
497+ ],
498+ },
499+ ],
500+ }
501+
502+ canonical = canonicalize_accessibility_node (tree )
503+
504+ # First-level: panel A should come before panel B
505+ assert canonical .children [0 ].name_norm == "a"
506+ assert canonical .children [1 ].name_norm == "b"
507+
508+ # Second-level: within panel A, link X should come before link Y
509+ assert canonical .children [0 ].children [0 ].name_norm == "x"
510+ assert canonical .children [0 ].children [1 ].name_norm == "y"
511+
512+ # Within panel B, button A should come before button Z
513+ assert canonical .children [1 ].children [0 ].name_norm == "a"
514+ assert canonical .children [1 ].children [1 ].name_norm == "z"
515+
516+ class TestTerminalHashStability :
517+ """Terminal hash stability tests."""
518+
519+ def test_identical_hashes_for_varying_whitespace (self ) -> None :
520+ snap1 = {"session_id" : "s1" , "command" : " npm run build " }
521+ snap2 = {"session_id" : "s1" , "command" : "npm run build" }
522+
523+ assert compute_terminal_state_hash (snap1 ) == compute_terminal_state_hash (snap2 )
524+
525+ def test_identical_hashes_for_transcripts_with_ansi_removed (self ) -> None :
526+ snap1 = {
527+ "session_id" : "s1" ,
528+ "command" : "test" ,
529+ "transcript" : "\x1b [32m✓\x1b [0m Tests passed" ,
530+ }
531+ snap2 = {
532+ "session_id" : "s1" ,
533+ "command" : "test" ,
534+ "transcript" : "✓ Tests passed" ,
535+ }
536+
537+ assert compute_terminal_state_hash (snap1 ) == compute_terminal_state_hash (snap2 )
538+
539+ def test_different_hashes_for_different_commands (self ) -> None :
540+ snap1 = {"session_id" : "s1" , "command" : "npm install" }
541+ snap2 = {"session_id" : "s1" , "command" : "npm update" }
542+
543+ assert compute_terminal_state_hash (snap1 ) != compute_terminal_state_hash (snap2 )
544+
545+ def test_different_hashes_for_different_session_ids (self ) -> None :
546+ snap1 = {"session_id" : "session-1" , "command" : "test" }
547+ snap2 = {"session_id" : "session-2" , "command" : "test" }
548+
549+ assert compute_terminal_state_hash (snap1 ) != compute_terminal_state_hash (snap2 )
550+
551+ def test_handles_timestamps_in_transcripts (self ) -> None :
552+ snap1 = {"session_id" : "s1" , "transcript" : "Build completed at 10:30:45" }
553+ snap2 = {"session_id" : "s1" , "transcript" : "Build completed at 14:22:01" }
554+
555+ assert compute_terminal_state_hash (snap1 ) == compute_terminal_state_hash (snap2 )
0 commit comments