@@ -812,5 +812,176 @@ def test_pyserial_detected_via_metadata(self):
812812 mock_dist .assert_called_with ("pyserial" )
813813
814814
815+ class TestMainPortConflict (unittest .TestCase ):
816+ """Test main() port conflict handling with get_port_owner and CLI server detection."""
817+
818+ def _mock_args (self , ** overrides ):
819+ defaults = dict (
820+ host = "0.0.0.0" ,
821+ port = 5500 ,
822+ debug = False ,
823+ skip_port_check = False ,
824+ no_browser = True ,
825+ no_auth = True ,
826+ )
827+ defaults .update (overrides )
828+ return Mock (** defaults )
829+
830+ @patch ("main.create_app" )
831+ @patch ("main.restore_state" )
832+ @patch ("main.check_port_available" , return_value = False )
833+ @patch (
834+ "main.get_port_owner" ,
835+ return_value = {"pid" : 100 , "name" : "python" , "cmdline" : "python main.py" },
836+ )
837+ @patch ("main.parse_args" )
838+ def test_port_conflict_non_cli_process (
839+ self , mock_args , mock_owner , mock_check , mock_restore , mock_create
840+ ):
841+ """Port occupied by non-CLI process → exit with options."""
842+ mock_args .return_value = self ._mock_args ()
843+ with patch ("cli.server_proxy.get_cli_server_pid" , return_value = None ):
844+ with self .assertRaises (SystemExit ) as cm :
845+ main .main ()
846+ self .assertEqual (cm .exception .code , 1 )
847+ mock_create .assert_not_called ()
848+
849+ @patch ("main.create_app" )
850+ @patch ("main.restore_state" )
851+ @patch ("main.check_port_available" , return_value = False )
852+ @patch (
853+ "main.get_port_owner" ,
854+ return_value = {
855+ "pid" : 200 ,
856+ "name" : "python" ,
857+ "cmdline" : "python main.py --no-browser" ,
858+ },
859+ )
860+ @patch ("main.parse_args" )
861+ def test_port_conflict_cli_server_user_accepts (
862+ self , mock_args , mock_owner , mock_check , mock_restore , mock_create
863+ ):
864+ """Port occupied by CLI server, user answers Y → kill and continue."""
865+ mock_args .return_value = self ._mock_args ()
866+ mock_app = Mock ()
867+ mock_create .return_value = mock_app
868+ with patch ("cli.server_proxy.get_cli_server_pid" , return_value = 200 ), patch (
869+ "cli.server_proxy.stop_cli_server" ,
870+ return_value = {"success" : True , "message" : "done" },
871+ ), patch ("builtins.input" , return_value = "Y" ), patch ("time.sleep" ), patch (
872+ "main.threading.Timer"
873+ ):
874+ # Should NOT exit — continues to start server
875+ main .main ()
876+ mock_create .assert_called_once ()
877+
878+ @patch ("main.create_app" )
879+ @patch ("main.restore_state" )
880+ @patch ("main.check_port_available" , return_value = False )
881+ @patch (
882+ "main.get_port_owner" ,
883+ return_value = {
884+ "pid" : 200 ,
885+ "name" : "python" ,
886+ "cmdline" : "python main.py --no-browser" ,
887+ },
888+ )
889+ @patch ("main.parse_args" )
890+ def test_port_conflict_cli_server_user_declines (
891+ self , mock_args , mock_owner , mock_check , mock_restore , mock_create
892+ ):
893+ """Port occupied by CLI server, user answers n → abort."""
894+ mock_args .return_value = self ._mock_args ()
895+ with patch ("cli.server_proxy.get_cli_server_pid" , return_value = 200 ), patch (
896+ "builtins.input" , return_value = "n"
897+ ):
898+ with self .assertRaises (SystemExit ) as cm :
899+ main .main ()
900+ self .assertEqual (cm .exception .code , 0 )
901+ mock_create .assert_not_called ()
902+
903+ @patch ("main.create_app" )
904+ @patch ("main.restore_state" )
905+ @patch ("main.check_port_available" , return_value = False )
906+ @patch (
907+ "main.get_port_owner" ,
908+ return_value = {
909+ "pid" : 200 ,
910+ "name" : "python" ,
911+ "cmdline" : "python main.py --no-browser" ,
912+ },
913+ )
914+ @patch ("main.parse_args" )
915+ def test_port_conflict_cli_server_stop_fails (
916+ self , mock_args , mock_owner , mock_check , mock_restore , mock_create
917+ ):
918+ """Port occupied by CLI server, user answers Y but stop fails → exit 1."""
919+ mock_args .return_value = self ._mock_args ()
920+ with patch ("cli.server_proxy.get_cli_server_pid" , return_value = 200 ), patch (
921+ "cli.server_proxy.stop_cli_server" ,
922+ return_value = {"success" : False , "error" : "fail" },
923+ ), patch ("builtins.input" , return_value = "Y" ):
924+ with self .assertRaises (SystemExit ) as cm :
925+ main .main ()
926+ self .assertEqual (cm .exception .code , 1 )
927+
928+ @patch ("main.create_app" )
929+ @patch ("main.restore_state" )
930+ @patch ("main.check_port_available" , return_value = False )
931+ @patch (
932+ "main.get_port_owner" ,
933+ return_value = {
934+ "pid" : 200 ,
935+ "name" : "python" ,
936+ "cmdline" : "python main.py --no-browser" ,
937+ },
938+ )
939+ @patch ("main.parse_args" )
940+ def test_port_conflict_cli_server_eof_on_input (
941+ self , mock_args , mock_owner , mock_check , mock_restore , mock_create
942+ ):
943+ """Port occupied by CLI server, EOFError on input → abort."""
944+ mock_args .return_value = self ._mock_args ()
945+ with patch ("cli.server_proxy.get_cli_server_pid" , return_value = 200 ), patch (
946+ "builtins.input" , side_effect = EOFError
947+ ):
948+ with self .assertRaises (SystemExit ) as cm :
949+ main .main ()
950+ self .assertEqual (cm .exception .code , 0 )
951+
952+ @patch ("main.create_app" )
953+ @patch ("main.restore_state" )
954+ @patch ("main.check_port_available" , return_value = False )
955+ @patch ("main.get_port_owner" , return_value = None )
956+ @patch ("main.parse_args" )
957+ def test_port_conflict_unknown_owner (
958+ self , mock_args , mock_owner , mock_check , mock_restore , mock_create
959+ ):
960+ """Port occupied but owner unknown → exit with generic message."""
961+ mock_args .return_value = self ._mock_args ()
962+ with patch ("cli.server_proxy.get_cli_server_pid" , return_value = None ):
963+ with self .assertRaises (SystemExit ) as cm :
964+ main .main ()
965+ self .assertEqual (cm .exception .code , 1 )
966+
967+ @patch ("main.create_app" )
968+ @patch ("main.restore_state" )
969+ @patch ("main.check_port_available" , return_value = False )
970+ @patch (
971+ "main.get_port_owner" ,
972+ return_value = {"pid" : 100 , "name" : "node" , "cmdline" : "node server.js" },
973+ )
974+ @patch ("main.parse_args" )
975+ def test_port_conflict_non_cli_with_stale_cli_pid (
976+ self , mock_args , mock_owner , mock_check , mock_restore , mock_create
977+ ):
978+ """Port occupied by non-CLI process but stale CLI PID exists → show both options."""
979+ mock_args .return_value = self ._mock_args ()
980+ with patch ("cli.server_proxy.get_cli_server_pid" , return_value = 999 ):
981+ with self .assertRaises (SystemExit ) as cm :
982+ main .main ()
983+ self .assertEqual (cm .exception .code , 1 )
984+
985+
815986if __name__ == "__main__" :
816987 unittest .main ()
0 commit comments