@@ -395,3 +395,181 @@ def test_extract_project_from_cwd_rejects_dotdot(self):
395395 def test_extract_project_from_cwd_no_hyphens_passthrough (self ):
396396 """Project names without hyphens should pass through."""
397397 assert extract_project_from_cwd ("/Users/user/myproject" ) == "myproject"
398+
399+ def test_extract_project_from_cwd_non_string_types (self ):
400+ """Should return empty string for invalid types like int, list, dict."""
401+ assert extract_project_from_cwd (123 ) == ""
402+ assert extract_project_from_cwd (["list" ]) == ""
403+ assert extract_project_from_cwd ({"dict" : "value" }) == ""
404+
405+ def test_extract_project_from_cwd_accepts_path_objects (self ):
406+ """Should accept Path and os.PathLike objects."""
407+ assert extract_project_from_cwd (Path ("/Users/user/my-app" )) == "my_app"
408+ assert extract_project_from_cwd (Path ("/Users/user/Projects/project" )) == "project"
409+
410+ def test_extract_project_from_cwd_embedded_null (self ):
411+ """Should handle strings with embedded null safely."""
412+ # Path() may raise or behave unexpectedly with embedded NUL
413+ result = extract_project_from_cwd ("/Users/user\x00 /project" )
414+ # Just verify it doesn't crash - result may vary by platform
415+ assert isinstance (result , str )
416+
417+
418+ class TestGetProjectNameFallback :
419+ """Tests for get_project_name fallback behavior."""
420+
421+ def test_accepts_marker_directories_as_project_names (self ):
422+ """Common directory names (code, src, etc.) are valid project names."""
423+ # These are legitimate project names when they're the working directory
424+ assert get_project_name (Path ("-Users-wesm-code" )) == "code"
425+ assert get_project_name (Path ("-Users-wesm-projects" )) == "projects"
426+ assert get_project_name (Path ("-Users-wesm-repos" )) == "repos"
427+ assert get_project_name (Path ("-Users-wesm-src" )) == "src"
428+ assert get_project_name (Path ("-Users-wesm-work" )) == "work"
429+ assert get_project_name (Path ("-Users-wesm-dev" )) == "dev"
430+
431+ def test_skips_system_directories_in_fallback (self ):
432+ """System directories (users, home, var, tmp, private) should be skipped."""
433+ assert get_project_name (Path ("-Users-wesm" )) == "wesm"
434+ assert get_project_name (Path ("-home-ubuntu" )) == "ubuntu"
435+ # "home" is a system directory and should be skipped even if it appears last
436+ # (unlike "code", "src" which are valid project names)
437+ assert get_project_name (Path ("-Users-wesm-home" )) == "wesm"
438+ assert get_project_name (Path ("-Users-wesm-tmp" )) == "wesm"
439+ assert get_project_name (Path ("-Users-wesm-var" )) == "wesm"
440+
441+ def test_marker_extraction_takes_precedence (self ):
442+ """When a marker has content after it, that content is the project name."""
443+ # code/my-app -> my_app (marker extraction works)
444+ assert get_project_name (Path ("-Users-wesm-code-my-app" )) == "my_app"
445+ assert get_project_name (Path ("-Users-wesm-src-project" )) == "project"
446+
447+
448+ class TestReparseBehavior :
449+ """Tests for reparse detection of bad project names."""
450+
451+ def test_sync_reparses_users_prefix (self , tmp_path ):
452+ """Sessions with _Users* project names should be reparsed."""
453+ from agent_session_viewer import db
454+ from agent_session_viewer import sync as sync_module
455+
456+ # Create a session file with cwd
457+ project_dir = tmp_path / "-Users-alice-code-my-app"
458+ project_dir .mkdir ()
459+ session_file = project_dir / "test-session.jsonl"
460+ session_file .write_text (
461+ '{"type": "user", "cwd": "/Users/alice/code/my-app", "message": {"content": "hello"}, "timestamp": "2025-01-01T00:00:00Z"}\n '
462+ )
463+
464+ # Mock db functions and CLAUDE_PROJECTS_DIR
465+ with patch .object (sync_module , "CLAUDE_PROJECTS_DIR" , tmp_path ), \
466+ patch .object (sync_module , "SESSIONS_DIR" , tmp_path / "sessions" ), \
467+ patch .object (db , "get_session_file_info" ) as mock_file_info , \
468+ patch .object (db , "get_session" ) as mock_get_session , \
469+ patch .object (db , "upsert_session" ) as mock_upsert , \
470+ patch .object (db , "delete_session_messages" ), \
471+ patch .object (db , "insert_messages_batch" ):
472+
473+ # Simulate: file hash matches but stored project is bad
474+ file_hash = sync_module .compute_file_hash (session_file )
475+ mock_file_info .return_value = (session_file .stat ().st_size , file_hash )
476+ mock_get_session .return_value = {"project" : "_Users_alice_code_my_app" }
477+
478+ result = sync_module .sync_session_file (
479+ session_file , "_Users_alice_code_my_app" , "local"
480+ )
481+
482+ # Should have reparsed (not skipped) and updated with correct project name
483+ assert result is not None
484+ assert result .get ("skipped" ) is False
485+ assert result .get ("project" ) == "my_app"
486+
487+ # Verify upsert was called with the correct project name
488+ mock_upsert .assert_called_once ()
489+ call_kwargs = mock_upsert .call_args .kwargs
490+ assert call_kwargs ["project" ] == "my_app"
491+
492+ def test_sync_reparses_tmp_prefix (self , tmp_path ):
493+ """Sessions with _tmp* project names should be reparsed."""
494+ from agent_session_viewer import db
495+ from agent_session_viewer import sync as sync_module
496+
497+ project_dir = tmp_path / "-tmp-my-app"
498+ project_dir .mkdir ()
499+ session_file = project_dir / "test-session.jsonl"
500+ session_file .write_text (
501+ '{"type": "user", "cwd": "/tmp/my-app", "message": {"content": "hello"}, "timestamp": "2025-01-01T00:00:00Z"}\n '
502+ )
503+
504+ with patch .object (sync_module , "CLAUDE_PROJECTS_DIR" , tmp_path ), \
505+ patch .object (sync_module , "SESSIONS_DIR" , tmp_path / "sessions" ), \
506+ patch .object (db , "get_session_file_info" ) as mock_file_info , \
507+ patch .object (db , "get_session" ) as mock_get_session , \
508+ patch .object (db , "upsert_session" ) as mock_upsert , \
509+ patch .object (db , "delete_session_messages" ), \
510+ patch .object (db , "insert_messages_batch" ):
511+
512+ file_hash = sync_module .compute_file_hash (session_file )
513+ mock_file_info .return_value = (session_file .stat ().st_size , file_hash )
514+ mock_get_session .return_value = {"project" : "_tmp_my_app" }
515+
516+ result = sync_module .sync_session_file (
517+ session_file , "_tmp_my_app" , "local"
518+ )
519+
520+ assert result is not None
521+ assert result .get ("skipped" ) is False
522+
523+ def test_sync_reparses_var_prefix (self , tmp_path ):
524+ """Sessions with _var* project names should be reparsed."""
525+ from agent_session_viewer import db
526+ from agent_session_viewer import sync as sync_module
527+
528+ project_dir = tmp_path / "-var-tmp-my-app"
529+ project_dir .mkdir ()
530+ session_file = project_dir / "test-session.jsonl"
531+ session_file .write_text (
532+ '{"type": "user", "cwd": "/var/tmp/my-app", "message": {"content": "hello"}, "timestamp": "2025-01-01T00:00:00Z"}\n '
533+ )
534+
535+ with patch .object (sync_module , "CLAUDE_PROJECTS_DIR" , tmp_path ), \
536+ patch .object (sync_module , "SESSIONS_DIR" , tmp_path / "sessions" ), \
537+ patch .object (db , "get_session_file_info" ) as mock_file_info , \
538+ patch .object (db , "get_session" ) as mock_get_session , \
539+ patch .object (db , "upsert_session" ) as mock_upsert , \
540+ patch .object (db , "delete_session_messages" ), \
541+ patch .object (db , "insert_messages_batch" ):
542+
543+ file_hash = sync_module .compute_file_hash (session_file )
544+ mock_file_info .return_value = (session_file .stat ().st_size , file_hash )
545+ mock_get_session .return_value = {"project" : "_var_tmp_my_app" }
546+
547+ result = sync_module .sync_session_file (
548+ session_file , "_var_tmp_my_app" , "local"
549+ )
550+
551+ assert result is not None
552+ assert result .get ("skipped" ) is False
553+
554+ def test_sync_skips_good_project_names (self , tmp_path ):
555+ """Sessions with good project names should be skipped if hash matches."""
556+ from agent_session_viewer import db
557+ from agent_session_viewer import sync as sync_module
558+
559+ project_dir = tmp_path / "my_app"
560+ project_dir .mkdir ()
561+ session_file = project_dir / "test-session.jsonl"
562+ session_file .write_text ('{"type": "user", "message": {"content": "hello"}}\n ' )
563+
564+ with patch .object (sync_module , "CLAUDE_PROJECTS_DIR" , tmp_path ), \
565+ patch .object (db , "get_session_file_info" ) as mock_file_info , \
566+ patch .object (db , "get_session" ) as mock_get_session :
567+
568+ file_hash = sync_module .compute_file_hash (session_file )
569+ mock_file_info .return_value = (session_file .stat ().st_size , file_hash )
570+ mock_get_session .return_value = {"project" : "my_app" }
571+
572+ result = sync_module .sync_session_file (session_file , "my_app" , "local" )
573+
574+ assert result is not None
575+ assert result .get ("skipped" ) is True
0 commit comments