Browse files

First commit

git-svn-id: svn+ssh://rubyforge.org/var/svn/jruby-extras/trunk/jmx@1019 8ba958d5-0c1a-0410-94a6-a65dfc1b28a6
  • Loading branch information...
0 parents commit 8709ec0e74036a12ae47bd140a9ed738f844cc9f enebo committed Jun 6, 2008
Showing with 755 additions and 0 deletions.
  1. +3 −0 History.txt
  2. +20 −0 LICENSE.txt
  3. +13 −0 Manifest.txt
  4. +29 −0 README.txt
  5. +27 −0 Rakefile
  6. +210 −0 lib/jmx.rb
  7. +101 −0 lib/jmx/dynamic_mbean.rb
  8. +117 −0 lib/jmx/server.rb
  9. +3 −0 lib/jmx/version.rb
  10. +21 −0 lib/rmi.rb
  11. +4 −0 nbproject/private/private.xml
  12. +4 −0 nbproject/project.properties
  13. +15 −0 nbproject/project.xml
  14. +29 −0 samples/memory.rb
  15. +88 −0 test/jmx_client_test.rb
  16. +71 −0 test/jmx_server_test.rb
3 History.txt
@@ -0,0 +1,3 @@
+== 0.1
+
+- Initial release
20 LICENSE.txt
@@ -0,0 +1,20 @@
+Copyright (c) 2008 Thomas E Enebo <enebo@acm.org>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
13 Manifest.txt
@@ -0,0 +1,13 @@
+Manifest.txt
+Rakefile
+README.txt
+LICENSE.txt
+lib/jmx
+lib/jmx.rb
+lib/rmi.rb
+lib/jmx/dynamic_mbean.rb
+lib/jmx/server.rb
+lib/jmx/version.rb
+samples/memory.rb
+test/jmx_client_test.rb
+test/jmx_server_test.rb
29 README.txt
@@ -0,0 +1,29 @@
+= JMX
+
+== DESCRIPTION:
+
+JMX is a library which allows you to access JMX MBeans as a client or create
+your own MBeans as a Ruby class.
+
+http://jruby-extras.rubyforge.org/jmx/
+
+== FEATURES/PROBLEMS:
+
+* Use '-J-Dcom.sun.management.jmxremote' to make jruby process accessible from a jruby command-line
+
+== SYNOPSIS:
+
+require 'jmx'
+
+client = JMX.simple_connect(:port => 9999)
+
+memory = client["java.lang:type=Memory"]
+puts memory.attributes
+
+== REQUIREMENTS:
+
+* JRuby
+
+== INSTALL:
+
+* jruby -S gem install jmx
27 Rakefile
@@ -0,0 +1,27 @@
+MANIFEST = FileList["Manifest.txt", "Rakefile", "README.txt", "LICENSE.txt", "lib/**/*", "samples/*","test/**/*"]
+
+file "Manifest.txt" => :manifest
+task :manifest do
+ File.open("Manifest.txt", "w") {|f| MANIFEST.each {|n| f << "#{n}\n"} }
+end
+Rake::Task['manifest'].invoke # Always regen manifest, so Hoe has up-to-date list of files
+
+$LOAD_PATH << 'lib'
+require 'jmx/version'
+begin
+ require 'hoe'
+ Hoe.new("jmx", JMX::VERSION) do |p|
+ p.rubyforge_name = "jruby-extras"
+ p.url = "http://jruby-extras.rubyforge.org/jmx"
+ p.author = "Thomas Enebo"
+ p.email = "enebo@acm.org"
+ p.summary = "Package for interacting/creating Java Management Extensions"
+ p.changes = p.paragraphs_of('History.txt', 0..1).join("\n\n")
+ p.description = "Install this gem and require 'jmx' to load the library."
+ end.spec.dependencies.delete_if { |dep| dep.name == "hoe" }
+rescue LoadError
+ puts "You need Hoe installed to be able to package this gem"
+rescue => e
+ p e.backtrace
+ puts "ignoring error while loading hoe: #{e.to_s}"
+end
210 lib/jmx.rb
@@ -0,0 +1,210 @@
+include Java
+
+require 'rmi'
+require 'jmx/dynamic_mbean'
+require 'jmx/server'
+
+import java.util.ArrayList
+import javax.management.Attribute
+import javax.management.DynamicMBean
+import javax.management.MBeanInfo
+import javax.management.ObjectName
+
+class ObjectName
+ def [](key)
+ get_key_property(key.to_s)
+ end
+
+ def info(server)
+ server.getMBeanInfo(self)
+ end
+end
+
+module javax::management::openmbean::CompositeData
+ include Enumerable
+
+ def [](key)
+ get(key.to_s)
+ end
+
+ def method_missing(name, *args)
+ self[name]
+ end
+
+ def each
+ get_composite_type.key_set.each { |key| yield key }
+ end
+
+ def each_pair
+ get_composite_type.key_set.each { |key| yield key, get(key) }
+ end
+end
+
+module JMX
+ ##
+ # Connect to a MBeanServer
+ # opts can contain several values
+ # :host - The hostname where the server resides (def: localhost)
+ # :port - Which port the server resides at (def: 8686)
+ # :url_path - path part of JMXServerURL (def: /jmxrmi)
+ # :user - User to connect as (optional)
+ # :password - Password for user (optional)
+ def self.connect(opts = {})
+ host = opts[:host] || 'localhost'
+ port = opts[:port] || 8686
+ url_path = opts[:url_path] || "/jmxrmi"
+ url = "service:jmx:rmi:///jndi/rmi://#{host}:#{port}#{url_path}"
+
+ if opts[:user]
+ JMX::MBeanServer.new url, opts[:user], opts[:password]
+ else
+ JMX::MBeanServer.new url
+ end
+ end
+
+ ##
+ # sad little simple server setup so you can connect up to it.
+ #
+ def self.simple_server(opts = {})
+ port = opts[:port] || 8686
+ url_path = opts[:url_path] || "/jmxrmi"
+ url = "service:jmx:rmi:///jndi/rmi://localhost:#{port}#{url_path}"
+ $registry = RMIRegistry.new port
+ @connector = JMX::MBeanServerConnector.new(url, JMX::MBeanServer.new).start
+ end
+
+ # Holder for beans created from retrieval (namespace protection [tm]).
+ # This also gives MBeans nicer names when inspected
+ module MBeans
+ ##
+ # Create modules in this namespace for each package in the Java fully
+ # qualified name and return the deepest module along with the Java class
+ # name back to the caller.
+ def self.parent_for(java_class_fqn)
+ java_class_fqn.split(".").inject(MBeans) do |parent, segment|
+ # Note: We are boned if java class name is lower cased
+ return [parent, segment] if segment =~ /^[A-Z]/
+
+ segment.capitalize!
+ unless parent.const_defined? segment
+ parent.const_set segment, Module.new
+ else
+ parent.const_get segment
+ end
+ end
+
+ end
+ end
+
+ # Create a Ruby proxy based on the MBean represented by the object_name
+ class MBeanProxy
+ # Generate a friendly Ruby proxy for the MBean represented by object_name
+ def self.generate(server, object_name)
+ parent, class_name = MBeans.parent_for object_name.info(server).class_name
+
+ if parent.const_defined? class_name
+ proxy = parent.const_get(class_name)
+ else
+ proxy = Class.new MBeanProxy
+ parent.const_set class_name, proxy
+ end
+
+ proxy.new(server, object_name)
+ end
+
+ def initialize(server, object_name)
+ @server, @object_name = server, object_name
+ @info = @server.getMBeanInfo(@object_name)
+
+ define_attributes
+ define_operations
+ end
+
+ def attributes
+ @attributes ||= @info.attributes.inject([]) { |s,attr| s << attr.name }
+ end
+
+ def operations
+ @operations ||= @info.operations.inject([]) { |s,op| s << op.name }
+ end
+
+ # Get MBean attribute specified by name
+ def [](name)
+ @server.getAttribute @object_name, name.to_s
+ end
+
+ # Set MBean attribute specified by name to value
+ def []=(name, value)
+ @server.setAttribute @object_name, Attribute.new(name.to_s, value)
+ end
+
+ def add_notification_listener(filter=nil, handback=nil, &listener)
+ @server.addNotificationListener @object_name, listener, filter, handback
+ end
+
+ def remove_notification_listener(listener)
+ @server.removeNotificationListener @object_name, listener
+ end
+
+ def method_missing(name, *args)
+ puts "Invoking: #{name}, #{args}"
+ java_args = java_args(args)
+ @server.invoke @object_name, name.to_s, java_args, java_types(java_args)
+ end
+
+ private
+
+ # Define ruby friendly methods for attributes. For odd attribute names or names
+ # that you want to call with the actual attribute name you can call aref/aset
+ def define_attributes
+ @info.attributes.each do |attr|
+ rname = underscore(attr.name)
+ self.class.__send__(:define_method, rname) { self[attr.name] } if attr.readable?
+ self.class.__send__(:define_method, rname + "=") {|v| self[attr.name] = v } if attr.writable?
+ end
+ end
+
+ def define_operations
+ @info.operations.each do |op|
+ self.class.__send__(:define_method, op.name) do |*args|
+ jargs = java_args(op.signature, args)
+ @server.invoke @object_name, op.name, jargs, java_types(jargs)
+ end
+ end
+ end
+
+ # Given the signature and the parameters supplied do these signatures match.
+ # Repackage these parameters as Java objects in a primitive object array.
+ def java_args(signature, params)
+ return nil if params.nil?
+
+ i = 0
+ params.map do |param|
+ required_type = JavaClass.for_name(signature[i].get_type)
+ java_arg = Java.ruby_to_java(param)
+
+ if (param.kind_of? Array)
+ java_arg = param.inject(ArrayList.new) {|l, element| l << element }
+ end
+
+ arg_type = java_arg.java_class
+
+ raise TypeError.new("parameter #{signature[i].name} expected to be #{required_type}, but was #{arg_type}") if !required_type.assignable_from? arg_type
+ i = i + 1
+
+ java_arg
+ end.to_java(:object)
+ end
+
+ # Convert a collection of java objects to their Java class name equivalents
+ def java_types(params)
+ return nil if params.nil?
+
+ params.map {|e| params.java_class.name }.to_java(:string)
+ end
+
+ def underscore(string)
+ string.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
+ end
+ end
+end
101 lib/jmx/dynamic_mbean.rb
@@ -0,0 +1,101 @@
+module JMX
+ import javax.management.MBeanParameterInfo
+ import javax.management.MBeanOperationInfo
+ import javax.management.MBeanInfo
+
+ module JavaTypeAware
+ SIMPLE_TYPES = {
+ :int => 'java.lang.Integer',
+ :list => 'java.util.List',
+ :long => 'java.lang.Long',
+ :map => 'java.util.Map',
+ :set => 'java.util.Set',
+ :string => 'java.lang.String',
+ :void => 'java.lang.Void'
+ }
+
+ def to_java_type(type_name)
+ SIMPLE_TYPES[type_name] || type_name
+ end
+ end
+
+ class Parameter
+ include JavaTypeAware
+
+ def initialize(type, name, description)
+ @type, @name, @description = type, name, description
+ end
+
+ def to_jmx
+ MBeanParameterInfo.new @name.to_s, to_java_type(@type), @description
+ end
+ end
+
+ class Operation < Struct.new(:description, :parameters, :return_type, :name, :impact)
+ include JavaTypeAware
+
+ def initialize(description)
+ super
+ self.parameters, self.impact, self.description = [], MBeanOperationInfo::UNKNOWN, description
+ end
+
+ def to_jmx
+ java_parameters = parameters.map { |parameter| parameter.to_jmx }
+ MBeanOperationInfo.new name.to_s, description, java_parameters.to_java(javax.management.MBeanParameterInfo), to_java_type(return_type), impact
+ end
+ end
+end
+
+class RubyDynamicMBean
+ import javax.management.MBeanOperationInfo
+
+ # TODO: preserve any original method_added?
+ # TODO: Error handling here when it all goes wrong?
+ def self.method_added(name)
+ return if Thread.current[:op].nil?
+ Thread.current[:op].name = name
+ operations << Thread.current[:op].to_jmx
+ Thread.current[:op] = nil
+ end
+
+ def self.attributes
+ Thread.current[:attrs] ||= []
+ end
+
+ def self.operations
+ Thread.current[:ops] ||= []
+ end
+
+ # Last operation wins if more than one
+ def self.operation(description)
+ include DynamicMBean
+
+ # Wait to error check until method_added so we can know method name
+ Thread.current[:op] = JMX::Operation.new description
+ end
+
+ def self.parameter(type, name=nil, description=nil)
+ Thread.current[:op].parameters << JMX::Parameter.new(type, name, description)
+ end
+
+ def self.returns(type)
+ Thread.current[:op].return_type = type
+ end
+
+ def initialize(name, description)
+ operations = self.class.operations.to_java(MBeanOperationInfo)
+ @info = MBeanInfo.new name, description, nil, nil, operations, nil
+ end
+
+ def getAttribute(attribute); $stderr.puts "getAttribute"; end
+ def getAttributes(attributes); $stderr.puts "getAttributes"; end
+ def getMBeanInfo; @info; end
+ def invoke(actionName, params=nil, signature=nil)
+ send(actionName, *params)
+ end
+ def setAttribute(attribute); $stderr.puts "setAttribute"; end
+ def setAttributes(attributes); $stderr.puts "setAttributes"; end
+ def to_s; toString; end
+ def inspect; toString; end
+ def toString; "#@info.class_name: #@info.description"; end
+end
117 lib/jmx/server.rb
@@ -0,0 +1,117 @@
+module JMX
+ # Represents both MBeanServer and MBeanServerConnection
+ class MBeanServer
+ import javax.management.Attribute
+ import javax.management.MBeanServerFactory
+ import javax.management.remote.JMXConnectorFactory
+ import javax.management.remote.JMXServiceURL
+
+ attr_accessor :server
+ @@classes = {}
+
+ def initialize(location=nil, username=nil, password=nil)
+ if (location)
+ env = username ?
+ {"jmx.remote.credentials" => [username, password].to_java(:string)} :
+ nil
+ url = JMXServiceURL.new location
+ @server = JMXConnectorFactory.connect(url, env).getMBeanServerConnection
+ else
+ @server = java.lang.management.ManagementFactory.getPlatformMBeanServer
+ #@server = MBeanServerFactory.createMBeanServer
+ end
+ end
+
+ def [](object_name)
+ name = make_object_name object_name
+
+ unless @server.isRegistered(name)
+ raise NoSuchBeanError.new("No name: #{object_name}")
+ end
+
+ #### TODO: Why?
+ @server.getObjectInstance name
+ MBeanProxy.generate(@server, name)
+ end
+
+ def []=(class_name, object_name)
+ name = make_object_name object_name
+
+ @server.createMBean class_name, name, nil, nil
+
+ MBeanProxy.generate(@server, name)
+ end
+
+ def default_domain
+ @server.getDefaultDomain
+ end
+
+ def domains
+ @server.domains
+ end
+
+ def mbean_count
+ @server.getMBeanCount
+ end
+
+ def query_names(name=nil, query=nil)
+ object_name = name.nil? ? nil : make_object_name(name)
+
+ @server.query_names(object_name, query)
+ end
+
+ def register_mbean(object, object_name)
+ name = make_object_name object_name
+
+ @server.registerMBean(object, name)
+
+ MBeanProxy.generate(@server, name)
+ end
+
+ def self.find(agent_id=nil)
+ MBeanServerFactory.findMBeanServer(agent_id)
+ end
+
+ private
+
+ def make_object_name(object_name)
+ return object_name if object_name.kind_of? ObjectName
+
+ ObjectName.new object_name
+ rescue
+ raise ArgumentError.new("Invalid ObjectName #{$!.message}")
+ end
+ end
+
+ class NoSuchBeanError < RuntimeError
+ end
+
+ class MBeanServerConnector
+ import javax.management.remote.JMXServiceURL
+ import javax.management.remote.JMXConnectorServerFactory
+
+ def initialize(location, server)
+ @url = JMXServiceURL.new location
+ @server = JMXConnectorServerFactory.newJMXConnectorServer @url, nil, server.server
+
+ if block_given?
+ start
+ yield
+ stop
+ end
+ end
+
+ def active?
+ @server.isActive
+ end
+
+ def start
+ @server.start
+ self
+ end
+
+ def stop
+ @server.stop if active?
+ end
+ end
+end
3 lib/jmx/version.rb
@@ -0,0 +1,3 @@
+module JMX
+ VERSION = "0.1"
+end
21 lib/rmi.rb
@@ -0,0 +1,21 @@
+include Java
+
+import java.rmi.registry.LocateRegistry
+import java.rmi.registry.Registry
+import java.rmi.server.UnicastRemoteObject
+
+class RMIRegistry
+ def initialize(port = Registry::REGISTRY_PORT)
+ start(port)
+ end
+
+ def start(port)
+ @registry = LocateRegistry.createRegistry port
+
+ end
+
+ def stop
+ UnicastRemoteObject.unexportObject @registry, true
+ end
+end
+
4 nbproject/private/private.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project-private xmlns="http://www.netbeans.org/ns/project-private/1">
+ <editor-bookmarks xmlns="http://www.netbeans.org/ns/editor-bookmarks/1"/>
+</project-private>
4 nbproject/project.properties
@@ -0,0 +1,4 @@
+main.file=main.rb
+source.encoding=UTF-8
+src.dir=lib
+test.src.dir=test
15 nbproject/project.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://www.netbeans.org/ns/project/1">
+ <type>org.netbeans.modules.ruby.rubyproject</type>
+ <configuration>
+ <data xmlns="http://www.netbeans.org/ns/ruby-project/1">
+ <name>jmxjr</name>
+ <source-roots>
+ <root id="src.dir"/>
+ </source-roots>
+ <test-roots>
+ <root id="test.src.dir"/>
+ </test-roots>
+ </data>
+ </configuration>
+</project>
29 samples/memory.rb
@@ -0,0 +1,29 @@
+require 'jmx'
+
+def in_mb(value)
+ format "%0.2f Mb" % (value.to_f / (1024 * 1024))
+end
+
+server = JMX.simple_server
+client = JMX.connect
+memory = client["java.lang:type=Memory"]
+
+Thread.new do
+ puts "Enter 'gc' to garbage collect or anything else to quit"
+ while (command = gets.chomp)
+ break if command != "gc"
+ memory.gc
+ end
+
+ server.stop
+ exit 0
+end
+
+while (true)
+ heap = in_mb(memory.heap_memory_usage.used)
+ non_heap = in_mb(memory.non_heap_memory_usage.used)
+
+ puts "Heap: #{heap}, Non-Heap: #{non_heap}"
+ sleep(2)
+end
+
88 test/jmx_client_test.rb
@@ -0,0 +1,88 @@
+# In order to run these tests you must be running GFv2
+
+$:.unshift File.join(File.dirname(__FILE__),'..','lib')
+
+require 'test/unit'
+require 'rmi'
+require 'jmx'
+
+PORT = 9999
+$registry = RMIRegistry.new PORT
+
+class JMXConnectorClientTest < Test::Unit::TestCase
+ URL = "service:jmx:rmi:///jndi/rmi://localhost:#{PORT}/jmxrmi"
+
+ def setup
+ @connector = JMX::MBeanServerConnector.new(URL, JMX::MBeanServer.new)
+ @connector.start
+ @client = JMX::connect(:port => PORT)
+ end
+
+ def teardown
+ @connector.stop
+ end
+
+ def test_invalid_mbean_name
+ assert_raises(ArgumentError) { @client["::::::"] }
+ end
+
+ def test_get_mbean
+ memory = @client["java.lang:type=Memory"]
+
+ assert_not_nil memory, "Could not acquire memory mbean"
+
+ # Attr form
+ heap = memory[:HeapMemoryUsage]
+ assert_not_nil heap
+ assert(heap[:used] > 0, "No heap used? Impossible!")
+
+ # underscored form
+ heap = memory.heap_memory_usage
+ assert_not_nil heap
+ assert(heap.used > 0, "No heap used? Impossible!")
+ end
+
+ def test_set_mbean
+ memory = @client["java.lang:type=Memory"]
+ original_verbose = memory.verbose
+ memory.verbose = !original_verbose
+ assert(memory.verbose != original_verbose, "Could not change verbose")
+
+ memory[:Verbose] = original_verbose
+ assert(memory[:Verbose] == original_verbose, "Could not change back verbose")
+ end
+
+ def test_attributes
+ memory = @client["java.lang:type=Memory"]
+ assert(memory.attributes.include?("HeapMemoryUsage"), "HeapMemoryUsage not found")
+ end
+
+ def test_operations
+ memory = @client["java.lang:type=Memory"]
+ assert(memory.operations.include?("gc"), "gc not found")
+ end
+
+
+ def test_simple_operation
+ memory = @client["java.lang:type=Memory"]
+
+ heap1 = memory[:HeapMemoryUsage][:used]
+ memory.gc
+ heap2 = memory[:HeapMemoryUsage][:used]
+
+ assert(heap1.to_i >= heap2.to_i, "GC did not collect")
+ end
+
+ def test_query_names
+ names = @client.query_names("java.lang:type=MemoryPool,*")
+ assert(names.size > 0, "No memory pools. Impossible!")
+
+ a_memory_pool_bean = @client[names.to_array[0]]
+ assert_not_nil a_memory_pool_bean, "Name must resolve to something"
+
+ usage = a_memory_pool_bean[:Usage]
+ assert_not_nil usage, "Memory pools have usage"
+
+ assert_not_nil usage[:used], "Some memory is used"
+ end
+end
71 test/jmx_server_test.rb
@@ -0,0 +1,71 @@
+#
+# To change this template, choose Tools | Templates
+# and open the template in the editor.
+
+
+$:.unshift File.join(File.dirname(__FILE__),'..','lib')
+
+require 'test/unit'
+require 'rmi'
+require 'jmx'
+
+class MyDynamicMBean < RubyDynamicMBean
+ operation "Doubles a value"
+ parameter :int, "a", "Value to double"
+ returns :int
+ def double(a)
+ a + a
+ end
+
+ operation "Doubles a string"
+ parameter :string, "a", "Value to double"
+ returns :string
+ def string_double(a)
+ a + a
+ end
+
+ operation "Give me foo"
+ returns :string
+ def foo
+ "foo"
+ end
+
+ operation "Concatentates a list"
+ parameter :list, "list", "List to concatenate"
+ returns :string
+ def concat(list)
+ list.inject("") { |memo, element| memo << element.to_s }
+ end
+end
+
+class JMXServerTest < Test::Unit::TestCase
+ PORT = 9999
+ URL = "service:jmx:rmi:///jndi/rmi://localhost:#{PORT}/jmxrmi"
+
+ def setup
+ @registry = RMIRegistry.new PORT
+ @server = JMX::MBeanServer.new
+ @connector = JMX::MBeanServerConnector.new(URL, @server)
+ @connector.start
+ @client = JMX::connect(:port => PORT)
+ end
+
+ def teardown
+ @connector.stop
+ @registry.stop
+ end
+
+ def test_ruby_mbean
+ dyna = MyDynamicMBean.new("domain.MySuperBean", "Heh")
+ domain = @server.default_domain
+ @server.register_mbean dyna, "#{domain}:type=MyDynamicMBean"
+
+ # Get bean from client connector connection
+ bean = @client["#{domain}:type=MyDynamicMBean"]
+ assert_equal("foo", bean.foo)
+ assert_equal(6, bean.double(3))
+ assert_raise(TypeError) { puts bean.double("HEH") }
+ assert_equal("hehheh", bean.string_double("heh"))
+ assert_equal("123", bean.concat([1,2,3]))
+ end
+end

0 comments on commit 8709ec0

Please sign in to comment.