diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f10a6b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +docker-compose.yml +Dockerfile +Jenkinsfile diff --git a/.gitignore b/.gitignore index 4f4f73c..39c20db 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ profiles.clj .idea resources/contracts node_modules +/config-prod.edn +/config-dev.edn +/config-test.edn +/src/java diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce2947a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM clojure as builder + +WORKDIR /tmp + +RUN wget -O /usr/local/bin/solc https://github.com/ethereum/solidity/releases/download/v0.4.15/solc-static-linux +RUN chmod +x /usr/local/bin/solc + +RUN wget https://github.com/web3j/web3j/releases/download/v2.3.0/web3j-2.3.0.tar +RUN tar -xf web3j-2.3.0.tar +RUN cp -r web3j-2.3.0/* /usr/local/ + + +COPY . /usr/src/app +WORKDIR /usr/src/app + +ENV LEIN_SNAPSHOTS_IN_RELEASE=1 + + +RUN lein less once +RUN lein uberjar + + +FROM clojure +WORKDIR /root/ + +COPY --from=builder /usr/src/app/target/uberjar/commiteth.jar . + +CMD [""] +ENTRYPOINT ["/usr/bin/java", "-Duser.timezone=UTC", "-Dconf=config-test.edn", "-jar", "/root/commiteth.jar"] + diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..ec6b860 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,35 @@ +#!/usr/bin/env groovy + +node('linux') { +checkout scm + +def dockerreponame = "statusim/openbounty-app" + + try { + stage('Build & push') { + + GIT_COMMIT_HASH = sh (script: "git rev-parse --short HEAD | tr -d '\n'", returnStdout: true) + + docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-statusvan') { + def openbountyApp = docker.build("${dockerreponame}:${env.BUILD_NUMBER}") + openbountyApp.push("${env.BRANCH_NAME}") + if (env.BRANCH_NAME == 'develop') { + openbountyApp.push("develop") + } else if (env.BRANCH_NAME == 'master') { + openbountyApp.push("master") + } else { + println "Not named branch have no custom tag" + } + } + + } + + stage('Deploy') { + build job: 'status-openbounty/openbounty-cluster', parameters: [[$class: 'StringParameterValue', name: 'DEPLOY_ENVIRONMENT', value: "dev"], [$class: 'StringParameterValue', name: 'BRANCH', value: env.BRANCH_NAME]] + } + + } catch (e) { + // slackSend color: 'bad', message: REPO + ":" + BRANCH_NAME + ' failed to build. ' + env.BUILD_URL + throw e + } +} \ No newline at end of file diff --git a/README.md b/README.md index 597831f..98216f5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The `develop` branch is automatically deployed here. ## Prerequisites -You will need [Leiningen](https://github.com/technomancy/leiningen) 2.0 or above installed. +You will need [Leiningen](https://github.com/technomancy/leiningen) 2.0 or above installed. Also, make sure that you have [wkhtmltoimage](https://wkhtmltopdf.org/downloads.html) available in your PATH. On macOS, it can be installed via `brew cask install wkhtmltopdf`. ### PostgreSQL @@ -55,7 +55,7 @@ Web3j [2.3.0](https://github.com/web3j/web3j/releases/tag/v2.3.0) is required an ## Application config -Make sure that `env/dev/resources/config.edn` is correctly populated. Description of config fields is given below: +Make sure to create `/config-dev.edn` and populate it correctly, which is based on `env/dev/resources/config.edn`. Description of config fields is given below: Key | Description --- | --- @@ -81,7 +81,7 @@ testnet-token-data | Token data map, useful if there are Geth connectivity probl Open Bounty uses both OAuth App and GitHub App integration. ### OAuth App -Follow the steps [here](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Specify the value of `:server-address` as "Homepage URL", and `:server-address` + `/callback` as "Authorization callback URL". Be sure to copy Client ID and Client Secret values to `config.edn`. +Follow the steps [here](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). Specify the value of `:server-address` as "Homepage URL", and `:server-address` + `/callback` as "Authorization callback URL". Be sure to copy Client ID and Client Secret values in the config file. ### GitHub App Follow the steps [here](https://developer.github.com/apps/building-github-apps/creating-a-github-app/). Be sure to specify `:server-address` + `/webhook-app` as "Webhook URL", and `:webhook-secret` as "Webhook Secret". diff --git a/doc/testing.md b/doc/testing.md index 91f955a..f9dc7a1 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -59,3 +59,11 @@ To remove issue from the Bounties list you can close it in GitHub. All bugs should be reported as issues in the [OpenBounty Github repository](https://github.com/status-im/open-bounty/issues). Please first check that there is not already a duplicate issue. Issues should contain exact and minimal step-by-step instructions for reproducing the problem. + +### Status Open Bounty end-to-end tests + +Framework for testing located in: `open-bounty/test/end-to-end` + +Full installation and configuring manual: [Status Open Bounty end-to-end tests](https://wiki.status.im/Status_Open_Bounty_end-to-end_tests) + +Currently supports local and Jenkins environment running (you can find example of JenkinsFile in `open-bounty/test` ) diff --git a/env/dev/resources/config.edn b/env/dev/resources/config.edn index 1ee9264..571a6ae 100644 --- a/env/dev/resources/config.edn +++ b/env/dev/resources/config.edn @@ -11,16 +11,18 @@ :eth-password "XXX" ;; RPC URL to ethereum node to be used - :eth-rpc-url "http://localhost:8547" + :eth-rpc-url "http://localhost:8545" :eth-wallet-file "/some/location" ;; address of token registry to be used - :tokenreg-addr "0x..." + ;; this is the default value for ropsten + :tokenreg-addr "0x7d127a3e3b5e72cd8f15e7dee650abe4fcced2b9" ;; format of tokenreg records' base field, possible values :status, :parity - :tokenreg-base-format :parity + :tokenreg-base-format :status ;; address of factory contract used for deploying bounty contracts - :contract-factory-addr "0x..." + ;; this is the default value for ropsten + :contract-factory-addr "0x3B9A3c062Bdb640b5039C0cCda4157737d732F95" ;; commiteth-test-tpatja :github-client-id "CLIENT ID" @@ -43,5 +45,7 @@ ;; needeed when :hubspot-contact-create-enabled :hubspot-api-key "xxxxxxx-xxxx-x-xxxx-xxxx" + :user-whitelist #{} + ;; used for blacklisting tokens from token registry data :token-blacklist #{}} diff --git a/project.clj b/project.clj index e6af24a..ccb500f 100644 --- a/project.clj +++ b/project.clj @@ -69,7 +69,6 @@ [lein-auto "0.1.2"] [lein-less "1.7.5"] [lein-shell "0.5.0"] - [cider/cider-nrepl "0.15.0-SNAPSHOT"] [lein-sha-version "0.1.1"]] @@ -94,7 +93,8 @@ :profiles - {:uberjar {:omit-source true + {:uberjar {:jvm-opts ["-server" "-Dconf=config-prod.edn"] + :omit-source true :prep-tasks ["build-contracts" "javac" "compile" ["cljsbuild" "once" "min"] ["less" "once"]] :cljsbuild {:builds @@ -116,7 +116,8 @@ :uberjar-name "commiteth.jar" :source-paths ["env/prod/clj"] :resource-paths ["env/prod/resources"]} - :dev {:dependencies [[prone "1.1.4"] + :dev {:jvm-opts ["-server" "-Dconf=config-dev.edn"] + :dependencies [[prone "1.1.4"] [ring/ring-mock "0.3.1"] [ring/ring-devel "1.6.2"] [pjstadig/humane-test-output "0.8.3"] @@ -150,7 +151,8 @@ :nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]} :injections [(require 'pjstadig.humane-test-output) (pjstadig.humane-test-output/activate!)]} - :test {:resource-paths ["env/dev/resources" "env/test/resources"] + :test {:jvm-opts ["-server" "-Dconf=config-test.edn"] + :resource-paths ["env/dev/resources" "env/test/resources"] :dependencies [[devcards "0.2.4"]] :cljsbuild {:builds diff --git a/resources/public/bounty-filter-remove.svg b/resources/public/bounty-filter-remove.svg new file mode 100644 index 0000000..b4e091f --- /dev/null +++ b/resources/public/bounty-filter-remove.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/public/fonts/PostGrotesk-Book.eot b/resources/public/fonts/PostGrotesk-Book.eot new file mode 100644 index 0000000..06846d3 Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Book.eot differ diff --git a/resources/public/fonts/PostGrotesk-Book.svg b/resources/public/fonts/PostGrotesk-Book.svg new file mode 100644 index 0000000..dd80f2c --- /dev/null +++ b/resources/public/fonts/PostGrotesk-Book.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/public/fonts/PostGrotesk-Book.woff b/resources/public/fonts/PostGrotesk-Book.woff new file mode 100644 index 0000000..47b48d0 Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Book.woff differ diff --git a/resources/public/fonts/PostGrotesk-Medium.eot b/resources/public/fonts/PostGrotesk-Medium.eot new file mode 100644 index 0000000..e0171ff Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Medium.eot differ diff --git a/resources/public/fonts/PostGrotesk-Medium.svg b/resources/public/fonts/PostGrotesk-Medium.svg new file mode 100644 index 0000000..2e26fe7 --- /dev/null +++ b/resources/public/fonts/PostGrotesk-Medium.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/public/fonts/PostGrotesk-Medium.woff b/resources/public/fonts/PostGrotesk-Medium.woff new file mode 100644 index 0000000..933c51b Binary files /dev/null and b/resources/public/fonts/PostGrotesk-Medium.woff differ diff --git a/resources/public/fonts/RobotoRegular/RobotoRegular.eot b/resources/public/fonts/RobotoRegular/RobotoRegular.eot new file mode 100755 index 0000000..466f3a7 Binary files /dev/null and b/resources/public/fonts/RobotoRegular/RobotoRegular.eot differ diff --git a/resources/public/fonts/RobotoRegular/RobotoRegular.ttf b/resources/public/fonts/RobotoRegular/RobotoRegular.ttf new file mode 100755 index 0000000..a4ebaf7 Binary files /dev/null and b/resources/public/fonts/RobotoRegular/RobotoRegular.ttf differ diff --git a/resources/public/fonts/RobotoRegular/RobotoRegular.woff b/resources/public/fonts/RobotoRegular/RobotoRegular.woff new file mode 100755 index 0000000..0871062 Binary files /dev/null and b/resources/public/fonts/RobotoRegular/RobotoRegular.woff differ diff --git a/resources/public/icon-forward-white.svg b/resources/public/icon-forward-white.svg new file mode 100644 index 0000000..9842677 --- /dev/null +++ b/resources/public/icon-forward-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/clj/commiteth/core.clj b/src/clj/commiteth/core.clj index e617708..d3c46a7 100644 --- a/src/clj/commiteth/core.clj +++ b/src/clj/commiteth/core.clj @@ -1,7 +1,7 @@ (ns commiteth.core (:require [commiteth.handler :as handler] [clojure.tools.nrepl.server :as nrepl-server] - [cider.nrepl :refer [cider-nrepl-handler]] + [luminus.repl-server :as repl] [luminus.http-server :as http] [luminus-migrations.core :as migrations] [commiteth.config :refer [env]] @@ -31,7 +31,7 @@ repl-server (when-let [nrepl-port (env :nrepl-port)] (log/info "Starting NREPL server on port" nrepl-port) (nrepl-server/start-server :port nrepl-port - :handler cider-nrepl-handler)) + :handler nrepl-server/default-handler)) :stop (when repl-server (nrepl-server/stop-server repl-server))) diff --git a/src/clj/commiteth/eth/multisig_wallet.clj b/src/clj/commiteth/eth/multisig_wallet.clj index 67bbb06..f5393ab 100644 --- a/src/clj/commiteth/eth/multisig_wallet.clj +++ b/src/clj/commiteth/eth/multisig_wallet.clj @@ -29,7 +29,7 @@ :confirmation (eth/event-sig->topic-id "Confirmation(address,uint256)")}) (defn factory-contract-addr [] - (env :contract-factory-addr "0x47F56FD26EEeCda4FdF5DB5843De1fe75D2A64A6")) + (env :contract-factory-addr)) (defn tokenreg-base-format ;; status tokenreg uses eg :base 18, while parity uses :base 1000000000000 diff --git a/src/cljs/commiteth/bounties.cljs b/src/cljs/commiteth/bounties.cljs index bfa73a4..bbdceec 100644 --- a/src/cljs/commiteth/bounties.cljs +++ b/src/cljs/commiteth/bounties.cljs @@ -1,26 +1,31 @@ (ns commiteth.bounties - (:require [re-frame.core :as rf] - [reagent.core :as r] + (:require [reagent.core :as r] + [re-frame.core :as rf] [commiteth.common :refer [moment-timestamp display-data-page items-per-page - issue-url]])) + issue-url]] + [commiteth.handlers :as handlers] + [commiteth.db :as db] + [commiteth.ui-model :as ui-model] + [commiteth.subscriptions :as subs] + [commiteth.util :as util])) (defn bounty-item [bounty] - (let [{avatar-url :repo_owner_avatar_url - owner :repo-owner - repo-name :repo-name - issue-title :issue-title + (let [{avatar-url :repo_owner_avatar_url + owner :repo-owner + repo-name :repo-name + issue-title :issue-title issue-number :issue-number - updated :updated - tokens :tokens - balance-eth :balance-eth - value-usd :value-usd - claim-count :claim-count} bounty - full-repo (str owner "/" repo-name) - repo-url (str "https://github.com/" full-repo) - repo-link [:a {:href repo-url} full-repo] + updated :updated + tokens :tokens + balance-eth :balance-eth + value-usd :value-usd + claim-count :claim-count} bounty + full-repo (str owner "/" repo-name) + repo-url (str "https://github.com/" full-repo) + repo-link [:a {:href repo-url} full-repo] issue-link [:a {:href (issue-url owner repo-name issue-number)} issue-title]] @@ -40,35 +45,189 @@ [:span.usd-value-label "Value "] [:span.usd-balance-label (str "$" value-usd)] (when (> claim-count 0) [:span.open-claims-label (str claim-count " open claim" - (when (> claim-count 1) "s"))]) ]] + (when (> claim-count 1) "s"))])]] [:div.open-bounty-item-icon [:div.ui.tiny.circular.image [:img {:src avatar-url}]]]])) -(defn bounties-list [{:keys [items item-count page-number total-count] - :as bounty-page-data} +(defn bounties-filter-tooltip-value-input-view [label tooltip-open? opts] + [:div.open-bounties-filter-element-tooltip-value-input-container + [:div.:input.open-bounties-filter-element-tooltip-value-input-label + label] + [:input.open-bounties-filter-element-tooltip-value-input + {:type "range" + :min (:min opts) + :max (:max opts) + :step (:step opts) + :value (:current-val opts) + :on-change (when-let [f (:on-change-val opts)] + #(-> % .-target .-value int f)) + :on-focus #(reset! tooltip-open? true)}]]) + +(defmulti bounties-filter-tooltip-view #(-> %2 ::ui-model/bounty-filter-type.category)) + +(defmethod bounties-filter-tooltip-view ::ui-model/bounty-filter-type-category|range + [filter-type filter-type-def current-filter-value tooltip-open?] + (let [default-min (::ui-model/bounty-filter-type.min-val filter-type-def) + default-max (::ui-model/bounty-filter-type.max-val filter-type-def) + common-range-opts {:min default-min :max default-max} + current-min (or (first current-filter-value) default-min) + current-max (or (second current-filter-value) default-max) + on-change-fn (fn [min-val max-val] + (rf/dispatch [::handlers/set-open-bounty-filter-type + filter-type + [(min min-val default-max) + (max max-val default-min)]])) + on-min-change-fn (fn [new-min] + (let [new-max (max current-max (min default-max new-min))] + (on-change-fn new-min new-max))) + on-max-change-fn (fn [new-max] + (let [new-min (min current-min (max default-min new-max))] + (on-change-fn new-min new-max)))] + [:div.open-bounties-filter-list + (::ui-model/bounty-filter.type.header filter-type-def) + [bounties-filter-tooltip-value-input-view "Min" tooltip-open? (merge common-range-opts + {:current-val current-min + :on-change-val on-min-change-fn})] + [bounties-filter-tooltip-value-input-view "Max" tooltip-open? (merge common-range-opts + {:current-val current-max + :on-change-val on-max-change-fn})]])) + +(defmethod bounties-filter-tooltip-view ::ui-model/bounty-filter-type-category|single-static-option + [filter-type filter-type-def current-filter-value tooltip-open?] + [:div.open-bounties-filter-list + (for [[option-type option-text] (::ui-model/bounty-filter-type.options filter-type-def)] + ^{:key (str option-type)} + [:div.open-bounties-filter-list-option + (merge {:on-click #(do (rf/dispatch [::handlers/set-open-bounty-filter-type + filter-type + option-type]) + (reset! tooltip-open? false)) + :tab-index 0 + :on-focus #(reset! tooltip-open? true)} + (when (= option-type current-filter-value) + {:class "active"})) + option-text])]) + +(defmethod bounties-filter-tooltip-view ::ui-model/bounty-filter-type-category|multiple-dynamic-options + [filter-type filter-type-def current-filter-value tooltip-open?] + (let [options (rf/subscribe [(::ui-model/bounty-filter-type.re-frame-subs-key-for-options filter-type-def)])] + [:div.open-bounties-filter-list + (for [option @options] + (let [active? (boolean (and current-filter-value (current-filter-value option)))] + ^{:key (str option)} + [:div.open-bounties-filter-list-option-checkbox + [:label + {:on-click #(rf/dispatch [::handlers/set-open-bounty-filter-type + filter-type + (cond + (and active? (= #{option} current-filter-value)) nil + active? (disj current-filter-value option) + :else (into #{option} current-filter-value))]) + :tab-index 0 + :on-focus #(do (.stopPropagation %) (reset! tooltip-open? true))} + [:input + {:type "checkbox" + :checked active? + :on-focus #(reset! tooltip-open? true)}] + [:div.text option]]]))])) + +(defn bounty-filter-view [filter-type current-filter-value] + (let [tooltip-open? (r/atom false)] + (fn [filter-type current-filter-value] + [:div.open-bounties-filter-element-container + {:tab-index 0 + :on-focus #(reset! tooltip-open? true) + :on-blur #(reset! tooltip-open? false)} + [:div.open-bounties-filter-element + {:on-mouse-down #(swap! tooltip-open? not) + :class (when (or current-filter-value @tooltip-open?) + "open-bounties-filter-element-active")} + [:div.text + (if current-filter-value + (ui-model/bounty-filter-value->short-text filter-type current-filter-value) + (ui-model/bounty-filter-type->name filter-type))] + (when current-filter-value + [:div.remove-container + {:tab-index 0 + :on-focus #(.stopPropagation %)} + [:img.remove + {:src "bounty-filter-remove.svg" + :on-mouse-down (fn [e] + (.stopPropagation e) + (rf/dispatch [::handlers/set-open-bounty-filter-type filter-type nil]) + (reset! tooltip-open? false))}]])] + (when @tooltip-open? + [:div.open-bounties-filter-element-tooltip + [bounties-filter-tooltip-view + filter-type + (ui-model/bounty-filter-types-def filter-type) + current-filter-value + tooltip-open?]])]))) + +(defn bounty-filters-view [] + (let [current-filters (rf/subscribe [::subs/open-bounties-filters])] + [:div.open-bounties-filter + ; doall because derefs are not supported in lazy seqs: https://github.com/reagent-project/reagent/issues/18 + (doall + (for [filter-type ui-model/bounty-filter-types] + ^{:key (str filter-type)} + [bounty-filter-view + filter-type + (get @current-filters filter-type)]))])) + +(defn bounties-sort-view [] + (let [open? (r/atom false)] + (fn [] + (let [current-sorting (rf/subscribe [::subs/open-bounties-sorting-type])] + [:div.open-bounties-sort + {:tab-index 0 + :on-blur #(reset! open? false)} + [:div.open-bounties-sort-element + {:on-click #(swap! open? not)} + (ui-model/bounty-sorting-type->name @current-sorting) + [:div.icon-forward-white-box + [:img + {:src "icon-forward-white.svg"}]]] + (when @open? + [:div.open-bounties-sort-element-tooltip + (for [sorting-type (keys ui-model/bounty-sorting-types-def)] + ^{:key (str sorting-type)} + [:div.open-bounties-sort-type + {:on-click #(do + (reset! open? false) + (rf/dispatch [::handlers/set-open-bounties-sorting-type sorting-type]))} + (ui-model/bounty-sorting-type->name sorting-type)])])])))) + +(defn bounties-list [{:keys [items item-count page-number total-count] + :as bounty-page-data} container-element] (if (empty? items) [:div.view-no-data-container - [:p "No recent activity yet"]] + [:p "No matching bounties found."]] [:div - (let [left (inc (* (dec page-number) items-per-page)) + (let [left (inc (* (dec page-number) items-per-page)) right (dec (+ left item-count))] - [:div.item-counts-label - [:span (str "Showing " left "-" right " of " total-count)]]) + [:div.item-counts-label-and-sorting-container + [:div.item-counts-label + [:span (str "Showing " left "-" right " of " total-count)]] + (when-not (util/os-windows?) + [bounties-sort-view])]) (display-data-page bounty-page-data bounty-item container-element)])) (defn bounties-page [] - (let [bounty-page-data (rf/subscribe [:open-bounties-page]) + (let [bounty-page-data (rf/subscribe [:open-bounties-page]) open-bounties-loading? (rf/subscribe [:get-in [:open-bounties-loading?]]) - container-element (atom nil)] + container-element (atom nil)] (fn [] (if @open-bounties-loading? [:div.view-loading-container [:div.ui.active.inverted.dimmer [:div.ui.text.loader.view-loading-label "Loading"]]] [:div.ui.container.open-bounties-container - {:ref #(reset! container-element %1)} + {:ref #(reset! container-element %1)} [:div.open-bounties-header "Bounties"] - [bounties-list @bounty-page-data container-element]])) - )) + (when-not (util/os-windows?) + [:div.open-bounties-filter-and-sort + [bounty-filters-view]]) + [bounties-list @bounty-page-data container-element]])))) diff --git a/src/cljs/commiteth/core.cljs b/src/cljs/commiteth/core.cljs index 45bb1b8..2034449 100644 --- a/src/cljs/commiteth/core.cljs +++ b/src/cljs/commiteth/core.cljs @@ -208,12 +208,12 @@ [:div.ui.vertical.segment [:div.ui.container [:div.ui.grid.stackable - [:div {:class (str (if (show-top-hunters?) "ten" "sixteen") + [:div {:class (str (if (show-top-hunters?) "eleven" "sixteen") " wide computer sixteen wide tablet column")} [:div.ui.container [(pages @current-page)]]] (when (show-top-hunters?) - [:div.six.wide.column.computer.only + [:div.five.wide.column.computer.only [:div.ui.container.top-hunters [:h3.top-hunters-header "Top 5 hunters"] [:div.top-hunters-subheader "All time"] diff --git a/src/cljs/commiteth/db.cljs b/src/cljs/commiteth/db.cljs index 76b9a1c..c0ebfb8 100644 --- a/src/cljs/commiteth/db.cljs +++ b/src/cljs/commiteth/db.cljs @@ -1,14 +1,22 @@ -(ns commiteth.db) +(ns commiteth.db + (:require [commiteth.ui-model :as ui-model])) (def default-db - {:page :bounties - :user nil - :repos-loading? false - :repos {} - :activity-feed-loading? false - :open-bounties-loading? false - :open-bounties [] - :page-number 1 - :owner-bounties {} - :top-hunters [] - :activity-feed []}) + {:page :bounties + :user nil + :repos-loading? false + :repos {} + :activity-feed-loading? false + :open-bounties-loading? false + :open-bounties [] + :page-number 1 + :bounty-page-number 1 + :activity-page-number 1 + ::open-bounties-sorting-type ::ui-model/bounty-sorting-type|most-recent + ::open-bounties-filters {::ui-model/bounty-filter-type|value nil + ::ui-model/bounty-filter-type|currency nil + ::ui-model/bounty-filter-type|date nil + ::ui-model/bounty-filter-type|owner nil} + :owner-bounties {} + :top-hunters [] + :activity-feed []}) diff --git a/src/cljs/commiteth/handlers.cljs b/src/cljs/commiteth/handlers.cljs index 077d280..eb61374 100644 --- a/src/cljs/commiteth/handlers.cljs +++ b/src/cljs/commiteth/handlers.cljs @@ -11,7 +11,8 @@ [cljs-web3.eth :as web3-eth] [akiroz.re-frame.storage :as rf-storage - :refer [reg-co-fx!]])) + :refer [reg-co-fx!]] + [commiteth.ui-model :as ui-model])) (rf-storage/reg-co-fx! :commiteth-sob {:fx :store @@ -63,10 +64,12 @@ (reg-event-db - :set-active-page - (fn [db [_ page]] - (assoc db :page page - :page-number 1))) + :set-active-page + (fn [db [_ page]] + (assoc db :page page + :page-number 1 + ::db/open-bounties-filters {} + ::db/open-bounties-sorting-type ::ui-model/bounty-sorting-type|most-recent))) (reg-event-db :set-page-number @@ -457,3 +460,16 @@ (fn [db [_]] (.removeEventListener js/window "click" close-dropdown) (assoc db :user-dropdown-open? false))) + +(reg-event-db + ::set-open-bounties-sorting-type + (fn [db [_ sorting-type]] + (merge db {::db/open-bounties-sorting-type sorting-type + :page-number 1}))) + +(reg-event-db + ::set-open-bounty-filter-type + (fn [db [_ filter-type filter-value]] + (-> db + (assoc-in [::db/open-bounties-filters filter-type] filter-value) + (assoc :page-number 1)))) diff --git a/src/cljs/commiteth/subscriptions.cljs b/src/cljs/commiteth/subscriptions.cljs index c1b8952..bb2a82a 100644 --- a/src/cljs/commiteth/subscriptions.cljs +++ b/src/cljs/commiteth/subscriptions.cljs @@ -1,10 +1,13 @@ (ns commiteth.subscriptions (:require [re-frame.core :refer [reg-sub]] - [commiteth.common :refer [items-per-page]])) + [commiteth.db :as db] + [commiteth.ui-model :as ui-model] + [commiteth.common :refer [items-per-page]] + [clojure.string :as string])) (reg-sub - :db - (fn [db _] db)) + :db + (fn [db _] db)) (reg-sub :page @@ -43,7 +46,7 @@ (reg-sub :open-bounties-page - :<- [:open-bounties] + :<- [::filtered-and-sorted-open-bounties] :<- [:page-number] (fn [[open-bounties page-number] _] (let [total-count (count open-bounties) @@ -105,16 +108,62 @@ (get-in db path))) (reg-sub - :usage-metrics - (fn [db _] - (:usage-metrics db))) + :usage-metrics + (fn [db _] + (:usage-metrics db))) (reg-sub - :metrics-loading? - (fn [db _] - (:metrics-loading? db))) + :metrics-loading? + (fn [db _] + (:metrics-loading? db))) (reg-sub - :user-dropdown-open? + :user-dropdown-open? (fn [db _] (:user-dropdown-open? db))) + +(reg-sub + ::open-bounties-sorting-type + (fn [db _] + (::db/open-bounties-sorting-type db))) + +(reg-sub + ::open-bounties-filters + (fn [db _] + (::db/open-bounties-filters db))) + +(reg-sub + ::open-bounties-owners + :<- [:open-bounties] + (fn [open-bounties _] + (->> open-bounties + (map :repo-owner) + set))) + +(reg-sub + ::open-bounties-owners-sorted + :<- [::open-bounties-owners] + (fn [owners _] + (sort-by string/lower-case owners))) + +(reg-sub + ::open-bounties-currencies + :<- [:open-bounties] + (fn [open-bounties _] + (let [token-ids (->> open-bounties + (map :tokens) + (mapcat keys) + (filter identity) + set)] + (into #{"ETH"} token-ids)))) + +(reg-sub + ::filtered-and-sorted-open-bounties + :<- [:open-bounties] + :<- [::open-bounties-filters] + :<- [::open-bounties-sorting-type] + (fn [[open-bounties filters sorting-type] _] + (cond->> open-bounties + true (ui-model/filter-bounties filters) + sorting-type (ui-model/sort-bounties-by-sorting-type sorting-type) + true vec))) diff --git a/src/cljs/commiteth/ui_model.cljs b/src/cljs/commiteth/ui_model.cljs new file mode 100644 index 0000000..fd7d41c --- /dev/null +++ b/src/cljs/commiteth/ui_model.cljs @@ -0,0 +1,165 @@ +(ns commiteth.ui-model + (:require [clojure.set :as set] + [cljs-time.core :as t] + [cljs-time.coerce :as t-coerce] + [cljs-time.format :as t-format])) + +;;;; bounty sorting types + +(def bounty-sorting-types-def + {::bounty-sorting-type|most-recent {::bounty-sorting-type.name "Most recent" + ::bounty-sorting-type.sort-key-fn (fn [bounty] + (:updated bounty)) + ::bounty-sorting-type.sort-comparator-fn (comp - compare)} + ::bounty-sorting-type|lowest-value {::bounty-sorting-type.name "Lowest value" + ::bounty-sorting-type.sort-key-fn (fn [bounty] + (js/parseFloat (:value-usd bounty))) + ::bounty-sorting-type.sort-comparator-fn compare} + ::bounty-sorting-type|highest-value {::bounty-sorting-type.name "Highest value" + ::bounty-sorting-type.sort-key-fn (fn [bounty] + (js/parseFloat (:value-usd bounty))) + ::bounty-sorting-type.sort-comparator-fn (comp - compare)} + ::bounty-sorting-type|owner {::bounty-sorting-type.name "Owner" + ::bounty-sorting-type.sort-key-fn (fn [bounty] + (:repo-owner bounty)) + ::bounty-sorting-type.sort-comparator-fn compare}}) + +(defn bounty-sorting-type->name [sorting-type] + (-> bounty-sorting-types-def (get sorting-type) ::bounty-sorting-type.name)) + +(defn sort-bounties-by-sorting-type [sorting-type bounties] + (let [keyfn (-> bounty-sorting-types-def + sorting-type + ::bounty-sorting-type.sort-key-fn) + comparator (-> bounty-sorting-types-def + sorting-type + ::bounty-sorting-type.sort-comparator-fn)] + (sort-by keyfn comparator bounties))) + +;;;; bounty filter types + +(def bounty-filter-type-date-options-def {::bounty-filter-type-date-option|last-week "Last week" + ::bounty-filter-type-date-option|last-month "Last month" + ::bounty-filter-type-date-option|last-3-months "Last 3 months"}) + +(def bounty-filter-type-date-options (keys bounty-filter-type-date-options-def)) + +(defn bounty-filter-type-date-option->name [option] + (bounty-filter-type-date-options-def option)) + +(def bounty-filter-type-date-pre-predicate-value-processor + "It converts an option of the filter type date to a cljs-time interval in which + that option is valid, so that you can check `cljs-time.core.within?` against that + interval and know if a `cljs-time` date is valid for that filter type date option." + (fn [filter-value] + (let [filter-from (condp = filter-value + ::bounty-filter-type-date-option|last-week (t/minus (t/now) (t/weeks 1)) + ::bounty-filter-type-date-option|last-month (t/minus (t/now) (t/months 1)) + ::bounty-filter-type-date-option|last-3-months (t/minus (t/now) (t/months 3)))] + (t/interval filter-from (t/now))))) +(def bounty-filter-type-date-predicate + (fn [filter-value-interval bounty] + (when-let [date-inst (:updated bounty)] + (let [date (-> date-inst inst-ms t-coerce/from-long)] + (t/within? filter-value-interval date))))) + +(def bounty-filter-type-claims-options-def {::bounty-filter-type-claims-option|no-claims "Not claimed yet"}) + +(def bounty-filter-type-claims-options (keys bounty-filter-type-claims-options-def)) + +(defn bounty-filter-type-claims-option->name [option] + (bounty-filter-type-claims-options-def option)) + +(def bounty-filter-types-def + {::bounty-filter-type|value + {::bounty-filter-type.name "Value" + ::bounty-filter-type.category ::bounty-filter-type-category|range + ::bounty-filter-type.min-val 0 + ::bounty-filter-type.max-val 10000 + ::bounty-filter.type.header "$0 - $10000+" + ::bounty-filter-type.predicate (fn [filter-value bounty] + (let [min-val (first filter-value) + max-val (second filter-value)] + (<= min-val (:value-usd bounty) max-val)))} + + ::bounty-filter-type|currency + {::bounty-filter-type.name "Currency" + ::bounty-filter-type.category ::bounty-filter-type-category|multiple-dynamic-options + ::bounty-filter-type.re-frame-subs-key-for-options :commiteth.subscriptions/open-bounties-currencies + ::bounty-filter-type.predicate (fn [filter-value bounty] + (and (or (not-any? #{"ETH"} filter-value) + (< 0 (:balance-eth bounty))) + (set/subset? (->> filter-value (remove #{"ETH"}) set) + (-> bounty :tokens keys set))))} + + ::bounty-filter-type|date + {::bounty-filter-type.name "Date" + ::bounty-filter-type.category ::bounty-filter-type-category|single-static-option + ::bounty-filter-type.options bounty-filter-type-date-options-def + ::bounty-filter-type.pre-predicate-value-processor bounty-filter-type-date-pre-predicate-value-processor + ::bounty-filter-type.predicate bounty-filter-type-date-predicate} + + ::bounty-filter-type|owner + {::bounty-filter-type.name "Owner" + ::bounty-filter-type.category ::bounty-filter-type-category|multiple-dynamic-options + ::bounty-filter-type.re-frame-subs-key-for-options :commiteth.subscriptions/open-bounties-owners-sorted + ::bounty-filter-type.predicate (fn [filter-value bounty] + (->> filter-value + (some #{(:repo-owner bounty)}) + boolean))} + + ::bounty-filter-type|claims + {::bounty-filter-type.name "Claims" + ::bounty-filter-type.category ::bounty-filter-type-category|single-static-option + ::bounty-filter-type.options bounty-filter-type-claims-options-def + ::bounty-filter-type.predicate (fn [filter-value bounty] + (condp = filter-value + ::bounty-filter-type-claims-option|no-claims + (= 0 (:claim-count bounty))))}}) + +(def bounty-filter-types (keys bounty-filter-types-def)) + +(defn bounty-filter-type->name [filter-type] + (-> bounty-filter-types-def (get filter-type) ::bounty-filter-type.name)) + +(defn bounty-filter-value->short-text [filter-type filter-value] + (cond + (= filter-type ::bounty-filter-type|date) + (bounty-filter-type-date-option->name filter-value) + + (#{::bounty-filter-type|owner + ::bounty-filter-type|currency} filter-type) + (str (bounty-filter-type->name filter-type) " (" (count filter-value) ")") + + (= filter-type ::bounty-filter-type|value) + (str "$" (first filter-value) "-$" (second filter-value)) + + (= filter-type ::bounty-filter-type|claims) + (bounty-filter-type-claims-option->name filter-value) + + :else + (str filter-type " with val " filter-value))) + +(defn- bounty-filter-values-by-type->predicates [filters-by-type] + "It receives a map with filter types as keys and filter values as values and + returns a lazy seq of predicates, one for each pair of filter type and value. + Those predicate can receive a bounty and tell whether that bounty passes + the filter type with that filter value. It removes filter types with a `nil` + filter value." + (->> filters-by-type + ; used `nil?` because a valid filter value can be `false` + (remove #(nil? (val %))) + (map (fn [[filter-type filter-value]] + (let [filter-type-def (bounty-filter-types-def filter-type) + pred (::bounty-filter-type.predicate filter-type-def) + pre-pred-processor (::bounty-filter-type.pre-predicate-value-processor filter-type-def) + filter-value (cond-> filter-value + pre-pred-processor pre-pred-processor)] + (partial pred filter-value)))))) + +(defn filter-bounties [filters-by-type bounties] + (let [filter-preds (bounty-filter-values-by-type->predicates filters-by-type) + filters-pred (fn [bounty] + (every? #(% bounty) filter-preds))] + (cond->> bounties + (not-empty filter-preds) (filter filters-pred)))) diff --git a/src/cljs/commiteth/util.cljs b/src/cljs/commiteth/util.cljs new file mode 100644 index 0000000..8b6f852 --- /dev/null +++ b/src/cljs/commiteth/util.cljs @@ -0,0 +1,5 @@ +(ns commiteth.util + (:require [clojure.string :as string])) + +(defn os-windows? [] + (string/includes? (-> js/navigator .-platform) "Win")) diff --git a/src/less/style.less b/src/less/style.less index 063f7bb..e9f80a2 100644 --- a/src/less/style.less +++ b/src/less/style.less @@ -413,6 +413,312 @@ font-weight: 500; color: #42505c; } + + .item-counts-label-and-sorting-container { + display: flex; + justify-content: space-between; + } + + .open-bounties-filter-and-sort { + margin-top: 24px; + display: flex; + justify-content: space-between; + } + + @media (max-width: 767px) { + .open-bounties-filter-and-sort { + display: none; + } + } + + .open-bounties-filter { + display: flex; + } + + .open-bounties-filter-element { + font-family: "PostGrotesk-Book"; + font-size: 15px; + font-weight: 500; + line-height: 1.0; + color: #8d99a4; + //padding: 8px 12px; + border-radius: 8px; + border: solid 1px rgba(151, 151, 151, 0.2); + margin-right: 10px; + position: relative; + display: flex; + z-index: 1000; + + .text { + margin: 8px 12px; + } + + .remove-container { + display: flex; + } + + .remove { + margin-left: -6px; + margin-right: 5px; + } + } + + .open-bounties-filter-element:hover { + cursor: pointer; + } + + .open-bounties-filter-element-active + { + background-color: #57a7ed; + color: #ffffff; + } + + .open-bounties-filter-element-container:focus { + outline: none; + } + + .open-bounties-filter-element-tooltip { + position: absolute; + min-width: 227px; + margin-top: 12px; + border-radius: 10px; + background-color: #ffffff; + box-shadow: 0 15px 12px 0 rgba(161, 174, 182, 0.53), 0 0 38px 0 rgba(0, 0, 0, 0.05); + font-family: "PostGrotesk-Book"; + font-size: 16px; + line-height: 1.5; + z-index: 999; + max-height: 300px; + overflow: scroll; + + .open-bounties-filter-element-tooltip-value-input-container { + display: flex; + margin-top: 10px; + } + + .open-bounties-filter-element-tooltip-value-input-label { + width: 60px; + } + + .open-bounties-filter-element-tooltip-value-input { + margin-top: 24px; + width: 288.5px; + height: 4px; + background-color: #55a5ea; + } + + // generated with http://danielstern.ca/range.css/#/ + input[type=range] { + -webkit-appearance: none; + //width: 100%; + margin: 14.5px 0; + } + input[type=range]:focus { + outline: 0; + } + input[type=range]::-moz-focus-outer { + border: 0; + } + input[type=range]::-webkit-slider-runnable-track { + width: 100%; + height: 4px; + cursor: pointer; + box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; + background: #55a5ea; + border-radius: 0px; + border: 0px solid #010101; + } + input[type=range]::-webkit-slider-thumb { + box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; + border: 0px solid #000000; + height: 33px; + width: 33px; + border-radius: 50px; + background: #55a5ea; + cursor: pointer; + -webkit-appearance: none; + margin-top: -14.5px; + } + input[type=range]:focus::-webkit-slider-runnable-track { + background: #5aa7eb; + } + input[type=range]::-moz-range-track { + width: 100%; + height: 4px; + cursor: pointer; + box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; + background: #55a5ea; + border-radius: 0px; + border: 0px solid #010101; + } + input[type=range]::-moz-range-thumb { + box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; + border: 0px solid #000000; + height: 33px; + width: 33px; + border-radius: 50px; + background: #55a5ea; + cursor: pointer; + } + input[type=range]::-ms-track { + width: 100%; + height: 4px; + cursor: pointer; + background: transparent; + border-color: transparent; + color: transparent; + } + input[type=range]::-ms-fill-lower { + background: #50a3e9; + border: 0px solid #010101; + border-radius: 0px; + box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; + } + input[type=range]::-ms-fill-upper { + background: #55a5ea; + border: 0px solid #010101; + border-radius: 0px; + box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; + } + input[type=range]::-ms-thumb { + box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d; + border: 0px solid #000000; + height: 33px; + width: 33px; + border-radius: 50px; + background: #55a5ea; + cursor: pointer; + height: 4px; + } + input[type=range]:focus::-ms-fill-lower { + background: #55a5ea; + } + input[type=range]:focus::-ms-fill-upper { + background: #5aa7eb; + } + + .open-bounties-filter-list { + display: flex; + flex-direction: column; + align-items: flex-start; + margin: 24px 16px; + } + + .open-bounties-filter-list-option { + font-family: "PostGrotesk-Book"; + font-size: 15px; + font-weight: 500; + line-height: 1.0; + color: #42505c; + padding: 8px 12px; + border-radius: 8px; + border: solid 1px rgba(151, 151, 151, 0.2); + } + + .open-bounties-filter-list-option.active { + background-color: #57a7ed; + color: #ffffff; + } + + .open-bounties-filter-list-option:not(:first-child) { + margin-top: 8px; + } + + .open-bounties-filter-list-option:hover { + cursor: pointer; + background-color: #57a7ed; + color: #ffffff; + } + + .open-bounties-filter-list-option-checkbox { + label { + display: flex; + align-items: baseline; + } + + label:hover { + cursor: pointer; + } + + label:focus { + outline: none; + } + + input:hover { + cursor: pointer; + } + + .text { + font-size: 15px; + font-weight: 500; + line-height: 1.0; + margin-left: 12px; + } + } + + .open-bounties-filter-list-option-checkbox:not(:first-child) { + margin-top: 12px; + } + } + + .open-bounties-sort { + position: relative; + } + + .open-bounties-sort:focus { + outline: none; + } + + .open-bounties-sort-element { + display: flex; + font-size: 15px; + font-weight: 500; + line-height: 1.0; + color: #8d99a4; + padding: 16px 12px 0; + display: flex; + align-items: center; + } + + .open-bounties-sort-element:hover { + cursor: pointer; + } + + .open-bounties-sort-element-tooltip { + position: absolute; + right: 0; + //padding: 16px 0; + min-width: 200px; + border-radius: 8px; + background-color: #1e3751; + font-family: "PostGrotesk-Book"; + font-size: 15px; + line-height: 1.0; + text-align: left; + color: #ffffff; + z-index: 999; + } + + .open-bounties-sort-type { + padding: 19px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .open-bounties-sort-type:hover { + cursor: pointer; + } + + .icon-forward-white-box { + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-content: center; + } +} + +.open-bounty-item:first-child { + border-top: #eaecee 1px solid !important; } .open-bounty-item { @@ -496,7 +802,7 @@ } .open-claims-label { - padding-left: 5px; + padding-left: 15px; font-size: 15px; color: #57a7ed; } @@ -971,12 +1277,11 @@ body { } .item-counts-label { - margin: auto; + //margin: auto; font-family: "PostGrotesk-Book"; font-size: 15px; color: #8d99a4; - padding-top: 8px; - padding-bottom: 8px; + padding-top: 20px; } diff --git a/test/Jenkinsfile b/test/Jenkinsfile new file mode 100644 index 0000000..8169c6f --- /dev/null +++ b/test/Jenkinsfile @@ -0,0 +1,11 @@ +node ('linux1') {sauce('1be1b688-e0e7-4314-92a0-db11f52d3c00') { + checkout([$class: 'GitSCM', branches: [[name: '*/develop']], doGenerateSubmoduleConfigurations: false, extensions: [[$class: 'CleanBeforeCheckout']], submoduleCfg: [], userRemoteConfigs: [[url: 'https://github.com/status-im/open-bounty.git']]]) + configFileProvider([configFile(fileId: 'sob_automation_test_config', targetLocation: 'test/end-to-end/tests')]) { + try {sh 'cd test/end-to-end/tests && python3 -m pytest -m sanity --build=$BUILD_NAME -v -n 1' + } + finally { + saucePublisher() + junit testDataPublishers: [[$class: 'SauceOnDemandReportPublisher', jobVisibility: 'public']], testResults: 'test/end-to-end/tests/*.xml' } + } + } +} \ No newline at end of file diff --git a/test/end-to-end/pages/base_page.py b/test/end-to-end/pages/base_page.py index de61cd4..f602775 100644 --- a/test/end-to-end/pages/base_page.py +++ b/test/end-to-end/pages/base_page.py @@ -15,3 +15,4 @@ class BasePageObject(object): @property def time_now(self): return datetime.now().strftime('%-m%-d%-H%-M%-S') + diff --git a/test/end-to-end/pages/thirdparty/github.py b/test/end-to-end/pages/thirdparty/github.py index 0d00335..60333be 100644 --- a/test/end-to-end/pages/thirdparty/github.py +++ b/test/end-to-end/pages/thirdparty/github.py @@ -2,6 +2,10 @@ import time, pytest from pages.base_element import * from pages.base_page import BasePageObject from tests import test_data +from git import Repo +import os +import shutil +import logging class EmailEditbox(BaseEditBox): @@ -106,7 +110,46 @@ class ContractBody(BaseText): super(ContractBody, self).__init__(driver) self.locator = self.Locator.xpath_selector("//tbody//p[contains(text(), " "'Current balance: 0.000000 ETH')]") +class IssueId(BaseText): + def __init__(self, driver): + super(IssueId, self).__init__(driver) + self.locator = self.Locator.css_selector(".gh-header-number") +class ForkButton(BaseButton): + def __init__(self, driver): + super(ForkButton, self).__init__(driver) + self.locator = self.Locator.css_selector("[href='#fork-destination-box']") + +class HeaderInForkPopup(BaseText): + def __init__(self, driver): + super(HeaderInForkPopup, self).__init__(driver) + self.locator = self.Locator.css_selector("#facebox-header") + +class UserAccountInForkPopup(BaseButton): + def __init__(self, driver): + super(UserAccountInForkPopup, self).__init__(driver) + self.locator = self.Locator.css_selector("[value=%s]"%test_data.config['DEV']['gh_username']) + + +class ForkedRepoText(BaseText): + def __init__(self, driver): + super(ForkedRepoText, self).__init__(driver) + self.locator = self.Locator.css_selector(".commit-tease") + +class DeleteRepo(BaseButton): + def __init__(self, driver): + super(DeleteRepo, self).__init__(driver) + self.locator = self.Locator.xpath_selector("//button[text()[contains(.,' Delete this repository')]]") + +class RepoNameBoxInPopup(BaseEditBox): + def __init__(self, driver): + super(RepoNameBoxInPopup, self).__init__(driver) + self.locator = self.Locator.css_selector("input[aria-label='Type in the name of the repository to confirm that you want to delete this repository.']") + +class ConfirmDeleteButton(BaseButton): + def __init__(self, driver): + super(ConfirmDeleteButton, self).__init__(driver) + self.locator = self.Locator.xpath_selector("//button[text()[contains(.,'I understand the consequences, delete')]]") class GithubPage(BasePageObject): def __init__(self, driver): @@ -133,18 +176,32 @@ class GithubPage(BasePageObject): self.cross_button = LabelsButton.CrossButton(self.driver) self.submit_new_issue_button = SubmitNewIssueButton(self.driver) self.contract_body = ContractBody(self.driver) + self.issue_id = IssueId(self.driver) + self.fork_button = ForkButton(self.driver) + self.header_in_fork_popup = HeaderInForkPopup(self.driver) + self.user_account_in_fork_popup = UserAccountInForkPopup(self.driver) + self.forked_repo_text = ForkedRepoText(self.driver) + + self.delete_repo = DeleteRepo(self.driver) + self.repo_name_confirm_delete = RepoNameBoxInPopup(self.driver) + self.confirm_delete = ConfirmDeleteButton(self.driver) + def get_issues_page(self): - self.driver.get('https://github.com/Org4/nov13/issues') + self.driver.get(test_data.config['ORG']['gh_repo'] + 'issues') + + def get_issue_page(self, issue_id): + self.driver.get(test_data.config['ORG']['gh_repo'] + 'issues/' + issue_id) def get_sob_plugin_page(self): - self.driver.get('http://github.com/apps/status-open-bounty-app-test') + self.driver.get(test_data.config['Common']['sob_test_app']) def sign_in(self, email, password): self.email_input.send_keys(email) self.password_input.send_keys(password) self.sign_in_button.click() + def install_sob_plugin(self): initial_url = self.driver.current_url self.get_sob_plugin_page() @@ -164,7 +221,18 @@ class GithubPage(BasePageObject): self.bounty_label.click() self.cross_button.click() self.submit_new_issue_button.click() - return test_data.issue['title'] + test_data.issue['id'] = self.issue_id.text[1:] + logging.info("Issue title is %s" % test_data.issue['title']) + + def fork_repo(self, initial_repo, wait=60): + self.driver.get(initial_repo) + self.fork_button.click() + if self.header_in_fork_popup.text == 'Where should we fork this repository?': + self.user_account_in_fork_popup.click() + self.forked_repo_text.wait_for_element(wait) + + def get_login_page(self): + self.driver.get(test_data.config['Common']['gh_login']) def get_deployed_contract(self, wait=120): for i in range(wait): @@ -175,3 +243,34 @@ class GithubPage(BasePageObject): time.sleep(10) pass pytest.fail('Contract is not deployed in %s minutes!' % str(wait/60)) + + #cloning via HTTPS + def clone_repo(self, initial_repo=None, username=None, repo_name=None, repo_path='git_repo'): + os.mkdir(repo_path) + os.chdir(repo_path) + test_data.local_repo_path = os.getcwd() + fork = 'https://github.com/%s/%s.git' % (username, repo_name) + logging.info(('Cloning from %s to %s' % (fork, repo_path))) + r = Repo.clone_from(fork, repo_path) + logging.info(('Successefully cloned to: %s' % test_data.local_repo_path)) + logging.info('Set upstream to %s'% initial_repo) + upstream = r.create_remote('upstream', initial_repo) + upstream.fetch() + assert upstream.exists() + r.heads.master.checkout() + + def clean_repo_local_folder(self): + logging.info('Removing %s' % test_data.local_repo_path) + if test_data.local_repo_path: + shutil.rmtree(test_data.local_repo_path) + + def delete_fork(self): + self.get_url(test_data.config['DEV']['gh_forked_repo'] + 'settings') + self.delete_repo.click() + self.repo_name_confirm_delete.send_keys(test_data.config['ORG']['gh_repo_name']) + self.confirm_delete.click() + + + + + diff --git a/test/end-to-end/requirements.txt b/test/end-to-end/requirements.txt index 397c508..32b99e7 100644 --- a/test/end-to-end/requirements.txt +++ b/test/end-to-end/requirements.txt @@ -16,3 +16,6 @@ selenium==2.53.6 six==1.10.0 urllib3==1.22 yarl==0.12.0 +gitpython==2.1.8 +gitdb2==2.0.3 +smmap2==2.0.3 \ No newline at end of file diff --git a/test/end-to-end/tests/__init__.py b/test/end-to-end/tests/__init__.py index 41ae6be..49b8c0e 100644 --- a/test/end-to-end/tests/__init__.py +++ b/test/end-to-end/tests/__init__.py @@ -7,11 +7,14 @@ class TestData(object): self.config = configparser.ConfigParser() # define here path to your config.ini file - #example - config_example.ini + # example - config_example.ini self.config.read('config.ini') - self.base_case_issue = dict() - self.base_case_issue['title'] = 'Very first auto_test_bounty' + + # self.issue['title'] is set in GithubPage::create_new_bounty + # self.issue['id'] is set in GithubPage::create_new_bounty + # self.local_repo_path is set in GithubPage::clone_repo + test_data = TestData() diff --git a/test/end-to-end/tests/basetestcase.py b/test/end-to-end/tests/basetestcase.py index 9ba9409..aa113c4 100644 --- a/test/end-to-end/tests/basetestcase.py +++ b/test/end-to-end/tests/basetestcase.py @@ -4,63 +4,25 @@ from selenium.common.exceptions import WebDriverException from tests.postconditions import remove_application, remove_installation from os import environ, path from tests import test_data - +from pages.thirdparty.github import GithubPage +from pages.openbounty.landing import LandingPage class BaseTestCase: - @property - def sauce_username(self): - return environ.get('SAUCE_USERNAME') - - - @property - def sauce_access_key(self): - return environ.get('SAUCE_ACCESS_KEY') - - - @property - def executor_sauce_lab(self): - return 'http://%s:%s@ondemand.saucelabs.com:80/wd/hub' % (self.sauce_username, self.sauce_access_key) def print_sauce_lab_info(self, driver): sys.stdout = sys.stderr print("SauceOnDemandSessionID=%s job-name=%s" % (driver.session_id, pytest.config.getoption('build'))) - - @property - def capabilities_sauce_lab(self): - - desired_caps = dict() - desired_caps['name'] = test_data.test_name - desired_caps['build'] = pytest.config.getoption('build') - desired_caps['platform'] = "MAC" - desired_caps['browserName'] = 'Chrome' - desired_caps['screenResolution'] = '2048x1536' - desired_caps['captureHtml'] = False - return desired_caps - - @property - def environment(self): - return pytest.config.getoption('env') - - def setup_method(self): - - self.errors = [] - self.cleanup = None - - if self.environment == 'local': - options = webdriver.ChromeOptions() - options.add_argument('--start-fullscreen') - options.add_extension( - path.abspath(test_data.config['Paths']['tests_absolute'] + 'resources/metamask3_12_0.crx')) - # for chromedriver 2.35 - self.driver = webdriver.Chrome(chrome_options=options) - if self.environment == 'sauce': - self.driver = webdriver.Remote(self.executor_sauce_lab, - desired_capabilities=self.capabilities_sauce_lab) - self.driver.implicitly_wait(5) - - + def get_remote_caps(self): + sauce_lab_cap = dict() + sauce_lab_cap['name'] = test_data.test_name + sauce_lab_cap['build'] = pytest.config.getoption('build') + sauce_lab_cap['platform'] = "MAC" + sauce_lab_cap['browserName'] = 'Chrome' + sauce_lab_cap['screenResolution'] = '2048x1536' + sauce_lab_cap['captureHtml'] = False + return sauce_lab_cap def verify_no_errors(self): if self.errors: @@ -69,13 +31,115 @@ class BaseTestCase: msg += (error + '\n') pytest.fail(msg, pytrace=False) - def teardown_method(self): - if self.cleanup: - remove_application(self.driver) - remove_installation(self.driver) + @classmethod + def setup_class(cls): + cls.errors = [] + cls.environment = pytest.config.getoption('env') +################################################################################################################### +######### Drivers setup +################################################################################################################### + + # + # Dev Chrome options + # + cls.capabilities_dev = webdriver.ChromeOptions() + cls.capabilities_dev.add_argument('--start-fullscreen') + + # + # Org Chrome options + # + cls.capabilities_org = webdriver.ChromeOptions() + # doesn't work on sauce env + # cls.capabilities_org.add_extension(path.abspath(test_data.config['Paths']['tests_absolute'] + 'resources/metamask3_12_0.crx')) + + # + # SauceLab capabilities + # + cls.executor_sauce_lab = 'http://%s:%s@ondemand.saucelabs.com:80/wd/hub' % ( + environ.get('SAUCE_USERNAME'), environ.get('SAUCE_ACCESS_KEY')) + drivers = [] + + if cls.environment == 'local': + for caps in cls.capabilities_dev, cls.capabilities_org: + driver = webdriver.Chrome(chrome_options=caps) + drivers.append(driver) + + if cls.environment == 'sauce': + for caps in cls.capabilities_dev, cls.capabilities_org: + cls.get_remote_caps(cls) + new_caps = caps.to_capabilities() + driver = webdriver.Remote(cls.executor_sauce_lab, + desired_capabilities=new_caps) + drivers.append(driver) + + for driver in drivers: + cls.print_sauce_lab_info(cls, driver) + + cls.driver_dev = drivers[0] + cls.driver_org = drivers[1] + + for driver in drivers: + driver.implicitly_wait(10) + +################################################################################################################### +######### Actions for each driver before class +################################################################################################################### + + ######ORG + landing = LandingPage(cls.driver_org) + landing.get_landing_page() + + # Sign Up to SOB + cls.github_org = landing.login_button.click() + cls.github_org.sign_in(test_data.config['ORG']['gh_login'], + test_data.config['ORG']['gh_password']) + assert cls.github_org.permission_type.text == 'Personal user data' + bounties_page = cls.github_org.authorize_sob.click() + + # SOB Plugin installation and navigate to "Open bounties" + cls.github_org.install_sob_plugin() + assert bounties_page.bounties_header.text == 'Bounties' + assert bounties_page.top_hunters_header.text == 'Top 5 hunters' + + ######DEV + cls.github_dev = GithubPage(cls.driver_dev) + # Sign In to GH as Developer + cls.github_dev.get_login_page() + cls.github_dev.sign_in(test_data.config['DEV']['gh_login'], + test_data.config['DEV']['gh_password']) + + # Fork repo as Developer from Organization + cls.github_dev.fork_repo(test_data.config['ORG']['gh_repo']) + + # Cloning repo to local git as Developer and set upstream to Organization (via HTTPS) + cls.github_dev.clone_repo(test_data.config['ORG']['gh_repo'], + test_data.config['DEV']['gh_username'], + test_data.config['ORG']['gh_repo_name'], + 'git_repo') + cls.verify_no_errors(cls) + + + + + @classmethod + def teardown_class(cls): + + ######ORG + + # SOB Plugin remove installation + remove_application(cls.driver_org) + remove_installation(cls.driver_org) + + ######DEV + + cls.github_dev.delete_fork() + cls.github_dev.clean_repo_local_folder() try: - self.print_sauce_lab_info(self.driver) - self.driver.quit() + cls.driver_dev.quit() + cls.driver_org.quit() except WebDriverException: pass + + + diff --git a/test/end-to-end/tests/config_example.ini b/test/end-to-end/tests/config_example.ini index 4b52a24..6d2f8c3 100644 --- a/test/end-to-end/tests/config_example.ini +++ b/test/end-to-end/tests/config_example.ini @@ -1,13 +1,30 @@ [Common] ;app URL url = https://openbounty.status.im:444/ +;URL to GH openbounty app +sob_test_app = http://github.com/apps/status-open-bounty-app-test +gh_login = https://github.com/login [Paths] -;AbsolutePath to 'tests' folder -tests_absolute = /usr/dir/open-bounty/test/end-to-end/tests/ +;AbsolutePath to 'end-to-end' folder +tests_absolute = /usr/dir/open-bounty/test/end-to-end/ [ORG] -;GitHub credentials for organization + +;GitHub credentials for organization owner gh_login = login gh_password = password +;GitHub organization path +gh_org_profile = https://github.com/organizations/organization/ +;GitHub repo path +gh_repo = https://github.com/org_name/repo_name/ +;GitHub repo name +gh_repo_name = repo_name + ;MetaMask password for organization mm_password = password +[DEV] +;GitHub credentials for developer +gh_login = login +gh_password = password +gh_username = username +gh_forked_repo = https://github.com/username/reponame/ \ No newline at end of file diff --git a/test/end-to-end/tests/postconditions.py b/test/end-to-end/tests/postconditions.py index b9f4c7d..1b09d42 100644 --- a/test/end-to-end/tests/postconditions.py +++ b/test/end-to-end/tests/postconditions.py @@ -1,5 +1,6 @@ from selenium.webdriver.common.by import By from selenium.common.exceptions import NoSuchElementException +from tests import test_data def remove_application(driver): @@ -13,7 +14,7 @@ def remove_application(driver): def remove_installation(driver): try: - driver.get('https://github.com/organizations/Org4/settings/installations') + driver.get(test_data.config['ORG']['gh_org_profile'] + 'settings/installations') driver.find_element(By.CSS_SELECTOR, '.iconbutton').click() driver.find_element(By.XPATH, "//a[@class='btn btn-danger']").click() driver.find_element(By.CSS_SELECTOR, '.facebox-popup .btn-danger').click() diff --git a/test/end-to-end/tests/test_contracts.py b/test/end-to-end/tests/test_contracts.py index 5564b7b..17c66e2 100644 --- a/test/end-to-end/tests/test_contracts.py +++ b/test/end-to-end/tests/test_contracts.py @@ -2,6 +2,7 @@ import pytest from os import environ from pages.openbounty.landing import LandingPage from pages.openbounty.bounties import BountiesPage +from pages.thirdparty.github import GithubPage from tests.basetestcase import BaseTestCase from tests import test_data @@ -10,28 +11,13 @@ from tests import test_data class TestLogin(BaseTestCase): def test_deploy_new_contract(self): - self.cleanup = True - landing = LandingPage(self.driver) - landing.get_landing_page() - - # Sign Up to SOB - github = landing.login_button.click() - github.sign_in(test_data.config['ORG']['gh_login'], - test_data.config['ORG']['gh_password']) - assert github.permission_type.text == 'Personal user data' - bounties_page = github.authorize_sob.click() - - # SOB Plugin installation and navigate to "Open bounties" - github.install_sob_plugin() - assert bounties_page.bounties_header.text == 'Bounties' - assert bounties_page.top_hunters_header.text == 'Top 5 hunters' # Waiting for deployed contract; test_data.issue created here - github.create_new_bounty() - github.get_deployed_contract() + self.github_org.create_new_bounty() + self.github_org.get_deployed_contract() # Navigate and check top bounty in "Open bounties" - bounties_page = BountiesPage(self.driver) + bounties_page = BountiesPage(self.driver_org) bounties_page.get_bounties_page() titles = bounties_page.bounty_titles.find_elements() assert titles[0].text == test_data.issue['title'] @@ -40,3 +26,6 @@ class TestLogin(BaseTestCase): + + +