Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User simulator #3343

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/clj/rems/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
[mount.core :as mount]
[rems.application.search :as search]
[rems.common.git :as git]
[rems.common.util :refer [not-blank]]
[rems.config :refer [env]]
[rems.db.api-key]
[rems.db.applications]
Expand All @@ -24,6 +25,7 @@
[rems.service.fix-userid]
[rems.service.test-data :as test-data]
[rems.service.users]
[rems.user-simulator]
[rems.validate :as validate])
(:import [sun.misc Signal SignalHandler]
[org.eclipse.jetty.server.handler.gzip GzipHandler])
Expand Down Expand Up @@ -146,7 +148,10 @@
Example regex: /api/applications/[0-9]+/?
\"api-key allow-all <api-key>\" -- clears the allowed method/path whitelist.
An empty list means all methods and paths are allowed.
\"rename-user <old-userid> <new-userid>\" -- change a user's identity from old to new"
\"rename-user <old-userid> <new-userid>\" -- change a user's identity from old to new
\"user-simulator <url> <users>\" -- start user simulator that runs concurrent headless browser instances against target REMS.
<url> is target REMS (e.g. http://localhost:3000).
<users> is an comma-separated list of user ids (e.g. alice,elsa,frank). Each user is simulated in separate thread."
[& args]
(try
(exit-on-signals!)
Expand Down Expand Up @@ -300,6 +305,13 @@
(rems.service.fix-userid/fix-all old-userid new-userid simulate?)
(println "Finished.\n\nConsider rebooting the server process next to refresh all the caches, most importantly the application cache.")))))

"user-simulator"
(let [[_ url users] args]
(rems.user-simulator/start! {:url url
:users (when (not-blank users)
(mapv str/trim (str/split users #",")))})
(.addShutdownHook (Runtime/getRuntime) (Thread. rems.user-simulator/stop!)))

(do
(println "Unrecognized argument:" (first args))
(usage)
Expand Down
213 changes: 213 additions & 0 deletions src/clj/rems/user_simulator.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
(ns rems.user-simulator
"Namespace for simulating actual user interactions with in REMS, allowing
automated testing of user behavior. Simulations run in a headless browser
using etaoin with chromedriver, and can be configured via the command line
interface (CLI). Various user scenarios are implemented as functions in this
namespace, enabling flexible testing of different workflows. Multiple
simulations can run concurrently to test system performance and user load.

User simulator can be started from CLI with e.g. Leiningen:
- `lein run user-simulator` (uses default values), or
- `lein run user-simulator http://localhost:3000/ alice,elsa,frank` when replacing default values"
(:require [clj-http.client :as http]
[clojure.pprint]
[clojure.string :as str]
[clojure.tools.logging.readable :as logr]
[etaoin.api :as et]
[mount.core :as mount]
[muuntaja.core :as muuntaja]
[rems.browser-test-util :as btu]
[rems.common.util :refer [rand-nth*]]
[rems.config]
[rems.concurrency :as concurrency]
[rems.json :as json]
[rems.logging :refer [with-mdc]]))


;;; threadpool


(def ^:private thread-pool (atom nil))

(defn- submit-all [& fns]
(let [pool (or @thread-pool
(reset! thread-pool (concurrency/cached-thread-pool {:thread-prefix "user-simulator"})))]
(concurrency/submit! pool fns)))

(def task-counter (atom 0))
(def restart-queue (atom []))

(defn create-simulator-task [{:keys [actions url user-id] :as opts}]
(logr/info #'create-simulator-task opts)
(binding [btu/*test-context* (atom (assoc (btu/create-test-context)
:url url))
btu/screenshot (constantly nil)
btu/screenshot-element (constantly nil)
btu/check-axe (constantly nil)
btu/postmortem-handler (constantly nil)]
(bound-fn simulate-user []
(try
(logr/info "Simulator thread starting")
(btu/init-driver! :chrome (btu/get-server-url))
(btu/context-assoc! :user-id user-id)

(while true
(Thread/sleep (+ 200 (rand-int 300)))

(when (btu/running?)
(let [action-var (rand-nth actions)
action (var-get action-var)
task-id (swap! task-counter inc)]
(btu/context-assoc! :task-id task-id)
(with-mdc {:userid user-id
:request-id task-id}
(et/delete-cookies (btu/get-driver))
(btu/go (btu/get-server-url))
(et/refresh (btu/get-driver))
(btu/scroll-and-click [{:css ".language-switcher"} {:fn/text "EN"}]) ; make sure language is stable
(action)))))

(catch InterruptedException e
(.interrupt (Thread/currentThread))
(logr/info e "Simulator thread interrupted"))
(catch Throwable t
(logr/error t "Internal error" (with-out-str
(clojure.pprint/pprint (merge {::context @(btu/test-ctx)}
(ex-data t)))))
(swap! restart-queue conj opts))
(finally
(logr/info "Simulator thread shutting down")
(some-> (btu/get-driver)
et/stop-driver))))))


;;; api utils


(defn- parse-transit [x]
(muuntaja/decode json/muuntaja "application/transit+json" x))

(defn- join-url [base path]
(let [base (cond-> base
(str/ends-with? base "/")
(subs 0 (dec (count base))))
path (cond-> path
(str/starts-with? path "/")
(subs 1))]
(str base "/" path)))

(comment
(= "http://localhost:3000/api/applications"
(join-url "http://localhost:3000" "api/applications")
(join-url "http://localhost:3000" "/api/applications")
(join-url "http://localhost:3000/" "api/applications")
(join-url "http://localhost:3000/" "/api/applications")))

(defn- api-get [path & [{:keys [api-key user-id]}]]
(let [url (join-url (btu/get-server-url) path)
response (http/get url {:accept :transit+json
:headers {"x-rems-api-key" (or api-key 42)
"x-rems-user-id" (or user-id (btu/context-getx :user-id))}})]
(parse-transit (:body response))))

(defn- query-my-applications []
(let [user-id (btu/context-getx :user-id)]
(->> (api-get "/api/applications")
(filter #(= user-id (get-in % [:application/applicant :userid])))
(mapv :application/id))))


;;; etaoin utils


(defn login-as [username]
(btu/go (btu/get-server-url))
(btu/scroll-and-click {:css ".login-btn"})
(when (btu/visible? :show-special-users) ; sometimes the user is in the hidden part
(btu/scroll-and-click :show-special-users))
(btu/scroll-and-click [{:css ".users"} {:tag :a :fn/text username}])
(btu/wait-visible :logout))

(defn logout []
(btu/scroll-and-click :logout)
(btu/wait-visible {:css ".login-component"}))

(defn click-navigation-menu [link-text]
(btu/scroll-and-click [:big-navbar {:tag :a :fn/text link-text}]))

(defn go-to-application [application-id]
(click-navigation-menu "Applications")
(btu/wait-visible {:tag :h1 :fn/text "Applications – REMS"})
(btu/wait-page-loaded)
(btu/scroll-and-click [:my-applications
{:css (format "tr[data-row='%s'] > td.view a" application-id)}])
(btu/wait-page-loaded))


;;; simulations


(defn user-views-applications
"Simulation flow where
- user logs in,
- user visits random application pages (that user is applying for)
- user logs out."
[]
(logr/info "login")
(login-as (btu/context-getx :user-id))
(loop [n 0]
(Thread/sleep 1000)
(btu/context-assoc! :my-applications (query-my-applications))
(btu/context-assoc! :application-id (rand-nth* (btu/context-getx :my-applications)))
(cond
(>= n 100)
(do (logr/info "logout")
(logout))

(not (btu/context-getx :application-id))
(do (logr/warnf "No applications found for user %s" (btu/context-getx :user-id))
(recur (inc n)))

:else
(let [start (System/currentTimeMillis)]
(logr/infof "> view application %s/%s, id %s" n 100 (btu/context-getx :application-id))
(go-to-application (btu/context-getx :application-id))
(logr/infof "< view application %s/%s, id %s (%sms)" n 100 (- (System/currentTimeMillis) start) (btu/context-getx :application-id))
(recur (inc n))))))


;;; core logic


(def all-actions [#'user-views-applications])

(defn- queue-simulate-tasks! [opts]
(assert (seq (:users opts)) "no users to simulate?")
(assert (seq (:actions opts)) "no actions to simulate?")
(assert (:url opts) "missing target REMS url")
(logr/info 'queue-simulate-tasks :start opts)
(submit-all (vec (for [user-id (:users opts)
:let [task-opts {:actions (:actions opts)
:url (:url opts)
:user-id user-id}]]
(create-simulator-task task-opts))))
(add-watch restart-queue :task-daemon (fn [_key q _old-state new-state]
(when (seq new-state)
(submit-all (mapv create-simulator-task new-state))
(reset! q [])))))

(defn start! [& [{:keys [actions url users]}]]
(mount/start #'rems.config/env)
(queue-simulate-tasks! {:actions (or (seq actions) all-actions)
:url (or url "http://localhost:3000/")
:users (or (seq users) ["alice" "elsa" "frank"])}))

(defn stop! []
(remove-watch restart-queue :task-daemon)
(some-> @thread-pool concurrency/shutdown-now!))

(comment
(start! {:actions [#'user-views-applications]
:url "http://localhost:3000/"
:users ["alice" "elsa" "frank"]})
(stop!))
6 changes: 6 additions & 0 deletions src/cljc/rems/common/util.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -724,3 +724,9 @@
"Like `clojure.core/range`, but starts from 1 and `end` is inclusive."
[end]
(range 1 (inc end)))

(defn rand-nth*
"As (rand-nth), but returns nil if (seq coll) is nil."
[coll]
(some-> (seq coll)
(rand-nth)))
1 change: 0 additions & 1 deletion test/clj/rems/api/testing.clj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
[rems.handler :refer :all]
[rems.locales]
[rems.middleware]
[rems.main]
[ring.mock.request :refer :all]
[rems.json :as json]))

Expand Down
Loading