├── .gitignore ├── HISTORY.md ├── README.md ├── bin └── grinder ├── grinder ├── default.properties ├── hello.clj ├── hello_test.clj ├── http.clj ├── http_binding.clj ├── http_instrumented.clj ├── http_logging.clj ├── http_shared.clj ├── http_stats.clj ├── http_test.clj ├── meteor.clj ├── template.clj └── working.properties ├── project.clj ├── src └── meteor_load_test │ ├── core.clj │ ├── ddp_action.clj │ ├── initial_payload.clj │ ├── method_calls.clj │ ├── subscriptions.clj │ └── util.clj ├── sut ├── .meteor │ ├── .gitignore │ ├── packages │ └── release ├── client │ ├── client.js │ ├── load-test.css │ └── load-test.html ├── common.js ├── run ├── server │ └── server.js └── settings.json └── test └── meteor_load_test ├── core_test.clj ├── initial_payload_test.clj ├── method_calls_test.clj └── util_test.clj /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /lib 3 | /classes 4 | /checkouts 5 | /log 6 | pom.xml 7 | pom.xml.asc 8 | *.jar 9 | *.class 10 | .lein-deps-sum 11 | .lein-failures 12 | .lein-plugins 13 | .lein-repl-history 14 | *.swp 15 | *.swo 16 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | ## v0.0.5 2 | 3 | * Support SSL and non-SSL target urls 4 | 5 | 6 | ## v0.0.4 7 | 8 | * Add support for login via email/password 9 | 10 | 11 | ## v0.0.3 12 | 13 | * Add support for multiple resumeTokens 14 | 15 | 16 | ## v0.0.2 17 | 18 | * Add support for login via resumeToken 19 | 20 | 21 | ## v0.0.1 22 | 23 | * Initial release 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What is this? 2 | A load testing tool for Meteor applications. 3 | 4 | ** Important! Please don't load test sites running on meteor.com! Use your own servers! ** 5 | 6 | You can watch a video presentation about this from Meteor DevShop here: https://www.youtube.com/watch?v=tCPBGVI3PdA 7 | 8 | This tool utilizes [the Grinder](http://grinder.sourceforge.net/) to manage agents which execute a customized test script capable of speaking DDP with the target Meteor server. Each agent simulates 1 or more client connections and records performance metrics. 9 | 10 | Users can configure options such as: 11 | * Meteor methods to call with parameters 12 | * Meteor subscriptions to initiate with parameters 13 | * User login via email/pass or resumeTokens (for OAUTH) 14 | * Number of clients to simulate 15 | * Wait time between thread start 16 | * Client ramp-up (process increment) 17 | 18 | ## Preliminaries 19 | Install pre-req's 20 | * Java 21 | * [leiningen](https://github.com/technomancy/leiningen) 22 | * *Note: make sure you use leiningen version 2+, which you can get by using the installation 23 | script from the web page. Some package managers, such as Debian/Ubuntu, will give you an older v1 version 24 | which will result in `Could not find or load main class` errors. You can check which version of 25 | leiningen you have by running `lein --version`.* 26 | 27 | Setup the project 28 | 29 | ```bash 30 | git clone git://github.com/alanning/meteor-load-test.git 31 | cd meteor-load-test 32 | lein deps # downloads dependencies 33 | ``` 34 | 35 | ## Running Tests 36 | 37 | There are three parts to running your load tests: 38 | 1. The system you are testing 39 | 2. The agents which execute the test script 40 | 3. A console which manages all the agents 41 | 42 | 43 | ## The System Under Test (SUT) 44 | To start an example server 45 | 46 | ```bash 47 | cd sut 48 | ./run 49 | ``` 50 | 51 | Open app in browser 52 | 53 | http://localhost:3000/ 54 | 55 | 56 | ## The Grinder 57 | 58 | The Grinder is organized into two tiers: 59 | * the console, which controls agents and distributes the test scripts, and 60 | * the agents, which execute the tests and report back to the console. 61 | 62 | The console sends the configuration and test scripts to the agents, on demand, so 63 | modifying your tests and re-executing is straight-forward. Each agent can 64 | start multiple processes and each process can run your test script multiple 65 | times so generating a large amount of load is quite efficient. 66 | 67 | Number of simulated clients = agents * processes * threads 68 | 69 | Each thread (client) will: 70 | 71 | 1. Request initial payload 72 | 2. Request css & scripts found in initial response 73 | 3. Initiate DDP connection 74 | 4. Login with randomized user credentials (if supplied) 75 | 5. Subscribe to subscriptions specified 76 | 6. Perform method calls 'runs' number of times (see working.properties) 77 | 78 |
79 | 80 | 81 | To start an agent 82 | 83 | ```bash 84 | # in meteor-load-test directory 85 | bin/grinder agent start [optional host url of console - defaults to localhost] 86 | ``` 87 | 88 | Monitor agent log file 89 | 90 | ```bash 91 | # in separate console window, from meteor-load-test directory 92 | cd log 93 | tail -f agent_1.log 94 | ``` 95 | 96 | To start the console 97 | 98 | ```bash 99 | # in separate console window, from meteor-load-test directory 100 | bin/grinder console start 101 | ``` 102 | 103 | To run tests 104 | 105 | In the Script tab, set the root directory to $PROJECT_HOME/grinder 106 | 107 | Select `working.properties` and set it as the properties file to use (the star button) 108 | 109 | Open `working.properties` and adjust as appropriate. Default setup will load test http://localhost:3000/ 110 | 111 | Click the play button in the top left (tooltip says, "Start the worker processes") 112 | 113 | In the results tab, you should see test results 114 | 115 | 116 | ## How to use 117 | 118 | Modify `working.properties` as appropriate. See the [Grinder documentation](http://grinder.sourceforge.net/g3/properties.html) for more options 119 | 120 | Grinder agents should be started on separate boxes (not your webserver). 121 | 122 | Note: Currently collection updates are received via Meteor subscriptions but there are no metrics gathered for how long it takes an update to be delivered under load. The closest we have right now is peak and mean TPS for DDP calls. 123 | 124 | Given that, probably the most realistic way to test responsiveness of your app under load is to spin up your agents, kick off the tests, wait for them to saturate your server, and then visit your site via your own browser. 125 | 126 | 127 | ## Controlling a remote agent 128 | 129 | You will probably want to spin up agents on cloud machines so here's a couple useful things related to that: 130 | 131 | 1. Install java, leiningen, and `meteor-load-test` on all cloud instances and your local machine 132 | 2. Start console on local machine: `$ bin/grinder console start` 133 | 3. SSH into cloud instances and remote forward port 6372: 134 | ``` 135 | $ ssh -i ~/.ssh/creds.pem -R 6372:localhost:6372 ec2-user@10.0.0.1 136 | ``` 137 | 4. Start agents on each cloud instance: 138 | ``` 139 | $ ssh 140 | $ bin/grinder agent start 141 | ``` 142 | 5. Use grinder console to distribute properties script and start tests. Probably want to delete `sut/.meteor/local` first. 143 | 144 | 145 | ## Terminology 146 | 147 | Source: Goranka Bjedov, [Google Tech Talk](http://www.youtube.com/watch?v=335LKIXRauA&feature=gv) 148 | 149 | Performance Testing 150 | * given load X, how quickly will the system return a result 151 | * timing 152 | 153 | Stress Testing 154 | * when will the system fail and how will it fail 155 | * under what load will the system fail 156 | 157 | Load Test 158 | * 80% of max load, what happens if run load for long period of time 159 | 160 | Scalability 161 | * if I increase a certain resource, how will my through-put change 162 | 163 | Performance Profiling 164 | * used for very specific drill-down 165 | 166 | Reliability Testing 167 | * run load for 72+ hours 168 | * five 'nines' 169 | 170 | Availability Testing 171 | * if a system fails, how quickly will the system come back online 172 | 173 | Why test Performance? 174 | * Can our system return results as fast as customers want? 175 | * To prove stats for agreements 176 | * Peace of mind 177 | 178 | Why NOT to test Performance? 179 | * functional testing 180 | 181 | 182 | ## Future work 183 | 184 | Create Chef or Pallet scripts to automate creation of Grinder agent instances in the cloud 185 | 186 | Explore recording timing information for length of time to finish receiving all collection updates 187 | 188 | 189 | ## Acknowledgements 190 | 191 | Based on [load-testing-with-clojure](https://github.com/locopati/load-testing-with-clojure) by Andy Kriger which load-tests stateless websites. 192 | 193 | Uses the [java-ddp-client](https://github.com/kenyee/java-ddp-client) by Ken Yee to communicate with the target Meteor server. 194 | 195 | Evolved from discussion on the meteor-talk google group: [Load Testing Meteor](https://groups.google.com/forum/#!topic/meteor-talk/M9waYvcFufs). In particular, major thanks to Andrew Wilcox, Sam Hatoum, Matt DeBergalis, and Tom Coleman for their guidance and suggestions. 196 | -------------------------------------------------------------------------------- /bin/grinder: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # usage info 5 | # 6 | 7 | usage="echo -en ' 8 | grinder agent start [host] \n 9 | grinder agent stop \n 10 | \n 11 | grinder console start [remote-ip ...] \n 12 | grinder console stop \n 13 | '" 14 | 15 | # 16 | # initial setup of vars and dirs 17 | # 18 | 19 | grinder_log=log 20 | classpath=`lein classpath`:$CLASSPATH 21 | mkdir -p $grinder_log 22 | 23 | # 24 | # agent functions 25 | # 26 | 27 | agent_count() 28 | { 29 | echo `jps|grep Grinder|wc -l` 30 | } 31 | 32 | agent_start () 33 | { 34 | count=`agent_count` 35 | log=$grinder_log/agent_$(( ++count )).log 36 | java -Dgrinder.consoleHost=$1 -cp $classpath net.grinder.Grinder -daemon 3 $grinder/default.properties &> $log & 37 | echo Grinder agent started as process $! logging to $log 38 | } 39 | 40 | agent_stop () 41 | { 42 | count=`agent_count` 43 | [ $count -eq 0 ] && { echo no agents to stop; return 0; } 44 | jps|grep Grinder|cut -d' ' -f1|xargs kill 45 | echo stopped $count Grinder `[ $count -eq 1 ] && echo agent || echo agents` 46 | } 47 | 48 | # 49 | # console functions 50 | # 51 | 52 | console_start () 53 | { 54 | java -Xms128m -Xmx1024m -cp $classpath net.grinder.Console $3 &> $grinder_log/console.log & 55 | } 56 | 57 | console_stop () 58 | { 59 | jps|grep Console|cut -d' ' -f1|xargs kill 60 | } 61 | 62 | # 63 | # command line handling 64 | # 65 | 66 | case $1 in 67 | 68 | agent) 69 | case $2 in 70 | start) 71 | agent_start ${3:-localhost} 72 | ;; 73 | stop) 74 | agent_stop 75 | ;; 76 | *) 77 | eval $usage 78 | esac 79 | ;; 80 | 81 | console) 82 | case $2 in 83 | start) 84 | console_start 85 | ;; 86 | stop) 87 | console_stop 88 | ;; 89 | *) 90 | eval $usage 91 | esac 92 | ;; 93 | 94 | *) 95 | eval $usage 96 | 97 | esac 98 | -------------------------------------------------------------------------------- /grinder/default.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Default grinder properties 3 | # 4 | # 5 | # The properties can be specified in three ways. 6 | # - In the console. A properties file in the distribution directory 7 | # can be selected in the console. 8 | # - As a Java system property in the command line used to start the 9 | # agent. (e.g. java -Dgrinder.threads=20 net.grinder.Grinder). 10 | # - In an agent properties file. A local properties file named 11 | # "grinder.properties" can be placed in the working directory of 12 | # each agent, or a different file name passed as an agent command 13 | # line argument. 14 | # 15 | # Properties present in a console selected file take precedence over 16 | # agent command line properties, which in turn override those in 17 | # an agent properties file. 18 | # 19 | # Any line which starts with a ; (semi-colon) or a # (hash) is a 20 | # comment and is ignored. In this example we will use a # for 21 | # commentary and a ; for parts of the config file that you may wish to 22 | # enable 23 | # 24 | # Please refer to 25 | # http://net.grinder.sourceforge.net/g3/properties.html for further 26 | # documentation. 27 | 28 | 29 | # 30 | # Commonly used properties 31 | # 32 | 33 | # The file name of the script to run. 34 | # 35 | # Relative paths are evaluated from the directory containing the 36 | # properties file. The default is "grinder.py". 37 | grinder.script = hello.clj 38 | 39 | # The number of worker processes each agent should start. The default 40 | # is 1. 41 | grinder.processes = 1 42 | 43 | # The number of worker threads each worker process should start. The 44 | # default is 1. 45 | grinder.threads = 1 46 | 47 | # The number of runs each worker process will perform. When using the 48 | # console this is usually set to 0, meaning "run until the console 49 | # sneds a stop or reset signal". The default is 1. 50 | grinder.runs = 1 51 | 52 | # The IP address or host name that the agent and worker processes use 53 | # to contact the console. The default is all the network interfaces 54 | # of the local machine. 55 | ; grinder.consoleHost = consolehost 56 | 57 | # The IP port that the agent and worker processes use to contact the 58 | # console. Defaults to 6372. 59 | ; grinder.consolePort 60 | 61 | 62 | 63 | # 64 | # Less frequently used properties 65 | # 66 | 67 | 68 | ### Logging ### 69 | 70 | # The directory in which worker process logs should be created. If not 71 | # specified, the agent's working directory is used. 72 | grinder.logDirectory = log 73 | 74 | # The number of archived logs from previous runs that should be kept. 75 | # The default is 1. 76 | grinder.numberOfOldLogs = 1 77 | 78 | # Overrides the "host" string used in log filenames and logs. The 79 | # default is the host name of the machine running the agent. 80 | ; grinder.hostID = myagent 81 | 82 | # Set to false to disable the logging of output and error steams for 83 | # worker processes. You might want to use this to reduce the overhead 84 | # of running a client thread. The default is true. 85 | ; grinder.logProcessStreams = false 86 | 87 | 88 | ### Script sleep time #### 89 | 90 | # The maximum time in milliseconds that each thread waits before 91 | # starting. Unlike the sleep times specified in scripts, this is 92 | # varied according to a flat random distribution. The actual sleep 93 | # time will be a random value between 0 and the specified value. 94 | # Affected by grinder.sleepTimeFactor, but not 95 | # grinder.sleepTimeVariation. The default is 0ms. 96 | ; grinder.initialSleepTime=500 97 | 98 | # Apply a factor to all the sleep times you've specified, either 99 | # through a property of in a script. Setting this to 0.1 would run the 100 | # script ten times as fast. The default is 1. 101 | ; grinder.sleepTimeFactor=0.01 102 | 103 | # The Grinder varies the sleep times specified in scripts according to 104 | # a Normal distribution. This property specifies a fractional range 105 | # within which nearly all (99.75%) of the times will lie. E.g., if the 106 | # sleep time is specified as 1000 and the sleepTimeVariation is set to 107 | # 0.1, then 99.75% of the actual sleep times will be between 900 and 108 | # 1100 milliseconds. The default is 0.2. 109 | ; grinder.sleepTimeVariation=0.005 110 | 111 | 112 | ### Worker process control ### 113 | 114 | # If set, the agent will ramp up the number of worker processes, 115 | # starting the number specified every 116 | # grinder.processesIncrementInterval milliseconds. The upper limit is 117 | # set by grinder.processes. The default is to start all worker 118 | # processes together. 119 | grinder.processIncrement = 1000 120 | 121 | # Used in conjunction with grinder.processIncrement, this property 122 | # sets the interval in milliseconds at which the agent starts new 123 | # worker processes. The value is in milliseconds. The default is 60000 124 | # ms. 125 | ; grinder.processIncrementInterval = 10000 126 | 127 | # Used in conjunction with grinder.processIncrement, this property 128 | # sets the initial number of worker processes to start. The default is 129 | # the value of grinder.processIncrement. 130 | ; process.initialProcesses = 1 131 | 132 | # The maximum length of time in milliseconds that each worker process 133 | # should run for. grinder.duration can be specified in conjunction 134 | # with grinder.runs, in which case the worker processes will terminate 135 | # if either the duration time or the number of runs is exceeded. The 136 | # default is to run forever. 137 | ; grinder.duration = 60000 138 | 139 | # If set to true, the agent process spawns engines in threads rather 140 | # than processes, using special class loaders to isolate the engines. 141 | # This allows the engine to be easily run in a debugger. This is 142 | # primarily a tool for debugging The Grinder engine, but it might also 143 | # be useful to advanced users. The default is false. 144 | ; grinder.debug.singleprocess = true 145 | 146 | # If set to true, the new DCR instrumentation engine will be used. The 147 | # new engine will always be used if Jython 2.1/2.2 is not found. 148 | grinder.dcrinstrumentation = true 149 | 150 | 151 | ### Java ### 152 | 153 | # Use an alternate JVM for worker processes. The default is "java" so 154 | # you do not need to specify this if java is in your PATH. 155 | ; grinder.jvm = /opt/jrockit/jrockit-R27.5.0-jdk1.5.0_14/bin/java 156 | 157 | # Use to adjust the classpath used for the worker process JVMs. 158 | # Anything specified here will be prepended to the classpath used to 159 | # start the Grinder processes. 160 | ; grinder.jvm.classpath = 161 | 162 | # Additional arguments to worker process JVMs. 163 | ; grinder.jvm.arguments = -Dpython.cachedir=/tmp 164 | grinder.jvm.arguments = -Xms64m -Xmx128m -XX:-UseConcMarkSweepGC 165 | 166 | 167 | ### Console communications ### 168 | 169 | # (See above for console address properties). 170 | 171 | # If you are not using the console, and don't want the agent to try to 172 | # contact it, set grinder.useConsole = false. The default is true. 173 | ; grinder.useConsole = false 174 | 175 | # The period at which each process sends updates to the console. This 176 | # also controls the frequency at which the data files are flushed. 177 | # The default is 500 ms. 178 | grinder.reportToConsole.interval = 10000 179 | 180 | 181 | ### Statistics ### 182 | 183 | # Set to false to disable reporting of timing information to the 184 | # console; other statistics are still reported. See 185 | # http://grinder.sourceforge.net/faq.html#timing for why you might 186 | # want to do this. The default is true. 187 | ; grinder.reportTimesToConsole = false 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | -------------------------------------------------------------------------------- /grinder/hello.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Saying hello with The Grinder 3 | ;; 4 | 5 | (import '(net.grinder.script Grinder)) 6 | 7 | ;; declare symbols used by the script 8 | (let [grinder Grinder/grinder] 9 | 10 | ;; utility function for logging 11 | (defn log [& text] 12 | (.. grinder (getLogger) (info (apply str text)))) 13 | 14 | ;; the factory function 15 | ;; called once by each worker thread 16 | (fn [] 17 | 18 | ;; the test runner function 19 | ;; called on each run 20 | (fn [] 21 | 22 | ;; say hello 23 | (log "Hello World!") 24 | 25 | ) ;; end of test runner function 26 | ) ;; end of factory function 27 | ) ;; end of script let form -------------------------------------------------------------------------------- /grinder/hello_test.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; A simple test with The Grinder 3 | ;; 4 | 5 | (import '(net.grinder.script Grinder Test)) 6 | 7 | ;; declare symbols used by the script 8 | (let [grinder Grinder/grinder 9 | test (Test. 1 "Logging")] 10 | 11 | ;; utility function for logging 12 | (defn log [& text] 13 | (.. grinder (getLogger) (info (apply str text)))) 14 | 15 | ;; record calls to the logging function 16 | (.. test (record log)) 17 | 18 | ;; the factory function 19 | ;; called once by each worker thread 20 | (fn [] 21 | 22 | ;; the test runner function 23 | ;; called on each run 24 | (fn [] 25 | 26 | ;; say hello 27 | (log "Hello World!") 28 | 29 | ) ;; end of test runner function 30 | ) ;; end of factory function 31 | ) ;; end of script let form -------------------------------------------------------------------------------- /grinder/http.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Using HTTP with The Grinder 3 | ;; 4 | 5 | (ns math.http 6 | (:import [net.grinder.script Grinder]) 7 | (:use math.test) 8 | (:require [clj-http.client :as http]) 9 | ) 10 | 11 | ;; declare symbols used by the script 12 | (let [grinder Grinder/grinder] 13 | 14 | ;; utility function for logging 15 | (defn log [& text] 16 | (.. grinder (getLogger) (info (apply str text)))) 17 | 18 | ;; the factory function 19 | ;; called once by each worker thread 20 | (fn [] 21 | 22 | ;; the test runner function 23 | ;; called on each run 24 | (fn [] 25 | 26 | ;; log the output of a random math request 27 | (let [op (random-expr)] 28 | (log op " = " (:body (http/get (build-url op))))) 29 | 30 | ) ;; end of test runner function 31 | ) ;; end of factory function 32 | ) ;; end of script let form -------------------------------------------------------------------------------- /grinder/http_binding.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Using rebinding and reporting with The Grinder 3 | ;; 4 | 5 | (ns math.http 6 | (:import [net.grinder.script Grinder Test] 7 | [net.grinder.plugin.http HTTPRequest]) 8 | (:use math.test) 9 | (:require [clj-http.client :as http]) 10 | ) 11 | 12 | (let [grinder Grinder/grinder 13 | stats (.getStatistics grinder) 14 | test (Test. 5 "Rebinding and reporting")] 15 | 16 | (defn log [& text] 17 | (.. grinder (getLogger) (info (apply str text)))) 18 | 19 | ;; if testing reports an error, let the grinder know about it 20 | (defn report [event] 21 | (when-not (= (:type event) :pass) 22 | (log event) 23 | (.. stats getForLastTest (setSuccess false)))) 24 | 25 | ;; the arity of the instrumented fn changes to match the rebound fn 26 | ;; the return value of HTTPRequest must be converted as well 27 | (defn instrumented-get [url & _] 28 | (let [resp (.. (HTTPRequest.) (GET url))] 29 | {:body (.getText resp) 30 | :status (.getStatusCode resp)})) 31 | 32 | (.. test (record instrumented-get)) 33 | 34 | (fn [] 35 | 36 | (fn [] 37 | 38 | ;; rebind the http/get fn to our instrumented fn 39 | ;; rebind test reporting to capture errors 40 | (binding [wrapped-get instrumented-get 41 | clojure.test/report report] 42 | ;; delay grinder reporting for test reporting to work 43 | (.setDelayReports stats true) 44 | (test-operation) 45 | (test-error)) 46 | 47 | ) 48 | ) 49 | ) -------------------------------------------------------------------------------- /grinder/http_instrumented.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Instrumenting HTTP with The Grinder 3 | ;; 4 | 5 | (ns math.http 6 | (:import [net.grinder.script Grinder Test] 7 | [net.grinder.plugin.http HTTPRequest]) 8 | (:use math.test) 9 | (:require [clj-http.client :as http]) 10 | ) 11 | 12 | (let [grinder Grinder/grinder 13 | test (Test. 3 "HTTP Instrumented")] 14 | 15 | (defn log [& text] 16 | (.. grinder (getLogger) (info (apply str text)))) 17 | 18 | ;; function that we can record 19 | (defn instrumented-get [url] 20 | (.. (HTTPRequest.) (GET url))) 21 | 22 | ;; record calls to the instrumented function 23 | (.. test (record instrumented-get)) 24 | 25 | (fn [] 26 | 27 | (fn [] 28 | 29 | ;; request using a recorded function 30 | (instrumented-get (build-url (random-expr))) 31 | 32 | ;; request errors 33 | (instrumented-get (build-url "/err/401")) 34 | (instrumented-get (build-url "/err/500")) 35 | 36 | ) 37 | ) 38 | ) -------------------------------------------------------------------------------- /grinder/http_logging.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Custom logging with The Grinder 3 | ;; 4 | 5 | (ns math.http 6 | (:import [net.grinder.script Grinder Test] 7 | [net.grinder.plugin.http HTTPRequest]) 8 | (:use math.test) 9 | (:require [clj-http.client :as http]) 10 | ) 11 | 12 | ;; declare symbols used by the script 13 | (let [grinder Grinder/grinder 14 | test (Test. 1 "HTTP")] 15 | 16 | ;; utility function for logging 17 | (defn log [& text] 18 | (.. grinder (getLogger) (output (apply str text)))) 19 | 20 | ;; function that we can record 21 | (defn instrumented-get [url] 22 | (.. (HTTPRequest.) (GET url))) 23 | 24 | ;; record calls to the get function 25 | (.. test (record instrumented-get)) 26 | 27 | ;; the factory function 28 | ;; called once by each worker thread 29 | (fn [] 30 | 31 | ;; the test runner function 32 | ;; called on each run 33 | (fn [] 34 | 35 | ;; request using a recorded function 36 | (instrumented-get (build-url (random-operation))) 37 | 38 | ) ;; end of test runner function 39 | ) ;; end of factory function 40 | ) ;; end of script let form -------------------------------------------------------------------------------- /grinder/http_shared.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Sharing tests across threads with The Grinder 3 | ;; 4 | 5 | (ns math.http 6 | (:import [net.grinder.script Grinder Test] 7 | [net.grinder.plugin.http HTTPRequest]) 8 | (:use math.test) 9 | (:require [clj-http.client :as http]) 10 | ) 11 | 12 | (let [grinder Grinder/grinder 13 | stats (.getStatistics grinder) 14 | ;; here we use a custom property to indicate sharing among threads 15 | shared? (.. grinder getProperties (getBoolean "grinder.shared" false)) 16 | test (Test. 6 "Sharing across threads") 17 | ;; here we declare an atom for sharing among threads 18 | test-atom (atom {:test-fn nil :tests (repeat 100 test-operation)})] 19 | 20 | (defn log [& text] 21 | (.. grinder (getLogger) (info (apply str text)))) 22 | 23 | ;; again the arity must match the rebound fn 24 | ;; and the return value converted 25 | (defn instrumented-get [url & _] 26 | {:body (.. (HTTPRequest.) (GET url) getText)}) 27 | 28 | (.. test (record instrumented-get)) 29 | 30 | ;; a swapping function to setup 31 | (defn next-test [{_ :test-fn remaining :tests}] 32 | {:test-fn (first remaining) :tests (rest remaining)}) 33 | 34 | ;; 35 | (defn shared-tests [test-atom] 36 | (loop [{current-test :test-fn remaining-tests :tests} 37 | (swap! test-atom next-test)] 38 | (if (and (empty? remaining-tests) (nil? current-test)) 39 | nil 40 | (do (when-not (nil? current-test) (current-test)) 41 | (recur (swap! test-atom next-test)))))) 42 | 43 | (fn [] 44 | 45 | (fn [] 46 | 47 | ;; request using a recorded function 48 | (binding [wrapped-get instrumented-get] 49 | 50 | (if shared? 51 | (shared-tests test-atom) 52 | (doall (map #(%) (:tests @test-atom)))) 53 | 54 | ) 55 | ) 56 | ) 57 | ) -------------------------------------------------------------------------------- /grinder/http_stats.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Custom statistics with The Grinder 3 | ;; 4 | 5 | (ns math.http 6 | (:import [net.grinder.script Grinder Test] 7 | [net.grinder.plugin.http HTTPRequest]) 8 | (:use math.test) 9 | (:require [clj-http.client :as http]) 10 | ) 11 | 12 | (let [grinder Grinder/grinder 13 | stats (.getStatistics grinder) 14 | test (Test. 4 "Custom Stats")] 15 | 16 | (defn log [& text] 17 | (.. grinder (getLogger) (info (apply str text)))) 18 | 19 | ;; utility to return the number operations op in a mathematical expression expr 20 | (defn count-op [op expr] 21 | (count (re-seq (re-pattern (str "\\" op)) expr))) 22 | 23 | (defn instrumented-get [expr] 24 | ;; use getForCurrentTest when recording stats within an instrumented function 25 | ;;(.. stats getForCurrentTest (setLong "userLong0" (count-op '+ expr))) 26 | (.. (HTTPRequest.) (GET (build-url expr)))) 27 | 28 | (.. test (record instrumented-get)) 29 | 30 | (defn register-stat [idx op] 31 | ;; grinder seems to have a problem 32 | ;; using '* without a leading space 33 | ;; and using '/ without a trailing space 34 | (.. stats (registerDataLogExpression 35 | (str " " op " ") (str "userLong" idx))) 36 | (.. stats (registerSummaryExpression 37 | (str " " op " ") (str "userLong" idx)))) 38 | 39 | (defn record-stat [expr idx op] 40 | (.. stats getForLastTest 41 | (setLong (str "userLong" idx) (count-op op expr)))) 42 | 43 | ;; register stats for counting operations 44 | (register-stat 0 '+) 45 | (register-stat 1 '-) 46 | (register-stat 2 '*) 47 | (register-stat 3 '/) 48 | 49 | (fn [] 50 | 51 | (fn [] 52 | 53 | ;; request using a recorded function 54 | (let [expr (random-expr)] 55 | 56 | ;; prevent reporting until after the test is called 57 | (.. stats (setDelayReports true)) 58 | 59 | (instrumented-get expr) 60 | 61 | ;; record the stats 62 | (record-stat expr 0 '+) 63 | (record-stat expr 1 '-) 64 | (record-stat expr 2 '*) 65 | (record-stat expr 3 '/) 66 | 67 | ) 68 | ) 69 | ) 70 | ) -------------------------------------------------------------------------------- /grinder/http_test.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Recording HTTP with The Grinder 3 | ;; 4 | 5 | (ns meteor-load-test.core 6 | (:import [net.grinder.script Grinder Test] 7 | [net.grinder.plugin.http HTTPRequest] 8 | [me.kutrumbos DdpClient] 9 | [me.kutrumbos.examples SimpleDdpClientObserver]) 10 | (:require [clj-http.client :as http])) 11 | ;[cemerick.url :as url])) 12 | 13 | (let [grinder Grinder/grinder 14 | test1 (Test. 1 "Retrieve initial payload") 15 | test2 (Test. 2 "DDP object") 16 | ;properties (.getProperties grinder) 17 | ;targetUrl (url (.getProperty properties "targetUrl")) 18 | ] 19 | 20 | (defn log [& text] 21 | (.. grinder (getLogger) (info (apply str text)))) 22 | 23 | (defn get-port [targetUrl] 24 | (let [port (:port targetUrl)] 25 | (cond 26 | (= -1 port) 27 | (cond 28 | (= "http" (:protocol targetUrl)) (int 80) 29 | :else (int 443)) 30 | :else (int port)))) 31 | 32 | ;; function that we can record 33 | (defn instrumented-get [url] 34 | (.. (HTTPRequest.) (GET url))) 35 | 36 | ;; record calls to the instrumented function 37 | (.. test1 (record instrumented-get)) 38 | 39 | (fn [] 40 | 41 | (fn [] 42 | 43 | ;; simulate initial http fetch 44 | (instrumented-get "http://localhost:3000/") 45 | ;(http/get "http://localhost:3000") 46 | ;(http/get targetUrl) 47 | 48 | ;; simulate subscription and submission 49 | ;(let [ddp (DdpClient. (:host targetUrl) (get-port targetUrl)) 50 | (let [ddp (DdpClient. "localhost" (int 3000)) 51 | ;obs (SimpleDdpClientObserver.) 52 | ] 53 | ;(.addObserver ddp obs) 54 | (.connect ddp) 55 | (.sleep grinder 1000) 56 | ;(Thread/sleep 1000) 57 | (.subscribe ddp "entries" (object-array [])) 58 | 59 | (.. test2 (record ddp)) 60 | (.call ddp "addEntry" (object-array [])) 61 | ;; ensure we receive other updates over time 62 | ;(Thread/sleep 10000) 63 | (.sleep grinder 10000) 64 | (.unsubscribe ddp "entries") 65 | ) 66 | ) 67 | ) 68 | ) 69 | -------------------------------------------------------------------------------- /grinder/meteor.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Load-testing Meteor apps with The Grinder 3 | ;; 4 | 5 | (ns meteor-load-test.http 6 | (:import [net.grinder.script Grinder Test] 7 | [net.grinder.plugin.http HTTPRequest] 8 | ) 9 | (:use [meteor-load-test core util subscriptions method_calls]) 10 | ) 11 | 12 | (let [grinder Grinder/grinder 13 | test1 (Test. 1 "Retrieve initial payload") 14 | test2 (Test. 2 "DDP subscriptions") 15 | test3 (Test. 3 "DDP calls") 16 | properties (.getProperties grinder) 17 | ] 18 | 19 | (defn instrumented-get [url] 20 | (log "Requesting url: " url) 21 | (.. (HTTPRequest.) (GET url))) 22 | 23 | ;; record calls to the instrumented function 24 | (.. test1 (record instrumented-get)) 25 | (.. test2 (record subscribe)) 26 | (.. test3 (record call-method)) 27 | 28 | (defn get-client-id [] 29 | (str (.getAgentNumber grinder) "-" 30 | (.getProcessName grinder) "-" 31 | (.getThreadNumber grinder))) 32 | 33 | (defn get-run-id [] 34 | (.getRunNumber grinder)) 35 | 36 | (defn stop-fn [] 37 | (#(.stopThisWorkerThread grinder))) 38 | 39 | (defn sleep-fn [ms] 40 | (.sleep grinder ms)) 41 | 42 | ;; return function that is executed once per thread by each worker process 43 | (worker-thread-factory 44 | stop-fn 45 | sleep-fn 46 | properties 47 | get-client-id 48 | get-run-id 49 | instrumented-get 50 | )) 51 | -------------------------------------------------------------------------------- /grinder/template.clj: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; Grinder template 3 | ;; 4 | 5 | ;; declare symbols used in the script 6 | (let [grinder net.grinder.script.Grinder/grinder] 7 | 8 | ;; the factory function 9 | ;; called once by each worker thread 10 | (fn [] 11 | 12 | ;; the test runner function 13 | ;; called once on each test run 14 | (fn [] 15 | 16 | ) ;; end of test runner function 17 | ) ;; end of factory function 18 | ) ;; end of script let form -------------------------------------------------------------------------------- /grinder/working.properties: -------------------------------------------------------------------------------- 1 | # This is a set of demonstration scripts 2 | #grinder.script=hello.clj 3 | #grinder.script=hello_test.clj 4 | #grinder.script=http.clj 5 | #grinder.script=http_instrumented.clj 6 | #grinder.script=http_stats.clj 7 | #grinder.script=http_binding.clj 8 | #grinder.script=http_shared.clj 9 | 10 | grinder.script = meteor.clj 11 | 12 | grinder.targetUrl = http://localhost:3000/ 13 | 14 | # Fetch initial html and referenced assets? Default = false 15 | #grinder.downloadPayload = true 16 | 17 | # If users is supplied, each run will login via a randomly- 18 | # selected email. The supplied value should be a JSON array 19 | # of key-value pairs where the key is an email address and 20 | # the value is a password. 21 | # 22 | # ex. 23 | # 24 | # grinder.users = [ {"test@example.com":"crystal"}, {"test2@example.com":"crystal"} ] 25 | # 26 | #grinder.users = [ {"test@example.com":"crystal"}, {"test2@example.com":"crystal"} ] 27 | 28 | # For OAUTH, you'll need to use resume tokens to perform logins. 29 | # It can be a pain to obtain the tokens, easier to use accounts- 30 | # password if possible. 31 | # If resumeTokens is supplied, each run will login via a randomly- 32 | # selected token. The supplied value should be a string; multiple 33 | # tokens are supported using comma separators. 34 | # 35 | # ex. 36 | # 37 | # grinder.resumeTokens = "2mq3jD4Zb4XeLvwRC,cSB6DnERrk6ZFss58,5r5DoQNFNtwasCmHB" 38 | # 39 | # 40 | # Tokens can be obtained via: 41 | # 42 | # a) database - Login once using a browser so the resume token 43 | # is stored in the user's record. The user's 44 | # 'services.resume.loginTokens' array contains 45 | # the appropriate value in the latest entry's 46 | # 'token' field. 47 | # ex. 48 | # db.users.find({"emails.address":"test@example.com"}, 49 | # {"services.resume.loginTokens":1}) 50 | # => [{"token" : "5r5DoQNFNtwasCmHB", 51 | # "when" : 1382630975632}] 52 | # 53 | # b) browser - Login once using a browser, then open your 54 | # browser's developer tools (firebug, chrome 55 | # dev tools, etc), then refresh. 56 | # The Network tab will have a DDP call to the 57 | # 'login' method with the resume token as a 58 | # parameter. 59 | # ex. 60 | # ["{\"msg\":\"method\",\"method\":\"login\", 61 | # \"params\":[{\"resume\":\"5r5DoQNFNtwasCmHB\"}], 62 | # \"id\":\"1\"}"] 63 | # 64 | #grinder.resumeTokens = "5r5DoQNFNtwasCmHB,2mq3jD4Zb4XeLvwRC,cSB6DnERrk6ZFss58" 65 | 66 | # Subscriptions and calls expect a json array. Array elements 67 | # can be either: 68 | # * string - interpreted as name with no args 69 | # * object - keys interpreted as name, value must be an array 70 | # of arguments which will be passed to server 71 | # 72 | # These keywords will be replaced automatically: 73 | # CLIENTID - id unique to executing thread 74 | # RUNID - number corresponding to current test run 75 | # 76 | grinder.subscriptions = ["entry-count", {"latest-entries":[60]}] 77 | grinder.calls = [{"addEntry":[{"ownerId":"CLIENTID","name":"load-RUNID","type":"client"}]}] 78 | 79 | # Agent config settings 80 | # 81 | # Number of simulated clients = agents * processes * threads 82 | # 83 | # Each thread (client) will: 84 | # 1. Request initial payload 85 | # 2. Request css & scripts found in initial response 86 | # 3. Initiate DDP connection 87 | # 4. Login via random resumeToken (if supplied) 88 | # 5. Subscribe to subscriptions specified above 89 | # 6. Perform method calls specified above 'runs' number of times 90 | # 91 | # See http://grinder.sourceforge.net/g3/properties.html 92 | # for more options such as, initialSleepTime or processIncrement 93 | # 94 | grinder.processes = 1 95 | grinder.threads = 1 96 | grinder.runs = 1000 97 | 98 | # Number of milliseconds to wait between method calls 99 | #grinder.callDelayMs = 200 100 | 101 | # Maximum amount of time in ms thread will wait before starting. 102 | # Actual sleep time will be a random value between 0 and specified 103 | # value 104 | #grinder.initialSleepTime = 2000 105 | 106 | 107 | # Set debug to true to see a bit more detail 108 | # Collection updates from server will always be logged 109 | grinder.debug = false 110 | 111 | # for some reason, the log directory required restating 112 | grinder.logDirectory = log 113 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject meteor-load-test "0.5.1" 2 | :description "Meteor load testing tool" 3 | :min-lein-version "2.0.0" 4 | :url "https://github.com/alanning/meteor-load-test" 5 | :license {:name "Eclipse Public License" 6 | :url "http://www.eclipse.org/legal/epl-v10.html"} 7 | :dependencies [[org.clojure/clojure "1.4.0"] 8 | [org.clojure/data.json "0.2.2"] 9 | [com.google.code.gson/gson "2.2.2"] 10 | [com.keysolutions/java-ddp-client "0.5.7.5" 11 | :exclusions [org.java-websocket/Java-WebSocket]] 12 | [com.palletops/java-websocket "1.3.1-SNAPSHOT"]] 13 | :profiles {:dev 14 | {:dependencies 15 | [[clj-http "0.2.7"] 16 | [clj-stacktrace "0.2.4"] 17 | [org.clojars.runa/conjure "2.1.3"] 18 | [net.sf.grinder/grinder "3.11"]] }} 19 | :repositories {"sonatype" "https://oss.sonatype.org/content/repositories/releases/"} 20 | ) 21 | 22 | (comment (defproject meteor-load-test "0.5.1" 23 | :description "Meteor load testing tool" 24 | :min-lein-version "2.0.0" 25 | :url "https://github.com/alanning/meteor-load-test" 26 | :license {:name "Eclipse Public License" 27 | :url "http://www.eclipse.org/legal/epl-v10.html"} 28 | :dependencies [[org.clojure/clojure "1.4.0"] 29 | [org.clojure/data.json "0.2.2"] 30 | [com.google.code.gson/gson "2.2.2"] 31 | [com.keysolutions/java-ddp-client "0.5.7.5"] 32 | ] 33 | :profiles {:dev 34 | {:dependencies 35 | [[clj-http "0.2.7"] 36 | [clj-stacktrace "0.2.4"] 37 | [org.clojars.runa/conjure "2.1.3"] 38 | [net.sf.grinder/grinder "3.11"]] }} 39 | :repositories {"sonatype" "https://oss.sonatype.org/content/repositories/releases/"} 40 | )) 41 | -------------------------------------------------------------------------------- /src/meteor_load_test/core.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.core 2 | (:require [clojure.data.json :as json] 3 | [clojure.string :as str]) 4 | (:import [java.net URI] 5 | [com.keysolutions.ddpclient DDPClient]) 6 | (:use [meteor-load-test util method_calls subscriptions initial_payload]) 7 | ) 8 | 9 | (declare test-runner-factory) 10 | (def ddp-connected 11 | com.keysolutions.ddpclient.DDPClient$CONNSTATE/Connected) 12 | 13 | (defn worker-thread-factory 14 | "Returns an anonymous function which is called once by each 15 | worker thread and which returns a test runner function. 16 | Responsible for: initial payload get, ddp connection, and 17 | subscriptions" 18 | [stop-fn sleep-fn propertyBag get-client-id get-run-id get-html] 19 | 20 | (let [target-url-str (.getProperty propertyBag "grinder.targetUrl") 21 | users (get-json-property propertyBag "grinder.users") 22 | resume-tokens (.getProperty propertyBag "grinder.resumeTokens") 23 | call-delay-ms (if-let [delay-ms (.getProperty propertyBag "grinder.callDelayMs")] 24 | (Integer. delay-ms) 25 | 0) 26 | calls-raw (.getProperty propertyBag "grinder.calls") 27 | subscriptions (get-json-property propertyBag "grinder.subscriptions") 28 | download-payload? (.getProperty propertyBag "grinder.downloadPayload") 29 | debug? (.getBoolean propertyBag "grinder.debug" false)] 30 | 31 | (fn [] 32 | 33 | (when (empty? target-url-str) 34 | (log "Missing required setting 'grinder.targetUrl'") 35 | (stop-fn)) 36 | 37 | (def targetUrl (URI/create target-url-str)) 38 | 39 | (when debug? 40 | (log "grinder.targetUrl: " target-url-str) 41 | (log "grinder.downloadPayload?: " download-payload?) 42 | (log "grinder.users: " users) 43 | (log "grinder.resumeTokens: " resume-tokens) 44 | (log "grinder.subscriptions: " subscriptions) 45 | (log "grinder.calls: " calls-raw) 46 | (log "host: " (.getHost targetUrl) ", port: " (get-port targetUrl))) 47 | 48 | (let [ddp (DDPClient. (.getHost targetUrl) 49 | (get-port targetUrl) 50 | (isSSL targetUrl)) 51 | client-id (get-client-id) 52 | ] 53 | 54 | (when debug? 55 | (log "client id: " client-id)) 56 | 57 | ;; download initial html payload and all referenced files 58 | (when download-payload? 59 | (fetch-static-assets get-html target-url-str)) 60 | 61 | ;(if debug? (.addObserver ddp (SimpleDdpClientObserver.))) 62 | 63 | ;; connect ddp client 64 | (.connect ddp) 65 | 66 | ;; wait for the websocket to connect and handshake 67 | (loop [retries 5] 68 | (try 69 | (Thread/sleep 1000); 70 | (catch InterruptedException e)) 71 | (when (and (pos? retries) (not= ddp-connected (.getState ddp))) 72 | (log "Waiting for websocket connection to handshake") 73 | (recur (dec retries)))) 74 | 75 | (when-not (= ddp-connected (.getState ddp)) 76 | (throw (ex-info "Websocket connection failed to handshake" {}))) 77 | 78 | ;; perform login via random user info, if provided 79 | (cond 80 | (not-empty users) 81 | (let [credentials (first (rand-nth users))] 82 | (when debug? 83 | (log "logging in with user credentials " credentials)) 84 | (call-method ddp "login" [{"user" {"email" (key credentials)} "password" (val credentials)}])) 85 | (not-empty resume-tokens) 86 | (let [tokens (clojure.string/split resume-tokens #",") 87 | resume-token (rand-nth tokens)] 88 | (when debug? 89 | (log "logging in with resume token " resume-token)) 90 | (call-method ddp "login" [{"resume" resume-token}]))) 91 | 92 | ;; initiate subscriptions 93 | (perform-subscriptions ddp client-id subscriptions) 94 | 95 | ;; return function that will be executed for each test run 96 | (let [sleep #(sleep-fn call-delay-ms)] 97 | (test-runner-factory sleep client-id get-run-id (partial call-method ddp) calls-raw)) 98 | 99 | ) ; let ddp-client, id 100 | ) ; returned fn 101 | ) ; let 102 | ) ; worker-thread 103 | 104 | 105 | (defn test-runner-factory 106 | "Returns an anonymous function which is run by each worker thread." 107 | [sleep client-id get-run-id call-method-fn calls-raw] 108 | (fn [] 109 | 110 | (comment 111 | (log "test run: " (str client-id "-" (get-run-id)))) 112 | 113 | (if (empty? calls-raw) 114 | (log "No DDP calls to perform") 115 | (let [run-id (get-run-id) 116 | entry-name (str "load-" run-id) 117 | keywords (make-keywords client-id run-id) 118 | calls (-> calls-raw 119 | (replace-words keywords) 120 | json/read-str)] 121 | 122 | (comment 123 | (log "keywords " keywords) 124 | (log "run-id " run-id) 125 | (log "entry-name " entry-name) 126 | (log "calls " calls)) 127 | 128 | (perform-calls sleep call-method-fn calls) 129 | 130 | )) ; non-empty calls 131 | 132 | ) ; returned anonymous fn 133 | ) ; test-runner-factory 134 | -------------------------------------------------------------------------------- /src/meteor_load_test/ddp_action.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.ddp_action 2 | (:use [meteor-load-test util])) 3 | 4 | (defn perform-ddp-action 5 | "Calls function f once for each element in vector v with 6 | delay if supplied. f is a function of 1 or 2 arguments. 7 | v should contain elements of the following form: 8 | * string - interpreted as method name or subscription 9 | with no parameters 10 | * map - interpreted as method name or subscription 11 | with parameter array. 12 | ex. 'method-name':[arg1, arg2, etc.]" 13 | [sleep invalid-msg f v] 14 | (doseq [item v] 15 | (when sleep 16 | ;(Thread/sleep sleep-ms)) 17 | (sleep)) 18 | (cond 19 | (map? item) 20 | (doseq [[method-name params] item] 21 | (if (vector? params) 22 | (f method-name params) 23 | (log invalid-msg))) 24 | (string? item) 25 | (f item) 26 | :else 27 | (log "unsupported: " item)))) 28 | 29 | -------------------------------------------------------------------------------- /src/meteor_load_test/initial_payload.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.initial_payload) 2 | 3 | (declare process-urls) 4 | (declare drop-last-if) 5 | 6 | (defn fetch-static-assets 7 | "Fetch initial html payload and all referenced assets. 8 | get-html = function that returns result of HTTPRequest get 9 | target-url-str = url of system under test in string format" 10 | [get-html target-url-str] 11 | 12 | ;; make initial http fetch 13 | (let [initial-html (.getText (get-html target-url-str))] 14 | 15 | (defn- get-relative-url 16 | "Gets a url relative to base url. Relative url may 17 | start with /" 18 | [base-url relative-url] 19 | (let [base (drop-last-if '\/ base-url) 20 | rel (if (= \/ (first relative-url)) 21 | relative-url 22 | (str \/ relative-url))] 23 | (get-html (str base rel)))) 24 | 25 | ;; make subsequent javascript / css fetches 26 | (process-urls 27 | (partial get-relative-url target-url-str) 28 | initial-html))) 29 | 30 | (defn process-urls 31 | "Executes f for each css link or javascript src in 32 | html." 33 | [f html] 34 | (let [css (re-seq #"href=\"([^\"]+)\"" html) 35 | scripts (re-seq #"src=\"([^\"]+)\"" html) 36 | coll (concat css scripts)] 37 | (doseq [[_ url] coll] 38 | (f url)))) 39 | 40 | (defn drop-last-if 41 | "Returns string s without last character c, or s as appropriate" 42 | [c s] 43 | (if (= c (last s)) 44 | (apply str (pop (vec s))) 45 | s)) 46 | 47 | -------------------------------------------------------------------------------- /src/meteor_load_test/method_calls.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.method_calls 2 | (:use [meteor-load-test util ddp_action])) 3 | 4 | (def invalid-calls-msg "Property must be of form: 5 | grinder.calls = [, , etc...] 6 | where is one of: 7 | * \"method-name\" - ex. [\"keepAlive\"] 8 | * {\"addEntry\":[{\"ownerId\":\"CLIENTID\",\"name\":\"load-RUNID\",\"type\":\"client\"}]} ") 9 | 10 | (defn call-method 11 | "Calls a Meteor method. Converts args to an Object[] 12 | before passing to DDP client" 13 | ([ddp method-name] 14 | (log "calling: " method-name) 15 | (.call ddp method-name (object-array []))) 16 | ([ddp method-name v] 17 | (log "calling: " method-name " with args: " v) 18 | (.call ddp method-name (object-array v)))) 19 | 20 | (defn perform-calls 21 | "Calls meteor methods using supplied fn. Valid elements 22 | of seq s include: 23 | * string = method name with no parameters 24 | * map = of form 'method-name':[arg1, arg2, etc.]" 25 | [sleep call-method-fn s] 26 | (perform-ddp-action sleep invalid-calls-msg call-method-fn s)) 27 | 28 | -------------------------------------------------------------------------------- /src/meteor_load_test/subscriptions.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.subscriptions 2 | (:use [meteor-load-test util ddp_action])) 3 | 4 | (def invalid-subscription-msg 5 | "Property must be of form: 6 | grinder.subscriptions = [, , etc...] 7 | where is one of: 8 | * \"collection-name\" - ex. [\"entries\"] 9 | * {\"collection-name\":[\"param1\",2,\"param3\"]} ") 10 | 11 | (defn subscribe 12 | "Subscribes to a Meteor collection. Converts args to 13 | an Object[] before passing to DDP client" 14 | ([ddp client-id collection-name] 15 | (log client-id " subscribing to: " collection-name) 16 | (.subscribe ddp collection-name (object-array []))) 17 | ([ddp client-id collection-name v] 18 | (log client-id " subscribing to: " collection-name v) 19 | (.subscribe ddp collection-name (object-array v)))) 20 | 21 | (defn perform-subscriptions 22 | "Subscribes to collections specified in s. Elements of s 23 | should be of the form: 24 | * string - collection name with no parameters 25 | * map - solleciton name with parameters of form 26 | 'method-name':[arg1, arg2, etc.]" 27 | [ddp client-id s] 28 | (let [do-action (partial subscribe ddp client-id)] 29 | (log "perform-subscriptions") 30 | (perform-ddp-action nil invalid-subscription-msg do-action s))) 31 | 32 | -------------------------------------------------------------------------------- /src/meteor_load_test/util.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.util 2 | (:require [clojure.data.json :as json] 3 | [clojure.string :as str]) 4 | (:import [java.net URI]) 5 | ) 6 | 7 | (defn log [& text] 8 | (.. System out (println (apply str text)))) 9 | 10 | (defn make-keywords [client-id run-id] 11 | {"CLIENTID" client-id, "RUNID" (str run-id)}) 12 | 13 | (defn replace-words 14 | "Replaces all occurances of words in sentence based 15 | on replacement-map" 16 | [s replacement-map] 17 | (reduce (fn [sentence [match replacement]] 18 | (str/replace sentence match replacement)) s replacement-map)) 19 | 20 | (defn ensure-uri [targetUri] 21 | (cond 22 | (string? targetUri) (URI/create targetUri) 23 | (= java.net.URI (type targetUri)) targetUri 24 | :else (throw (Exception. "Unsupported argument type")))) 25 | 26 | (defn isSSL 27 | "True if target url uses https, false otherwise" 28 | [url] 29 | (= (.getScheme url) "https")) 30 | 31 | (defn get-port 32 | "Accepts a string or java.net.URI. If URI port not 33 | specified, returns port based on Scheme/Protocol." 34 | [targetUri] 35 | (let [port (.getPort (ensure-uri targetUri))] 36 | (cond 37 | (= -1 port) 38 | (cond 39 | (isSSL targetUri) (int 443) 40 | :else (int 80)) 41 | :else (int port)))) 42 | 43 | (defn get-json-property 44 | "Get JSON from a Java property bag. Returns nil 45 | if property doesn't exist" 46 | [bag name] 47 | (let [prop (.getProperty bag name)] 48 | (if (empty? prop) 49 | nil 50 | (json/read-str prop)))) 51 | 52 | -------------------------------------------------------------------------------- /sut/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | meteorite 3 | -------------------------------------------------------------------------------- /sut/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # 3 | # 'meteor add' and 'meteor remove' will edit this file for you, 4 | # but you can also edit it by hand. 5 | 6 | insecure 7 | preserve-inputs 8 | audit-argument-checks 9 | -------------------------------------------------------------------------------- /sut/.meteor/release: -------------------------------------------------------------------------------- 1 | 0.6.4.1 2 | -------------------------------------------------------------------------------- /sut/client/client.js: -------------------------------------------------------------------------------- 1 | ;(function () { 2 | 3 | ////////////////////////////////////////////////////////////////////// 4 | // setup 5 | ////////////////////////////////////////////////////////////////////// 6 | 7 | var clientId = Meteor.uuid(), 8 | entryIndex = 0, 9 | TotalEntryCount, 10 | colorIdMap = {}; 11 | 12 | TotalEntryCount = new Meteor.Collection("total-entry-count"); 13 | 14 | Meteor.startup(function () { 15 | Session.set('numEntriesToDisplay', 10); 16 | 17 | Deps.autorun(function () { 18 | var num = Session.get('numEntriesToDisplay'); 19 | console.log('subscribing to get', num, ' latest entries'); 20 | Meteor.subscribe("latest-entries", num, function () { 21 | Session.set('entriesReady', true); 22 | }); 23 | }); 24 | 25 | Meteor.subscribe("entry-count", function () { 26 | Session.set('countReady', true); 27 | }); 28 | }); 29 | 30 | 31 | ////////////////////////////////////////////////////////////////////// 32 | // templates 33 | ////////////////////////////////////////////////////////////////////// 34 | 35 | Template.main.helpers({ 36 | entries: function () { 37 | var options = { 38 | sort: { createdAt: -1 } 39 | }; 40 | 41 | if (Session.equals('entriesReady', true)) { 42 | return Meteor.entries.find({}, options); 43 | } 44 | }, 45 | 46 | totalEntryCount: function () { 47 | if (Session.equals('countReady', true)) { 48 | var doc = TotalEntryCount.findOne({_id:1}); 49 | return doc ? doc.count : 'calculating...'; 50 | } else { 51 | return 'calculating...'; 52 | } 53 | }, 54 | 55 | /** 56 | * Uses the 'ownerId' field to compute a color for current 57 | * entry. 58 | * 59 | * @method colorTag 60 | * @return {String} color in hex format '#ff0000' 61 | */ 62 | colorTag: function () { 63 | var id = this.ownerId, 64 | defaultColor = '#ff0000'; 65 | 66 | if ('undefined' == typeof id || null === id) 67 | return defaultColor; 68 | 69 | if (!colorIdMap[id]) { 70 | colorIdMap[id] = randomColor(); 71 | } 72 | 73 | return colorIdMap[id]; 74 | } 75 | }); 76 | 77 | Template.main.events({ 78 | 'change #numEntries' : function (evt) { 79 | evt.preventDefault(); 80 | Session.set('numEntriesToDisplay', parseInt(evt.target.value, 10)); 81 | }, 82 | 83 | 'click button' : function (evt) { 84 | var entry; 85 | 86 | evt.preventDefault(); 87 | 88 | entryIndex++; 89 | 90 | entry = { 91 | ownerId: clientId, 92 | name: "entry-" + entryIndex, 93 | type: "client", 94 | createdAt: new Date() 95 | }; 96 | 97 | Meteor.call('addEntry', entry, function(error, id) { 98 | if (error) { 99 | console.log(error); 100 | } 101 | }); 102 | } 103 | }); 104 | 105 | 106 | ////////////////////////////////////////////////////////////////////// 107 | // misc 108 | ////////////////////////////////////////////////////////////////////// 109 | 110 | function randomColor() { 111 | var text = '', 112 | possible = 'abcdef0123456789'; 113 | 114 | for( var i=0; i < 6; i++ ) { 115 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 116 | } 117 | 118 | return '#' + text; 119 | } 120 | 121 | }()); 122 | -------------------------------------------------------------------------------- /sut/client/load-test.css: -------------------------------------------------------------------------------- 1 | /* CSS declarations go here */ 2 | 3 | body { font-size:16px; } 4 | 5 | dt, dd { display:inline-block; border-bottom:1px dotted #ccc; } 6 | dt { width: 6em; } 7 | dd { font-size:0.8em; color:#333; } 8 | .color-tag {width:1.5em;border:none;} 9 | 10 | button, .total-entry-count { margin-bottom:1em; margin-left:1em; } 11 | select, button { padding:0.8em 1em; line-height:1.5em; font-size:1.1em; } 12 | 13 | .total-entry-count, .num-entries-form { display:inline-block; margin-right:2em; } 14 | -------------------------------------------------------------------------------- /sut/client/load-test.html: -------------------------------------------------------------------------------- 1 | 2 | load-test 3 | 4 | 5 | 6 | {{> main}} 7 | 8 | 9 | 44 | -------------------------------------------------------------------------------- /sut/common.js: -------------------------------------------------------------------------------- 1 | Meteor.entries = new Meteor.Collection("entries"); 2 | -------------------------------------------------------------------------------- /sut/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | meteor --settings settings.json 3 | -------------------------------------------------------------------------------- /sut/server/server.js: -------------------------------------------------------------------------------- 1 | ;(function () { 2 | 3 | ////////////////////////////////////////////////////////////////////// 4 | // startup 5 | ////////////////////////////////////////////////////////////////////// 6 | 7 | var settings = Meteor.settings || {}, 8 | serverId = settings.serverId || Meteor.uuid(), 9 | entryIndex = 0; 10 | 11 | Meteor.startup(function () { 12 | Meteor.entries._ensureIndex({createdAt: -1}); 13 | 14 | createServerEntries(); 15 | }); 16 | 17 | 18 | ////////////////////////////////////////////////////////////////////// 19 | // methods 20 | ////////////////////////////////////////////////////////////////////// 21 | 22 | Meteor.methods({ 23 | addEntry: function (entry) { 24 | check(entry, Match.Optional({ 25 | ownerId: String, 26 | name: String, 27 | type: String, 28 | createdAt: Match.Optional(Date) 29 | })); 30 | 31 | if (!entry) { 32 | entry = { 33 | ownerId: serverId, 34 | name: "entry-" + entryIndex++, 35 | type: "server" 36 | }; 37 | } 38 | 39 | if (!entry.createdAt) { 40 | entry.createdAt = new Date() 41 | } 42 | 43 | return Meteor.entries.insert(entry); 44 | } 45 | }); 46 | 47 | 48 | ////////////////////////////////////////////////////////////////////// 49 | // publish 50 | ////////////////////////////////////////////////////////////////////// 51 | 52 | Meteor.publish("entry-count", function () { 53 | var self = this, 54 | count = 0, 55 | docId = 1, 56 | initializing = true, 57 | handle; 58 | 59 | handle = Meteor.entries.find({}).observeChanges({ 60 | added: function (id) { 61 | count++; 62 | if (!initializing) 63 | self.changed("total-entry-count", docId, {count: count}); 64 | }, 65 | removed: function (id) { 66 | count--; 67 | self.changed("total-entry-count", docId, {count: count}); 68 | } 69 | // don't care about moved or changed 70 | }); 71 | 72 | initializing = false; 73 | self.added("total-entry-count", docId, {count: count}); 74 | self.ready(); 75 | 76 | self.onStop(function () { 77 | handle.stop(); 78 | }); 79 | }); 80 | 81 | Meteor.publish("latest-entries", function (limit) { 82 | check(limit, Match.Optional(Number)); 83 | 84 | var settings = Meteor.settings || {}, 85 | query = settings.query || {}, 86 | sort = settings.sort, 87 | limit, 88 | options; 89 | 90 | limit = limit || settings.limit || 10, 91 | 92 | options = { 93 | sort: sort || { createdAt: -1 }, 94 | limit: limit 95 | }; 96 | 97 | return Meteor.entries.find(query, options); 98 | }); 99 | 100 | 101 | ////////////////////////////////////////////////////////////////////// 102 | // misc 103 | ////////////////////////////////////////////////////////////////////// 104 | 105 | function createServerEntries () { 106 | var i = 0, 107 | entry; 108 | 109 | entry = Meteor.entries.findOne({ownerId: serverId}); 110 | 111 | if (entry) { 112 | // already have entries for this server 113 | return; 114 | } 115 | 116 | console.log('creating default entries for server id ' + serverId); 117 | 118 | for (; i < 1000; i++) { 119 | entry = { 120 | ownerId: serverId, 121 | name: "entry-" + (i+1), 122 | type: "server", 123 | createdAt: new Date() 124 | }; 125 | Meteor.entries.insert(entry); 126 | } 127 | } // end createServerEntries 128 | 129 | 130 | }()); 131 | -------------------------------------------------------------------------------- /sut/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "serverId": 1, 3 | "query": {}, 4 | "sort": {"createdAt": -1}, 5 | "limit": 10 6 | } 7 | -------------------------------------------------------------------------------- /test/meteor_load_test/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.core-test 2 | (:require [clojure.test :refer :all] 3 | [meteor-load-test.core :refer :all])) 4 | 5 | (deftest a-test 6 | (testing "Empty test" 7 | (is (= 1 1)))) 8 | -------------------------------------------------------------------------------- /test/meteor_load_test/initial_payload_test.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.initial_payload_test 2 | (:require [clojure.test :refer :all] 3 | [conjure.core :refer :all] 4 | [meteor-load-test.initial_payload :refer :all])) 5 | 6 | (deftest test-drop-last-if 7 | (testing "drop-last-if" 8 | (let [s1 "/client/load-test.css?51243234" 9 | s2 "/client/load-test.css?51243234/" 10 | s3 "http://localhost:3000" 11 | s4 "http://localhost:3000/"] 12 | (is (= s1 (drop-last-if '\/ s1))) 13 | (is (= s1 (drop-last-if '\/ s2))) 14 | (is (= s3 (drop-last-if '\/ s3))) 15 | (is (= s3 (drop-last-if '\/ s4)))))) 16 | 17 | (def html " 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | load-test 32 | ") 33 | 34 | (deftest test-process-urls 35 | (defn f [method-name]) 36 | (mocking [f] 37 | (process-urls f html) 38 | (verify-call-times-for f 7) 39 | (verify-first-call-args-for f "/client/load-test.css?c946c3d657a4acb7b5d72e3ad90c123dc170eb80") 40 | (verify-nth-call-args-for 2 f "/client/load-test2.css") 41 | (verify-nth-call-args-for 3 f "/client/load-test3.css") 42 | (verify-nth-call-args-for 4 f "appicon-60.png") 43 | (verify-nth-call-args-for 5 f "/packages/underscore/underscore.js?ed2d2b960c0e746b3e4f9282d5de66ef7b1a2b4d") 44 | (verify-nth-call-args-for 6 f "/packages/meteor/client_environment.js?07a7cfbe7a2389cf9855c7db833f12202a656c6b") 45 | (verify-nth-call-args-for 7 f "/packages/meteor/helpers.js?2968aa157e0a16667da224b8aa48edb17fbccf7c"))) 46 | 47 | -------------------------------------------------------------------------------- /test/meteor_load_test/method_calls_test.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.method_calls_test 2 | (:require [clojure.test :refer :all] 3 | [conjure.core :refer :all] 4 | [meteor-load-test.util :refer :all] 5 | [meteor-load-test.method_calls :refer :all])) 6 | 7 | (deftest test-perform-calls 8 | (defn do-call 9 | ([method-name]) 10 | ([method-name params])) 11 | (let [call-with-args {"addEntry" [{"ownerId" "client1" 12 | "name" "load-1" 13 | "type" "client"}]} 14 | calls ["no-args" call-with-args]] 15 | (mocking [do-call log] 16 | (perform-calls nil do-call calls) 17 | (verify-call-times-for do-call 2) 18 | (verify-first-call-args-for do-call "no-args") 19 | (verify-nth-call-args-for 2 do-call "addEntry" (get call-with-args "addEntry"))))) 20 | 21 | -------------------------------------------------------------------------------- /test/meteor_load_test/util_test.clj: -------------------------------------------------------------------------------- 1 | (ns meteor-load-test.util_test 2 | (:require [clojure.test :refer :all] 3 | [conjure.core :refer :all] 4 | [meteor-load-test.util :refer :all])) 5 | 6 | (deftest test-replace-words 7 | (testing "Test replace words" 8 | (let [m {"CLIENTID" "client1", "RUNID" "run1"} 9 | s "My clientid is 'CLIENTID'. My runid is 'RUNID'." 10 | m2 {"CLIENTID" "0-156.122.171.108.client.dyn.strong-in125.as13926.net-16-0", "RUNID" "5"} 11 | s2 "[\"addEntry\", {\"addEntry\":[{\"ownerId\":\"CLIENTID\",\"name\":\"load-RUNID\",\"type\":\"client\"}]}]" 12 | expected1 "My clientid is 'client1'. My runid is 'run1'." 13 | expected2 "[\"addEntry\", {\"addEntry\":[{\"ownerId\":\"0-156.122.171.108.client.dyn.strong-in125.as13926.net-16-0\",\"name\":\"load-5\",\"type\":\"client\"}]}]" 14 | ] 15 | (is (= expected1 (replace-words s m))) 16 | (is (= expected2 (replace-words s2 m2)))))) 17 | 18 | (deftest test-ensure-uri-string 19 | (testing "ensure-uri with string param" 20 | (is (= java.net.URI (type (ensure-uri "http://example.com/")))))) 21 | 22 | (deftest test-ensure-uri-class 23 | (testing "ensure-uri with class param" 24 | (is (= java.net.URI (type (ensure-uri (java.net.URI/create "http://example.com/"))))))) 25 | 26 | (deftest test-get-port 27 | (testing "get-port" 28 | (testing "when port specified" 29 | (is (= (get-port (ensure-uri "http://example.com:8080/")) 8080)) 30 | (is (= (get-port (ensure-uri "http://example.com:3769")) 3769)) 31 | (is (= (get-port (ensure-uri "http://example.com:443")) 443))) 32 | (testing "when port not specified" 33 | (is (= (get-port (ensure-uri "http://example.com/")) 80)) 34 | (is (= (get-port (ensure-uri "https://example.com")) 443))))) 35 | 36 | (deftest test-isSSL 37 | (testing "isSSL" 38 | (testing "when port specified" 39 | (is (= (isSSL (ensure-uri "http://example.com:8080/")) false)) 40 | (is (= (isSSL (ensure-uri "http://example.com:3769")) false)) 41 | (is (= (isSSL (ensure-uri "http://example.com:443")) false)) 42 | (is (= (isSSL (ensure-uri "https://example.com:443")) true))) 43 | (testing "when port not specified" 44 | (is (= (isSSL (ensure-uri "http://example.com/")) false)) 45 | (is (= (isSSL (ensure-uri "https://example.com")) true))))) 46 | --------------------------------------------------------------------------------