├── .DS_Store ├── .credo.exs ├── .formatter.exs ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── lib ├── actions.ex ├── bot.ex ├── bot_army.ex ├── bt_parser.ex ├── ets_metrics.ex ├── integration_test.ex ├── load_test.ex ├── log_formatters │ └── json_log_formatter.ex ├── metrics_formatters │ ├── export.ex │ └── summary_report.ex ├── mix │ └── tasks │ │ ├── bots.extract_actions.ex │ │ ├── bots.load_test.ex │ │ ├── helpers.ex │ │ └── load_test_release.ex ├── router.ex ├── shared_data.ex └── term_parser.ex ├── mix.exs ├── mix.lock └── test ├── base_name_sample.json ├── bot_test.exs ├── bt_parser_test.exs ├── bt_sample.json ├── ets_metrics_export_test.exs └── test_helper.exs /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/bot_army/68369a846d13e4ff973fdbc5decd418cc6e80f43/.DS_Store -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib/", "src/", "web/", "apps/"], 7 | excluded: [] 8 | }, 9 | checks: [ 10 | # don't fail on TODOs in code 11 | {Credo.Check.Design.TagTODO, exit_status: 0}, 12 | # because correctly ordered logging is critical to analyzing the logs, don't fail here 13 | {Credo.Check.Warning.LazyLogging, exit_status: 0} 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,scripts,test}/**/*.{ex,exs}"], 4 | locals_without_parens: [pre: 1, post: 1, parallel: 2, merge: 1] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ### Expected Behaviour 5 | 6 | ### Actual Behaviour 7 | 8 | ### Reproduce Scenario (including but not limited to) 9 | 10 | #### Steps to Reproduce 11 | 12 | #### Platform and Version 13 | 14 | #### Sample Code that illustrates the problem 15 | 16 | #### Logs taken while reproducing problem 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | 7 | ## Related Issue 8 | 9 | 10 | 11 | 12 | 13 | 14 | ## Motivation and Context 15 | 16 | 17 | 18 | ## How Has This Been Tested? 19 | 20 | 21 | 22 | 23 | 24 | ## Screenshots (if appropriate): 25 | 26 | ## Types of changes 27 | 28 | 29 | 30 | - [ ] Bug fix (non-breaking change which fixes an issue) 31 | - [ ] New feature (non-breaking change which adds functionality) 32 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 33 | 34 | ## Checklist: 35 | 36 | 37 | 38 | 39 | - [ ] I have signed the [Adobe Open Source CLA](https://opensource.adobe.com/cla.html). 40 | - [ ] My code follows the code style of this project. 41 | - [ ] My change requires a change to the documentation. 42 | - [ ] I have updated the documentation accordingly. 43 | - [ ] I have read the **CONTRIBUTING** document. 44 | - [ ] I have added tests to cover my changes. 45 | - [ ] All new and existing tests passed. 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build/ 2 | /cover/ 3 | /deps/ 4 | /doc/ 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | *.beam 9 | tags 10 | .elixir_ls 11 | .envrc 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Adobe Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language. 18 | * Being respectful of differing viewpoints and experiences. 19 | * Gracefully accepting constructive criticism. 20 | * Focusing on what is best for the community. 21 | * Showing empathy towards other community members. 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances. 27 | * Trolling, insulting/derogatory comments, and personal or political attacks. 28 | * Public or private harassment. 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission. 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting. 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at Grp-opensourceoffice@adobe.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version]. 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for choosing to contribute! 4 | 5 | The following are a set of guidelines to follow when contributing to this project. 6 | 7 | ## Code Of Conduct 8 | 9 | This project adheres to the Adobe [code of conduct](../CODE_OF_CONDUCT.md). By participating, 10 | you are expected to uphold this code. Please report unacceptable behavior to 11 | [Grp-opensourceoffice@adobe.com](mailto:Grp-opensourceoffice@adobe.com). 12 | 13 | ## Have A Question? 14 | 15 | Start by filing an issue. The existing committers on this project work to reach 16 | consensus around project direction and issue solutions within issue threads 17 | (when appropriate). 18 | 19 | ## Contributor License Agreement 20 | 21 | All third-party contributions to this project must be accompanied by a signed contributor 22 | license agreement. This gives Adobe permission to redistribute your contributions 23 | as part of the project. [Sign our CLA](https://opensource.adobe.com/cla.html). You 24 | only need to submit an Adobe CLA one time, so if you have submitted one previously, 25 | you are good to go! 26 | 27 | ## Code Reviews 28 | 29 | All submissions should come in the form of pull requests and need to be reviewed 30 | by project committers. Read [GitHub's pull request documentation](https://help.github.com/articles/about-pull-requests/) 31 | for more information on sending pull requests. 32 | 33 | Lastly, please follow the [pull request template](PULL_REQUEST_TEMPLATE.md) when 34 | submitting a pull request! 35 | 36 | ## From Contributor To Committer 37 | 38 | We love contributions from our community! If you'd like to go a step beyond contributor 39 | and become a committer with full write access and a say in the project, you must 40 | be invited to the project. The existing committers employ an internal nomination 41 | process that must reach lazy consensus (silence is approval) before invitations 42 | are issued. If you feel you are qualified and want to get more deeply involved, 43 | feel free to reach out to existing committers to have a conversation about that. 44 | 45 | ## Security Issues 46 | 47 | Security issues shouldn't be reported on this issue tracker. Instead, [file an issue to our security experts](https://helpx.adobe.com/security/alertus.html). 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | © Copyright 2020 Adobe. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bot Army 2 | 3 | A framework for building and running "bots" for load testing and integration testing. 4 | Bots are defined by [Behavior 5 | Trees](https://hexdocs.pm/behavior_tree/BehaviorTree.html) to replicate different 6 | user sequences. 7 | 8 | This package is a generic runner. It works in conjunction with domain specific bots 9 | that you define in the service you want to test. 10 | 11 | Quick start: install add `{:bot_army, "~> 1.0"}` to your `mix.exs` deps. See [the 12 | bot army starter](https://github.com/adobe/bot_army_starter) for a sample set up. 13 | The [Bot Army Cookbook](https://opensource.adobe.com/bot_army_cookbook/) has many 14 | tips and tricks for various techniques. 15 | 16 | ## Behavior what? 17 | 18 | Behavior trees. It's a nifty way to declaratively express complex and variable 19 | sequences of actions. Most importantly, they are composable, which makes them easy 20 | to work with, and easy to scale. 21 | 22 | [Read up on the docs](https://hexdocs.pm/behavior_tree/BehaviorTree.html) or [Watch a 23 | video](https://www.youtube.com/watch?v=3sLYzxuKGXI). 24 | 25 | Bots look like this: 26 | 27 | ```elixir 28 | # in MyService.Workflow.Simple 29 | def tree do 30 | BehaviorTree.Node.sequence([ 31 | BotArmy.Actions.action(MyService.Actions, :get_ready), 32 | BotArmy.Actions.action(BotArmy.Actions, :wait, [5]), 33 | BehaviorTree.Node.select([ 34 | BotArmy.Actions.action(MyService.Actions, :try_something, [42]), 35 | BotArmy.Actions.action(MyService.Actions, :try_something_else), 36 | BotArmy.Actions.action(BotArmy.Actions, :error, ["Darn, didn't work!"]) 37 | ]), 38 | MyService.Workflow.DifficultWork.tree(), 39 | BotArmy.Actions.action(BotArmy.Actions, :done) 40 | ]) 41 | end 42 | ``` 43 | 44 | ```elixir 45 | # in MyService.Actions 46 | def get_ready(context) do 47 | {id: id} = set_up() 48 | {:succeed, id: id} # adds `id` to the context for future actions to use 49 | end 50 | 51 | def try_something(context, magic_number) do 52 | case do_it(context.id, magic_number) do 53 | {:ok, _} -> :succeed 54 | {:error, _} -> :fail 55 | end 56 | end 57 | 58 | def try_something_else(context), do: ... 59 | ``` 60 | 61 | See `BotArmy.Bot` and `BotArmy.IntegrationTest` and `BotArmy.Actions` for more details. 62 | 63 | ## What if I want to make trees with a GUI editor? 64 | 65 | No problem, check out the [Behavior Tree 66 | Editor](https://github.com/adobe/behavior_tree_editor) to make json files that you 67 | can parse with `BotArmy.BTParser.parse!/2`. You can export your actions with 68 | `mix bots.extract_actions`. 69 | 70 | ![Behavior Tree Editor 71 | example](https://raw.githubusercontent.com/adobe/behavior_tree_editor/master/preview.png) 72 | 73 | ## Release the bots! 74 | 75 | Run the bots with `mix bots.load_test`: 76 | 77 | mix bots.load_test --n 100 --tree MyService.Workflow.Simple 78 | 79 | ## Integration testing 80 | 81 | The bots can double as an integration testing system, which you can integrate into 82 | your CI pipeline. Integration tests are run via 83 | [ExUnit](https://hexdocs.pm/ex_unit/ExUnit.html) just like normal unit tests. See 84 | `BotArmy.IntegrationTest` for useful helpers that allow you to run trees as your 85 | tests. 86 | 87 | ## Logging 88 | 89 | > By default, logs are shunted to the `./bot_run.log` file. 90 | 91 | It's hard to keep up with thousands of bots. The logs help, but need to be analyzed 92 | in meaningful ways. Using [`lnav`](http://lnav.org) to view the `bot_run.log` file 93 | is extremely useful. One useful approach is simply to find where errors occurred, 94 | but making use of the SQL feature can give very useful metrics. Try these queries 95 | for example (note that the key words are auto-derived from the log format): 96 | 97 | # list how many times each action ran 98 | ;select count(action_0), action_0 from logline group by action_0 99 | 100 | #see how long actions took on aggregate 101 | ;select min(duration), mode(duration), max(duration), avg(duration), action_0 from logline group by action_0 order by avg(duration) desc 102 | 103 | # Show count and duration for each distinct action attempted by the bots, grouped 104 | by success or failure. 105 | ;select count(action_0), avg(duration), outcome, action_0, outcome from logline group by outcome, action_0 106 | 107 | # list actions with their num of failures and errors and success rate 108 | ;SELECT action_0,count(*) as runs,count(CASE outcome WHEN "fail" then 1 end) as fails,count(CASE WHEN outcome LIKE "error%" then 1 end) as errors,round(100 * (count(CASE outcome WHEN "succeed" then 1 end)) / count(*)) as success_rate FROM logline group by action_0 order by success_rate desc 109 | 110 | # list average number of times bots perform each action (for the duration of the 111 | logs queried) 112 | ;select action_0, avg(runs) from (select bot_id, action_0, count(*) runs from logline group by bot_id, action_0) group by action_0 order by avg(runs) desc 113 | 114 | `lnav` also offers some nice filtering options. For example: 115 | 116 | # Show only log lines where with a duration value of 1000ms or larger. 117 | :filter-in duration=\d{4,}ms 118 | 119 | > Logging Configuration Options 120 | 121 | Other logging formats may be useful depending on application. For example, if logs are output to Splunk or some other log aggregation tooling, it may be beneficial to use JSON-formatted logs rather than a line-by-line representation. 122 | 123 | To enable JSON-formatted logs, pass the `--format-json-logs` option when starting your bot run. 124 | 125 | To disable log outputs to a file, pass the `--disable-log-file` option when starting your bot run. 126 | 127 | ## Metrics schema 128 | 129 | During the course of a run a `Bot` will generate information pertaining to their 130 | activity in a system. 131 | 132 | In order to communicate this information with the outside world a `BotManager` will 133 | retain information about an ongoing attack which conforms to the following schema. 134 | 135 | ``` 136 | { 137 | bot_count: ..., 138 | total_error_count: ..., 139 | actions: { 140 | : { 141 | duration (running average): ..., 142 | success_count: ..., 143 | error_count: ... 144 | } 145 | } 146 | } 147 | ``` 148 | 149 | Where `bot_count` is expected to change over the course of a run and represents a 150 | point in time count of the number of bots currently alive. 151 | 152 | `actions` is a map whose keys are the name of the action and whose value is a map 153 | containing key value pairs with the following information: The running average 154 | duration the given action has taken to complete, the number of successful invocations 155 | of the given action, and the number of errors encountered when running the given 156 | action. 157 | 158 | `total_error_count` is the aggregate of all errors reported by the bots. This can be 159 | used to catch any lurking problems not directly reported via the `actions` error 160 | counts. 161 | 162 | ## Communicating with the bots from outside 163 | 164 | The bots expose a simple HTTP api on port `8124`. 165 | 166 | You can use the following routes: 167 | 168 | - `POST [host]:8124/load_test/start` (same params as `mix bots.load_test`) 169 | - `DELETE [host]:8124/load_test/stop` 170 | - `GET [host]:8124/metrics` 171 | - `GET [host]:8124/logs` 172 | 173 | ## Are there tests? 174 | 175 | > Who tests the tests? 176 | 177 | Some. Run `make test`. 178 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "dev.exs" 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # silence logs during testing (mostly) 4 | config :logger, 5 | backends: [] 6 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "treat_no_relevant_lines_as_covered": true, 4 | "minimum_coverage": 10 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/actions.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.Actions do 11 | @moduledoc """ 12 | Generic Actions. 13 | 14 | Actions are functions that take the bot's context and any supplied arguments, 15 | perform some useful side effects, and then return the outcome. The context is 16 | always passed as the first argument. 17 | 18 | Valid outcomes are: `:succeed`, `:fail`, `:continue`, `:done` or `{:error, 19 | reason}`. 20 | 21 | `:succeed`, `:fail`, and `:continue` can also be in the form of `{:succeed, key: 22 | "value"}` if you want save/update the context. 23 | """ 24 | 25 | require Logger 26 | 27 | @typedoc """ 28 | Actions must return one of these outcomes. 29 | """ 30 | @type outcome :: 31 | :succeed 32 | | :fail 33 | | :continue 34 | | :done 35 | | {:error, any()} 36 | | {:succeed, keyword()} 37 | | {:fail, keyword()} 38 | | {:continue, keyword()} 39 | 40 | @doc """ 41 | A semantic helper to define actions in your behavior tree. 42 | 43 | Node.sequence([ 44 | ... 45 | action(BotArmy.Actions, :wait, [5]), 46 | ... 47 | action(BotArmy.Actions, :done) 48 | ]) 49 | """ 50 | def action(module, fun, args \\ []) do 51 | {module, fun, args} 52 | end 53 | 54 | @doc """ 55 | Makes the calling process wait for the given number of seconds 56 | """ 57 | def wait(_context, s \\ 5) do 58 | Process.sleep(trunc(1000 * s)) 59 | :succeed 60 | end 61 | 62 | @doc """ 63 | Makes the calling process wait for a random number of seconds in the range defined 64 | by the given integers min and max 65 | """ 66 | def wait(_context, min, max) when is_integer(min) and is_integer(max) do 67 | Process.sleep(1000 * Enum.random(min..max)) 68 | :succeed 69 | end 70 | 71 | @doc """ 72 | Given a rate as a percentage, this will succeed that percent of the time, and fail 73 | otherwise. 74 | 75 | For example `succeed_rate(context, 0.25)` will succeed on average 1 our of 4 tries. 76 | """ 77 | def succeed_rate(_context, rate) when is_float(rate) and rate < 1 and rate > 0 do 78 | if :rand.uniform() <= rate, 79 | do: :succeed, 80 | else: :fail 81 | end 82 | 83 | @doc """ 84 | This will stop the bot from running (by default bots "loop" continously through 85 | their behavior trees 86 | """ 87 | def done(_), do: :done 88 | 89 | @doc """ 90 | Signal that this bot has errored, causing the bot's process to die with the given 91 | reason. 92 | """ 93 | def error(_, reason), do: {:error, reason} 94 | 95 | @doc """ 96 | A helpful way to "tap" the flow of the behavior tree for debugging. 97 | """ 98 | def log(_context, message) do 99 | Logger.info(message) 100 | :succeed 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/bot.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.Bot do 11 | @moduledoc """ 12 | A "live" bot that can perform actions described by a behavior tree 13 | (`BehaviorTree.Node`). 14 | 15 | Bots are just GenServers that continuously tick through the provided behavior tree 16 | until they die or get an outcome of `:done`. 17 | 18 | Each bot has a "bag of state" called the "context" (sometimes called a "blackboard" 19 | in behaviour tree terminology), which is used to pass values between actions. 20 | 21 | Bots ingest actions (the leaf nodes of the tree) in the form of MFA tuples. The 22 | function will be called with the current context and any provided arguments. The 23 | function must return an outcome, and may also return key-value pairs to 24 | store/update in the context. 25 | 26 | See the [README](readme.html#behavior-what) for an example. 27 | 28 | Accepted outcomes are: `:succeed`, `:fail`, `:continue`, `:done` or `{:error, 29 | reason}`. 30 | `:succeed`, `:fail` and `:continue` can also be in the form of `{:succeed, key: 31 | "value"}` if you want save/update the context. 32 | 33 | `:succeed` and `:fail` will advance the tree via `BehaviorTree`, while `:continue` 34 | will leave the tree as it is for the next tick (this can be useful for example for 35 | attempting an action multiple times with a sleep and a "max tries" counter in the 36 | context). `:done` stops the bot process successfully, and `{:error, reason}` kills 37 | the bot process with the provided reason. 38 | 39 | Note the following keys are reserved, and should not be overwritten: `id`, `bt`, 40 | `start_time` 41 | 42 | 43 | Extending the Bot 44 | ----------------- 45 | 46 | You can use Bot exactly as it is. However, if you need to add additional 47 | functionality to your bots, you can do so. 48 | 49 | `BotArmy.Bot` is actually a behaviour that you can use (`use BotArmy.Bot`) in a 50 | custom callback module. It has a couple of callbacks for config, logging, and 51 | lifecycle hooks (TODO). Since Bot itself uses a GenServer, you can also add 52 | GenServer callbacks, such as `init`, or `handle_[call|cast|info]` 53 | (`format_status/2` is particularly useful). 54 | 55 | For example, you may have a syncing system over websockets that updates the state 56 | in the bot's context. By extending Bot with some additional handlers, you can add 57 | this functionality. 58 | 59 | The aforementioned "context" is just the GenServer's state, so your Actions will 60 | have access to everything there. You can set up initial state in `init/1`, and 61 | modify it in your handlers as necessary. If you implement `init/1`, the argument 62 | will be the starting state, so you can merge your state into that, but must return 63 | it. Again, please be mindful not to overwrite the following keys in the state: 64 | `id`, `bt`, `start_time`. 65 | 66 | IMPORTANT - if you do extend Bot, you must set the `bot` param when starting a run 67 | (see the docs in the mix tasks). 68 | 69 | """ 70 | 71 | @doc """ 72 | Implement this callback if you want a custom way to log action outcomes. 73 | Defaults to a call to `Logger.info` with nice meta data. 74 | """ 75 | @callback log_action_outcome( 76 | action_mfa :: {module, atom, list(any)}, 77 | duration :: integer, 78 | outcome :: atom 79 | ) :: any 80 | 81 | require Logger 82 | alias BehaviorTree, as: BT 83 | 84 | defmacro __using__(_) do 85 | quote do 86 | @behaviour BotArmy.Bot 87 | 88 | alias BehaviorTree, as: BT 89 | require Logger 90 | use GenServer, restart: :temporary 91 | 92 | def start_link(opts \\ []), do: BotArmy.Bot.start_link(__MODULE__, opts) 93 | 94 | @impl BotArmy.Bot 95 | def log_action_outcome(action_mfa, duration, outcome), 96 | do: BotArmy.Bot.log_action_outcome(action_mfa, duration, outcome) 97 | 98 | @impl GenServer 99 | def init(args), do: {:ok, args} 100 | 101 | @impl GenServer 102 | def handle_call({:run, _}, _from, %{bt: _} = state), 103 | do: {:reply, {:error, :already_running}, state} 104 | 105 | @impl GenServer 106 | def handle_call({:run, tree}, _from, state) do 107 | # we inspect the exit reasons for better logging 108 | Process.flag(:trap_exit, true) 109 | 110 | Logger.metadata(bot_id: state.id, bot_pid: self()) 111 | 112 | send(self(), :tick) 113 | 114 | # TODO maybe include a pre-run hook? 115 | {:reply, :ok, Map.put(state, :bt, BT.start(tree))} 116 | end 117 | 118 | @impl GenServer 119 | def handle_info(:tick, %{bt: _} = state) do 120 | BotArmy.Bot.tick(__MODULE__, state) 121 | end 122 | 123 | @impl GenServer 124 | def format_status(_, [_, state]) do 125 | # the behavior tree is so noisy, so remove it from logging if the bot dies 126 | "Bot state elided. Overwrite `format_status/2` in a custom `Bot` to change." 127 | end 128 | 129 | @impl GenServer 130 | def terminate(:normal, state) do 131 | # TODO maybe callback modules will want to override this? They could, but 132 | # would lose the logging. Maybe we add another callback for clean_up/logging 133 | # side effects? 134 | # need to convert :normal to :shutdown to kill off linked processes (like the 135 | # socketApi) 136 | {:stop, :shutdown, state} 137 | end 138 | 139 | @impl GenServer 140 | def terminate(reason, state) do 141 | BotArmy.Bot.handle_error(reason, state) 142 | end 143 | 144 | defoverridable init: 1, log_action_outcome: 3, format_status: 2 145 | end 146 | end 147 | 148 | @doc """ 149 | Start up a new bot using the supplied bot implementation. 150 | 151 | Takes a keyword list of options that will be passed to the GenServer. The 152 | following keys are also used: 153 | 154 | * `:id` - [required] an identifier for this specific bot, used for logging 155 | purposes. 156 | 157 | """ 158 | def start_link(bot_callback_module, opts \\ []) do 159 | id = Keyword.get(opts, :id) || raise "You must specify a unique `id` for this bot" 160 | 161 | state = %{ 162 | id: id, 163 | start_time: System.monotonic_time(:millisecond) 164 | } 165 | 166 | Logger.info("A new baby bot is born") 167 | GenServer.start_link(bot_callback_module, state, opts) 168 | end 169 | 170 | @doc """ 171 | Instruct the bot to run the supplied behavior tree. 172 | 173 | Note, the bot will repeatedly loop through the tree unless you include an action 174 | that returns `:done`, at which point it will die. 175 | 176 | Returns an error tuple if called on a bot that is already running. 177 | 178 | """ 179 | def run(pid, tree) do 180 | GenServer.call(pid, {:run, tree}) 181 | end 182 | 183 | @doc false 184 | # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 185 | def tick(callback_mod, %{bt: bt} = state) do 186 | # TODO make a `pre_tick` optional_callback for side effects? Maybe `post_tick` 187 | # with result? 188 | 189 | action_mfa = BT.value(bt) 190 | {action_module, action_fun_atom, args} = action_mfa 191 | 192 | start_time = System.monotonic_time(:millisecond) 193 | result = apply(action_module, action_fun_atom, [state | args]) 194 | duration = System.monotonic_time(:millisecond) - start_time 195 | 196 | outcome = 197 | case result do 198 | {v, _} -> v 199 | v -> v 200 | end 201 | 202 | callback_mod.log_action_outcome(action_mfa, duration, outcome) 203 | send(BotArmy.EtsMetrics, {:action, action_module, action_fun_atom, duration, outcome}) 204 | 205 | case result do 206 | :succeed -> 207 | send(self(), :tick) 208 | {:noreply, %{state | bt: BT.succeed(bt)}} 209 | 210 | :fail -> 211 | send(self(), :tick) 212 | {:noreply, %{state | bt: BT.fail(bt)}} 213 | 214 | :continue -> 215 | send(self(), :tick) 216 | {:noreply, state} 217 | 218 | {:succeed, updates} -> 219 | send(self(), :tick) 220 | new_state = Enum.into(updates, state) 221 | {:noreply, %{new_state | bt: BT.succeed(bt)}} 222 | 223 | {:fail, updates} -> 224 | send(self(), :tick) 225 | new_state = Enum.into(updates, state) 226 | {:noreply, %{new_state | bt: BT.fail(bt)}} 227 | 228 | {:continue, updates} -> 229 | send(self(), :tick) 230 | new_state = Enum.into(updates, state) 231 | {:noreply, new_state} 232 | 233 | :done -> 234 | duration = System.monotonic_time(:millisecond) - state.start_time 235 | 236 | Logger.info( 237 | "Bot finished work", 238 | bot_id: state.id, 239 | uptime: 240 | duration 241 | |> Timex.Duration.from_milliseconds() 242 | |> Timex.format_duration(:humanized) 243 | ) 244 | 245 | {:stop, :shutdown, state} 246 | 247 | {:error, reason} -> 248 | BotArmy.Bot.handle_error({:error, reason}, state) 249 | end 250 | end 251 | 252 | @doc false 253 | def log_action_outcome({action_module, action_fun_atom, _args}, duration, outcome) do 254 | meta = [ 255 | action: 256 | "#{inspect(action_module)}.#{action_fun_atom |> to_string |> String.trim_leading(":")}", 257 | duration: duration, 258 | outcome: outcome 259 | ] 260 | 261 | case outcome do 262 | {:fail, _} -> Logger.warn("", meta) 263 | :fail -> Logger.warn("", meta) 264 | :error -> Logger.error("", meta) 265 | _ -> Logger.info("", meta) 266 | end 267 | end 268 | 269 | @doc false 270 | def handle_error(:shutdown, state) do 271 | {:stop, :shutdown, state} 272 | end 273 | 274 | @doc false 275 | def handle_error(error, state) do 276 | {:stop, error, state} 277 | end 278 | end 279 | 280 | defmodule BotArmy.Bot.Default do 281 | @moduledoc """ 282 | The standard bot, implementing the BotArmy.Bot behaviour, without any additional 283 | functionality or configuration. 284 | """ 285 | use BotArmy.Bot 286 | 287 | def handle_info(unhandled_message, state) do 288 | Logger.warn("unhandled message in bot: #{inspect(unhandled_message)}") 289 | {:noreply, state} 290 | end 291 | end 292 | -------------------------------------------------------------------------------- /lib/bot_army.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy do 11 | @moduledoc false 12 | use Application 13 | 14 | alias BotArmy.{Router, EtsMetrics, LoadTest, SharedData} 15 | 16 | def start(_types, _args) do 17 | children = [ 18 | # Note, the LoadTest monitors all the bots, so if the BotSupervisor crashes, it 19 | # will update accordingly 20 | SharedData, 21 | LoadTest, 22 | {DynamicSupervisor, strategy: :one_for_one, name: BotSupervisor}, 23 | EtsMetrics, 24 | Plug.Cowboy.child_spec(scheme: :http, plug: Router, options: [port: 8124]) 25 | ] 26 | 27 | Supervisor.start_link(children, strategy: :rest_for_one) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/bt_parser.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.BTParser do 11 | @moduledoc """ 12 | Parses JSON files created from the [Behavior Tree visual 13 | editor](https://github.com/adobe/behavior_tree_editor) into a `BehaviorTree.Node`, 14 | ready to be supplied to a bot. 15 | 16 | Note, you can automatically import your defined Actions into the visual editor with 17 | the included `mix bots.extract_actions` mix task: 18 | 19 | `mix bots.extract_actions --actions-dir lib/actions/ --module-base MyProject.Actions --bt-json-file lib/trees/tree.json` 20 | """ 21 | 22 | alias BehaviorTree.Node 23 | import BotArmy.Actions, only: [action: 3] 24 | 25 | @doc """ 26 | Parses the requested tree in the supplied JSON file created with the visual editor. 27 | 28 | The `tree` parameter should match the title of the tree in the editor. 29 | 30 | The following `opts` are allowed: 31 | 32 | * `context` - a map of keys and values to be merged onto the root node's properties 33 | (overwriting any existing keys). 34 | * `module_base` - a common module base to prefix each parsed generic function 35 | style action and custom action. Useful in combination with the `module-base` flag 36 | on the `bots.extract_actions` mix task. 37 | """ 38 | @spec parse!(path :: String.t(), tree :: String.t(), opts :: Keyword.t()) :: 39 | BehaviorTree.Node.t() 40 | def parse!(path, tree_title, opts \\ []) do 41 | project = 42 | path 43 | |> File.read!() 44 | |> Jason.decode!() 45 | |> (fn 46 | # json output via save to file has a "wrapping" layer, whereas copy/paste 47 | # from Project > Export does not 48 | %{"data" => data} -> 49 | data 50 | 51 | full -> 52 | full 53 | end).() 54 | 55 | root_tree = 56 | project["trees"] 57 | |> Enum.find(fn 58 | %{"title" => ^tree_title} -> true 59 | _ -> false 60 | end) 61 | 62 | unless root_tree, 63 | do: 64 | raise( 65 | "Unable to find tree \"#{inspect(tree_title)}\" Found trees: #{ 66 | project["trees"] |> Enum.map(& &1["title"]) |> Enum.join(", ") 67 | }" 68 | ) 69 | 70 | context = Keyword.get(opts, :context, %{}) 71 | context_with_string_keys = for {k, v} <- context, into: %{}, do: {to_string(k), v} 72 | 73 | project_with_module_base = Map.put(project, "module_base", Keyword.get(opts, :module_base)) 74 | 75 | tree = 76 | root_tree 77 | |> Map.update!("properties", &Map.merge(&1, context_with_string_keys)) 78 | |> convert_tree(project_with_module_base) 79 | 80 | tree 81 | end 82 | 83 | defp get_tree(id, project) do 84 | Enum.find( 85 | project["trees"], 86 | fn 87 | %{"id" => ^id} -> true 88 | _ -> false 89 | end 90 | ) 91 | end 92 | 93 | defp get_node(id, tree) do 94 | Map.get(tree["nodes"], id) 95 | end 96 | 97 | # Given context of `%{num: 1, "str" -> "hi"}` 98 | # `replace_templates!("9, {{{num}}, {{str}}", context)` -> `~s(9, 1, "hi")` 99 | # `replace_templates!("{{num}}", context)` -> `~s(9)` 100 | # `replace_templates!("{{str}}", context)` -> `~s("hi")` 101 | defp replace_templates!(str, context) when is_binary(str) and is_map(context) do 102 | Regex.replace(~r/{{([^}]+)}}/, str, fn _whole_match, key -> 103 | value = Map.get(context, key) 104 | 105 | unless value, 106 | do: 107 | raise( 108 | ~s(Unable to find a property with key `#{key}`in this node's tree's properties. Defined properties: `#{ 109 | inspect(context) 110 | }`) 111 | ) 112 | 113 | # The looked-up value might be an int, which doesn't work with Regex.replace 114 | # (becomes a binary), so we must to_string it first 115 | # Otherwise, it needs to be wrapped in quotes to appear as it would if it were 116 | # specified directly, (see examples above function) 117 | case value do 118 | i when is_integer(i) -> to_string(i) 119 | other -> ~s("#{other}") 120 | end 121 | end) 122 | end 123 | 124 | defp ensure_int(int) when is_integer(int), do: {:ok, int} 125 | 126 | defp ensure_int(other) do 127 | case Integer.parse(other) do 128 | {n, ""} -> {:ok, n} 129 | _ -> {:error, other} 130 | end 131 | end 132 | 133 | defp get_properties(node, context) do 134 | node["properties"] 135 | |> Enum.map(fn {k, v} -> 136 | {k, 137 | v 138 | # properties might be ints, so ensure they are strings for replace_templates! 139 | # and TermParser.parse to work 140 | |> to_string 141 | |> replace_templates!(context) 142 | |> TermParser.parse() 143 | |> case do 144 | {:ok, parsed} -> 145 | parsed 146 | 147 | e -> 148 | raise( 149 | ~s(Cannot parse property "#{k}" with value `#{v}` in #{inspect(node["properties"])}, error #{ 150 | inspect(e) 151 | }) 152 | ) 153 | end} 154 | end) 155 | |> Enum.into(%{}) 156 | end 157 | 158 | defp extract_args!(str, context) when is_binary(str) do 159 | [_all, args] = Regex.run(~r/^[^(]+\(([^)]*)\)/, str) 160 | args |> parse_args!(context) 161 | end 162 | 163 | defp parse_args!(args, context) do 164 | case args |> replace_templates!(context) |> (&("[" <> &1 <> "]")).() |> TermParser.parse() do 165 | {:ok, parsed_args} -> 166 | parsed_args 167 | 168 | {:error, e} -> 169 | raise ~s(Unable to parse args `#{args}`. Make sure they are in a valid Elixir terms format, like `"my_string", 99, false, [opt_a: true], %{name: "Tom"}`. 170 | Raw error: #{inspect(e, pretty: true)}) 171 | end 172 | end 173 | 174 | defp print_mfa(m, f, a) do 175 | String.trim_leading(to_string(m), "Elixir.") <> 176 | "." <> to_string(f) <> "/" <> to_string(Enum.count(a) + 1) 177 | end 178 | 179 | defp parse_action_syntax(str, module_base, context) when is_binary(str) do 180 | with {:format?, [_all, mod_fn, args_str]} <- 181 | {:format?, Regex.run(~r/^([^(]+)\((.*)\)(?:\s.+|$)/, str)}, 182 | {:format?, [function_str | mod_reversed]} when mod_reversed != [] and function_str != "" <- 183 | {:format?, mod_fn |> String.split(".") |> Enum.reverse()}, 184 | args <- args_str |> parse_args!(context), 185 | mod <- 186 | mod_reversed 187 | |> Enum.reverse() 188 | |> List.insert_at(0, module_base) 189 | |> Module.concat(), 190 | function <- String.to_atom(function_str), 191 | # If the action module is never used (which is likely the case when 192 | # parsing from json) it won't get loaded 193 | Code.ensure_loaded(mod), 194 | {:exists?, _, true} <- 195 | {:exists?, print_mfa(mod, function, args), 196 | function_exported?( 197 | mod, 198 | function, 199 | # add 1 for the implicit "context" argument that gets tacked on 200 | Enum.count(args) + 1 201 | )} do 202 | {:ok, {mod, function, args}} 203 | else 204 | {:format?, _} -> 205 | raise "Runner/custom action nodes must have a title like \"Module.Submodule.function_name(1,2,3)\" all in valid Elixir terms. Unable to parse \"#{ 206 | str 207 | }\"." 208 | 209 | {:exists?, name, false} -> 210 | raise "The provided action does not exist: \"#{name}\"" 211 | 212 | e -> 213 | {:error, e} 214 | end 215 | end 216 | 217 | ###### Conversions 218 | 219 | ### Tree 220 | 221 | defp convert_tree(tree, project) do 222 | tree["root"] 223 | |> get_node(tree) 224 | |> convert_node(tree, project) 225 | end 226 | 227 | ### Composites 228 | 229 | defp convert_node(%{"name" => "sequence"} = node, tree, project) do 230 | children = 231 | node["children"] 232 | |> Enum.map(fn node_id -> 233 | node_id 234 | |> get_node(tree) 235 | |> convert_node(tree, project) 236 | end) 237 | 238 | Node.sequence(children) 239 | end 240 | 241 | defp convert_node(%{"name" => "select"} = node, tree, project) do 242 | children = 243 | node["children"] 244 | |> Enum.map(fn node_id -> 245 | node_id 246 | |> get_node(tree) 247 | |> convert_node(tree, project) 248 | end) 249 | 250 | Node.select(children) 251 | end 252 | 253 | defp convert_node(%{"name" => "random"} = node, tree, project) do 254 | children = 255 | node["children"] 256 | |> Enum.map(fn node_id -> 257 | node_id 258 | |> get_node(tree) 259 | |> convert_node(tree, project) 260 | end) 261 | 262 | Node.random(children) 263 | end 264 | 265 | defp convert_node(%{"name" => "random_weighted"} = node, tree, project) do 266 | children = 267 | node["children"] 268 | |> Enum.map(fn node_id -> 269 | child = get_node(node_id, tree) 270 | 271 | with %{"weight" => weight_val} <- get_properties(child, tree["properties"]), 272 | {:ok, weight} when weight_val > 0 <- ensure_int(weight_val) do 273 | {convert_node(child, tree, project), weight} 274 | else 275 | e -> 276 | raise "All children nodes of a Random weighted node must have a \"weight\" proprty as an integer greater than 0, got: #{ 277 | inspect(child, pretty: true) 278 | } 279 | specific error: #{inspect(e, pretty: true)}" 280 | end 281 | end) 282 | 283 | Node.random_weighted(children) 284 | end 285 | 286 | ### Decorators 287 | 288 | defp convert_node(%{"name" => "repeat_until_succeed"} = node, tree, project) do 289 | child = 290 | node["child"] 291 | |> get_node(tree) 292 | |> convert_node(tree, project) 293 | 294 | Node.repeat_until_succeed(child) 295 | end 296 | 297 | defp convert_node(%{"name" => "repeat_until_fail"} = node, tree, project) do 298 | child = 299 | node["child"] 300 | |> get_node(tree) 301 | |> convert_node(tree, project) 302 | 303 | Node.repeat_until_fail(child) 304 | end 305 | 306 | defp convert_node(%{"name" => "negate"} = node, tree, project) do 307 | child = 308 | node["child"] 309 | |> get_node(tree) 310 | |> convert_node(tree, project) 311 | 312 | Node.negate(child) 313 | end 314 | 315 | defp convert_node(%{"name" => "always_fail"} = node, tree, project) do 316 | child = 317 | node["child"] 318 | |> get_node(tree) 319 | |> convert_node(tree, project) 320 | 321 | Node.always_fail(child) 322 | end 323 | 324 | defp convert_node(%{"name" => "always_succeed"} = node, tree, project) do 325 | child = 326 | node["child"] 327 | |> get_node(tree) 328 | |> convert_node(tree, project) 329 | 330 | Node.always_succeed(child) 331 | end 332 | 333 | defp convert_node(%{"name" => "repeat_n"} = node, tree, project) do 334 | child = 335 | node["child"] 336 | |> get_node(tree) 337 | |> convert_node(tree, project) 338 | 339 | with %{"n" => n_val} <- get_properties(node, tree["properties"]), 340 | {:ok, n} when n > 1 <- ensure_int(n_val) do 341 | Node.repeat_n(n, child) 342 | else 343 | e -> 344 | raise "Repeater nodes must have a `n` integer property greater than 1, got: #{ 345 | inspect(node["properties"], pretty: true) 346 | } 347 | , specific error: #{inspect(e, pretty: true)}" 348 | end 349 | end 350 | 351 | ### Actions 352 | 353 | defp convert_node(%{"name" => "runner"} = node, tree, project) do 354 | case parse_action_syntax(node["title"], project["module_base"], tree["properties"]) do 355 | {:ok, {mod, function, args}} -> 356 | action(mod, function, args) 357 | 358 | {:error, e} -> 359 | raise "Unknown error parsing \"#{node["title"]}\", error: #{inspect(e, pretty: true)}" 360 | end 361 | end 362 | 363 | defp convert_node(%{"name" => "error"} = node, tree, _project) do 364 | args = extract_args!(node["title"], tree["properties"]) 365 | 366 | action(BotArmy.Actions, :error, args) 367 | end 368 | 369 | defp convert_node(%{"name" => "wait"} = node, tree, _project) do 370 | args = extract_args!(node["title"], tree["properties"]) 371 | 372 | unless match?([n | _] when n > 0, args), 373 | do: 374 | raise( 375 | "Wait nodes must have a \"seconds\" property greater than or equal to 0, or two integers like `wait(1, 10)`; got #{ 376 | inspect(args) 377 | }" 378 | ) 379 | 380 | action(BotArmy.Actions, :wait, args) 381 | end 382 | 383 | defp convert_node(%{"name" => "log"} = node, tree, _project) do 384 | args = extract_args!(node["title"], tree["properties"]) 385 | action(BotArmy.Actions, :log, args) 386 | end 387 | 388 | defp convert_node(%{"name" => "succeed_rate"} = node, tree, _project) do 389 | args = extract_args!(node["title"], tree["properties"]) 390 | 391 | unless match?([i] when i > 0 and i < 1, args), 392 | do: 393 | raise( 394 | "Succeed Rate nodes must have a \"rate\" argument between 0 and 1 like `succeed_rate(0.75); got #{ 395 | inspect(args) 396 | }" 397 | ) 398 | 399 | action(BotArmy.Actions, :succeed_rate, args) 400 | end 401 | 402 | defp convert_node(%{"name" => "done"}, _tree, _project) do 403 | action(BotArmy.Actions, :done, []) 404 | end 405 | 406 | defp convert_node(node, tree, project) do 407 | # might be a tree, check if the name is one of the tree ids, or might be a custom 408 | # action node, check if the format is correct 409 | # 410 | case get_tree(node["name"], project) do 411 | target_tree when not is_nil(target_tree) -> 412 | node_props = get_properties(node, tree["properties"]) 413 | 414 | target_tree 415 | |> Map.update!("properties", &Map.merge(&1, node_props)) 416 | |> convert_tree(project) 417 | 418 | nil -> 419 | case parse_action_syntax(node["title"], project["module_base"], tree["properties"]) do 420 | {:ok, {mod, function, args}} -> 421 | action(mod, function, args) 422 | 423 | {:error, _} -> 424 | raise "Unknown node type: \"#{inspect(node, pretty: true)}\"" 425 | end 426 | end 427 | end 428 | end 429 | -------------------------------------------------------------------------------- /lib/ets_metrics.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.EtsMetrics do 11 | @moduledoc """ 12 | Stores information during the but run for metrics gathering. 13 | """ 14 | 15 | use GenServer 16 | require Logger 17 | 18 | defstruct n: nil, start_time: nil, actions: %{} 19 | 20 | def start_link(_) do 21 | GenServer.start_link(__MODULE__, %__MODULE__{}, name: __MODULE__) 22 | end 23 | 24 | def run(count) when is_integer(count) do 25 | GenServer.cast(__MODULE__, {:run, count}) 26 | end 27 | 28 | # ---- 29 | 30 | def init(state) do 31 | :metrics = :ets.new(:metrics, [:set, :protected, :named_table, read_concurrency: true]) 32 | 33 | {:ok, state} 34 | end 35 | 36 | def handle_cast({:run, count}, state) do 37 | run_state = %__MODULE__{ 38 | n: count, 39 | start_time: Timex.now(), 40 | actions: %{} 41 | } 42 | 43 | true = :ets.insert(:metrics, {"metrics", run_state}) 44 | 45 | {:noreply, state} 46 | end 47 | 48 | def handle_info({:action, module, action, duration, outcome}, state) 49 | when is_atom(module) and is_atom(action) and is_integer(duration) and duration >= 0 do 50 | success = if outcome == :succeed, do: 1, else: 0 51 | error = if outcome == :error, do: 1, else: 0 52 | 53 | try do 54 | case :ets.lookup(:metrics, "metrics") do 55 | [{"metrics", metrics}] -> 56 | new_actions = 57 | Map.update( 58 | metrics.actions, 59 | "#{module |> Module.split() |> List.last()}.#{action}", 60 | %{runs: 1, avg_duration: duration, success_count: success, error_count: error}, 61 | fn %{ 62 | runs: runs, 63 | avg_duration: avg_duration, 64 | success_count: success_count, 65 | error_count: error_count 66 | } -> 67 | %{ 68 | runs: runs + 1, 69 | avg_duration: running_avg(avg_duration, duration, runs + 1), 70 | success_count: success_count + success, 71 | error_count: error_count + error 72 | } 73 | end 74 | ) 75 | 76 | new_metrics = Map.put(metrics, :actions, new_actions) 77 | 78 | true = :ets.insert(:metrics, {"metrics", new_metrics}) 79 | 80 | _ -> 81 | nil 82 | end 83 | rescue 84 | e in ArgumentError -> 85 | Logger.error("ArgumentError processing metrics: #{inspect(e)}") 86 | 87 | e -> 88 | Logger.error("Error processing metrics: #{inspect(e)}") 89 | end 90 | 91 | {:noreply, state} 92 | end 93 | 94 | def handle_info(m, state) do 95 | IO.puts("Ignoring unknown message #{inspect(m)}") 96 | {:noreply, state} 97 | end 98 | 99 | # ------- 100 | 101 | # make sure to call with the new count, in other words, `prev_count + 1` 102 | defp running_avg(prev_avg, current_val, new_count) do 103 | prev_avg + (current_val - prev_avg) / new_count 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/integration_test.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.IntegrationTest do 11 | @moduledoc """ 12 | Adds macros to assist in running bot trees in ExUnit. 13 | 14 | Create a test file as per the normal ExUnit process, for example, in 15 | `test/my_project_test.exs`: 16 | 17 | defmodule MyProjectTest do 18 | @moduledoc false 19 | 20 | # This will set up ExUnit.Case, import `action` from BotArmy.Actions and 21 | # alias BehaviorTree.Node. You can pass `async: true` just like with `use 22 | # ExUnit.Case` if you want to run this test file in parallel to other async 23 | # files (be careful of tests that mutate a global state!). 24 | use BotArmy.IntegrationTest, async: true 25 | 26 | alias MyProject.Actions.Sample 27 | 28 | # use this if you want a log file (applies to all tests in module) 29 | log_to_file() 30 | 31 | # use this if you use a custom bot module (applies to all tests in module) 32 | use_bot_module(MyProject.CustomBot) 33 | 34 | # normal ExUnit setup how ever you need 35 | setup do 36 | %{magic_number: 9} 37 | end 38 | 39 | # you can also setup/cleanup via a tree similar to using `test_tree` 40 | pre_all_tree "pre all" do 41 | Node.sequence([action(BotArmy.Actions, :log, ["Pre all ..."])]) 42 | end 43 | 44 | # Run test trees with `test_tree`, which works very much like ExUnit's 45 | # `test`, except it will run the bot and pass or fail based on its outcome. 46 | # 47 | # Setting the `:verbose` tag will show all of the bot's logs for that test, 48 | # otherwise only errors will show. 49 | 50 | @tag :verbose 51 | test_tree "validate target number", context do 52 | Node.select([ 53 | action(Sample, :validate_number, [context.magic_number]), 54 | action(BotArmy.Actions, :error, [context.magic_number <> " is an invalid number"]) 55 | ]) 56 | end 57 | 58 | Note, if a tree takes longer than 1 minute to run, it will fail the test. You can 59 | set `@moduletag timeout: n` (or `@tag timeout: n` per test) to raise this limit. 60 | """ 61 | 62 | require Logger 63 | 64 | @timeout 60_000 65 | 66 | defmacro __using__(opts) do 67 | quote do 68 | use ExUnit.Case, unquote(opts) 69 | import BotArmy.IntegrationTest 70 | import BotArmy.Actions, only: [action: 2, action: 3] 71 | alias BehaviorTree.Node 72 | 73 | @timeout 60_000 74 | 75 | setup :configure_logger 76 | end 77 | end 78 | 79 | @doc """ 80 | Include `log_to_file()` if you want the full logs to be saved to a file. You can 81 | pass a path to the file you want to log to, or it defaults to `bot_run.log`. 82 | """ 83 | defmacro log_to_file(path \\ "bot_run.log") do 84 | quote do 85 | setup_all do 86 | setup_log_to_file(unquote(path)) 87 | :ok 88 | end 89 | end 90 | end 91 | 92 | @doc """ 93 | Specify this at the top of your module to use a specific bot module (see `BotArmy.Bot`). 94 | 95 | Defaults to `BotArmy.Bot.Default`. 96 | """ 97 | defmacro use_bot_module(mod) do 98 | quote do 99 | setup_all do: [bot_module: unquote(mod)] 100 | end 101 | end 102 | 103 | @doc """ 104 | Runs a tree before all the tests in the module. 105 | 106 | The body must return a tree. 107 | """ 108 | defmacro pre_all_tree(message, context \\ quote(do: _), do: contents) do 109 | # don't show any output for setup trees 110 | configure_logger(%{}) 111 | 112 | quote bind_quoted: [ 113 | context: Macro.escape(context), 114 | contents: Macro.escape(contents, unquote: true), 115 | message: message 116 | ] do 117 | setup_all unquote(context) = context do 118 | :ok = 119 | run_tree( 120 | unquote(contents), 121 | to_bot_id(unquote(message)), 122 | # timeout is not in the context for setup_all 123 | context 124 | |> Map.take([:bot_module]) 125 | |> Keyword.new() 126 | |> Keyword.merge(timeout: 3 * @timeout) 127 | ) 128 | end 129 | end 130 | end 131 | 132 | @doc """ 133 | Runs a tree before each test in the module. 134 | 135 | The body either needs to return a tree or `nil`, which is useful if you want to 136 | conditionally run a tree based on a test's tag. 137 | """ 138 | defmacro pre_tree(message, context \\ quote(do: _), do: contents) do 139 | # don't show any output for setup trees 140 | configure_logger(%{}) 141 | 142 | quote bind_quoted: [ 143 | context: Macro.escape(context), 144 | contents: Macro.escape(contents, unquote: true), 145 | message: message 146 | ] do 147 | setup unquote(context) = context do 148 | case unquote(contents) do 149 | nil -> 150 | :ok 151 | 152 | tree -> 153 | :ok = 154 | run_tree( 155 | tree, 156 | to_bot_id(unquote(message)), 157 | context |> Map.take([:bot_module, :timeout]) |> Keyword.new() 158 | ) 159 | end 160 | end 161 | end 162 | end 163 | 164 | @doc """ 165 | Runs a tree after all the tests in the module. 166 | 167 | The body must return a tree. 168 | """ 169 | defmacro post_all_tree(message, context \\ quote(do: _), do: contents) do 170 | # don't show any output for setup trees 171 | configure_logger(%{}) 172 | 173 | quote bind_quoted: [ 174 | context: Macro.escape(context), 175 | contents: Macro.escape(contents, unquote: true), 176 | message: message 177 | ] do 178 | setup_all unquote(context) = context do 179 | do_post_tree(unquote(message), context, unquote(contents)) 180 | end 181 | end 182 | end 183 | 184 | @doc """ 185 | Runs a tree after each test in the module. 186 | 187 | The body either needs to return a tree or `nil`, which is useful if you want to 188 | conditionally run a tree based on a test's tag. 189 | """ 190 | defmacro post_tree(message, context \\ quote(do: _), do: contents) do 191 | # don't show any output for setup trees 192 | configure_logger(%{}) 193 | 194 | quote bind_quoted: [ 195 | context: Macro.escape(context), 196 | contents: Macro.escape(contents, unquote: true), 197 | message: message 198 | ] do 199 | setup unquote(context) = context do 200 | case unquote(contents) do 201 | nil -> :ok 202 | tree -> do_post_tree(unquote(message), context, tree) 203 | end 204 | end 205 | end 206 | end 207 | 208 | @doc false 209 | def do_post_tree(message, context, contents) do 210 | ExUnit.Callbacks.on_exit(fn -> 211 | # Need an unlinked, monitored process to properly run a bot in on_exit 212 | p = 213 | spawn(fn -> 214 | result = 215 | run_tree( 216 | contents, 217 | to_bot_id(message), 218 | context |> Map.take([:bot_module, :timeout]) |> Keyword.new() 219 | ) 220 | 221 | unless match?(:ok, result), 222 | do: raise("#{inspect(result, pretty: true)}") 223 | end) 224 | 225 | ref = Process.monitor(p) 226 | 227 | timeout = Map.get(context, :timeout, @timeout) 228 | 229 | receive do 230 | {:DOWN, ^ref, :process, ^p, :normal} -> 231 | :ok 232 | 233 | {:DOWN, ^ref, :process, ^p, e} -> 234 | raise("Error during post tree. #{inspect(e)}") 235 | after 236 | timeout -> 237 | raise "Timeout while running post tree after #{timeout}ms." 238 | end 239 | end) 240 | end 241 | 242 | @doc """ 243 | This works very much like ExUnit's `test` macro, except it runs your bot tree and 244 | fails or succeeds based on the outcome. The body must return a valid tree. You 245 | can use values from the context in the tree, just as with normal ExUnit tests. 246 | 247 | Your tree will be wrapped in order to always call the `done` action when it 248 | finishes (to prevent the default of looping through the tree from the top). 249 | """ 250 | defmacro test_tree(message, context \\ quote(do: _), contents) do 251 | contents = 252 | case contents do 253 | [do: block] -> 254 | quote do 255 | unquote(block) 256 | end 257 | 258 | _ -> 259 | quote do 260 | try(unquote(contents)) 261 | end 262 | end 263 | 264 | context = Macro.escape(context) 265 | contents = Macro.escape(contents, unquote: true) 266 | 267 | quote bind_quoted: [context: context, contents: contents, message: message] do 268 | name = ExUnit.Case.register_test(__ENV__, :test, message, []) 269 | 270 | def unquote(name)(unquote(context) = context) do 271 | opts = context |> Map.take([:bot_module, :timeout]) |> Keyword.new() 272 | 273 | tree = unquote(contents) 274 | 275 | bot_id = to_bot_id(unquote(message)) 276 | 277 | assert :ok = run_tree(tree, bot_id, opts) 278 | end 279 | end 280 | end 281 | 282 | @doc """ 283 | Runs a bot tree. Used internally, but you could call it directly if you have a 284 | reason to. 285 | 286 | Takes an `opts` param that can include `bot_module` (defaults to 287 | `BotArmy.Bot.Default`). 288 | """ 289 | def run_tree( 290 | %BehaviorTree.Node{} = tree, 291 | bot_id, 292 | opts \\ [] 293 | ) 294 | when is_binary(bot_id) do 295 | unless match?(%BehaviorTree.Node{}, tree), 296 | do: raise("the block of 'test_tree' must return a BehaviorTree.Node") 297 | 298 | bot_module = Keyword.get(opts, :bot_module, BotArmy.Bot.Default) 299 | timeout = Keyword.get(opts, :timeout, @timeout) 300 | 301 | case Code.ensure_loaded(bot_module) do 302 | {:module, ^bot_module} -> :ok 303 | e -> raise "Error finding bot module #{bot_module}. Error: #{inspect(e)}" 304 | end 305 | 306 | Logger.debug("Using bot module #{bot_module}") 307 | 308 | Process.flag(:trap_exit, true) 309 | {:ok, bot_pid} = BotArmy.Bot.start_link(bot_module, id: bot_id) 310 | :ok = BotArmy.Bot.run(bot_pid, tree_with_done(tree)) 311 | 312 | receive do 313 | {:EXIT, ^bot_pid, :shutdown} -> :ok 314 | {:EXIT, ^bot_pid, {:error, err}} -> {:error, err} 315 | {:EXIT, ^bot_pid, other} -> {:error, other} 316 | after 317 | timeout -> {:error, "Timeout while running test after #{timeout}ms"} 318 | end 319 | end 320 | 321 | @doc false 322 | def tree_with_done(tree), 323 | do: 324 | BehaviorTree.Node.sequence([ 325 | BehaviorTree.Node.select([ 326 | tree, 327 | BotArmy.Actions.action(BotArmy.Actions, :error, [:tree_failed]) 328 | ]), 329 | BotArmy.Actions.action(BotArmy.Actions, :done) 330 | ]) 331 | 332 | @doc false 333 | def setup_log_to_file(path) when is_binary(path) do 334 | metadata = [ 335 | :bot_id, 336 | :bot_run_id, 337 | :action, 338 | :outcome, 339 | :error, 340 | :duration, 341 | :uptime, 342 | :bot_pid, 343 | :session_id, 344 | :bot_count, 345 | :custom 346 | ] 347 | 348 | Logger.add_backend({LoggerFileBackend, :bot_log}) 349 | 350 | Logger.configure_backend({LoggerFileBackend, :bot_log}, 351 | path: path, 352 | level: :debug, 353 | metadata: metadata 354 | ) 355 | 356 | :ok 357 | end 358 | 359 | @doc false 360 | def configure_logger(context) do 361 | verbose = Map.get(context, :verbose) 362 | 363 | metadata = [ 364 | :outcome, 365 | :action, 366 | :error, 367 | :duration, 368 | :bot_id, 369 | :bot_pid, 370 | :session_id, 371 | :custom 372 | ] 373 | 374 | backend_configuration = 375 | if verbose do 376 | [level: :debug, metadata: metadata] 377 | else 378 | [level: :error, metadata: [:outcome, :action, :bot_id]] 379 | end 380 | 381 | Logger.configure_backend(:console, backend_configuration) 382 | 383 | :ok 384 | end 385 | 386 | @doc false 387 | def to_bot_id(message) do 388 | message 389 | |> String.replace(~r/\s/, "_") 390 | |> String.replace(~r/[^a-zA-Z0-9_]/, "_") 391 | end 392 | end 393 | -------------------------------------------------------------------------------- /lib/load_test.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.LoadTest do 11 | @moduledoc """ 12 | Manages a load test run. 13 | 14 | Don't use this directly, call from `mix bots.load_test`. See the documentation for the 15 | available params. 16 | 17 | This will start up the target number of bots. If bots die off, this will restart 18 | them in batches to return to the target number. 19 | 20 | Bots run until calling `stop`. 21 | """ 22 | 23 | require Logger 24 | use GenServer 25 | alias BotArmy.{Bot, EtsMetrics, Actions} 26 | 27 | @default_bot_count 1 28 | 29 | def start_link(opts \\ []), 30 | do: GenServer.start_link(__MODULE__, nil, Keyword.merge(opts, name: __MODULE__)) 31 | 32 | def stop() do 33 | GenServer.stop(__MODULE__, :normal) 34 | end 35 | 36 | @doc """ 37 | Starts up the bots. 38 | 39 | Opts map: 40 | 41 | * `n` - [optional] the number of bots to start up (defaults to 1) 42 | * `tree` - [required] the behavior tree for the bots to use 43 | * `bot` - [optional] a custom callback module implementing `BotArmy.Bot`, otherwise 44 | uses `BotArmy.Bot.Default` 45 | 46 | Note that you cannot call this if bots are already running (call `BotArmy.LoadTest.stop` 47 | first). 48 | 49 | """ 50 | def run(%{tree: %BehaviorTree.Node{}, bot: _} = opts), 51 | do: GenServer.cast(__MODULE__, {:run, opts}) 52 | 53 | @doc """ 54 | Run just one bot and stop when finished. Useful for testing a bot out, or running 55 | a bot as a "task." 56 | 57 | Opts map: 58 | 59 | * `tree` - [required] the tree defining the work to be done. 60 | * `bot` - [optional] a custom callback module implementing `BotArmy.Bot`, otherwise 61 | uses `BotArmy.Bot.Default` 62 | 63 | This wraps the provided tree so that it either errors if it fails, or performs 64 | `BotArmy.Actions.done` if it succeeds. This guarantees the tree won't run more 65 | than once (unless you intentionally create a loop using one of the `repeat` nodes). 66 | 67 | """ 68 | def one_off(%{tree: %BehaviorTree.Node{}} = opts), 69 | do: GenServer.cast(__MODULE__, {:one_off, opts}) 70 | 71 | def get_bot_count(), do: GenServer.call(__MODULE__, :get_bot_count) 72 | 73 | # ----------------Implementation------------------------ 74 | 75 | def init(_state) do 76 | {:ok, 77 | %{ 78 | bot_count: 0, 79 | target_bot_count: 0, 80 | last_id: 0, 81 | start_time: System.monotonic_time(:millisecond), 82 | tree: nil, 83 | bot: nil 84 | }} 85 | end 86 | 87 | def handle_cast({:run, _opts}, %{bot_count: bot_count} = state) when bot_count > 0 do 88 | IO.puts("Warning, you must stop the run first, before trying to start new bots") 89 | {:noreply, state} 90 | end 91 | 92 | def handle_cast({:run, opts}, state) do 93 | tree = Map.get(opts, :tree) || raise "No tree supplied" 94 | bot = Map.get(opts, :bot, BotArmy.Bot.Default) 95 | target_count = Map.get(opts, :n) || @default_bot_count 96 | 97 | Logger.warn("Starting up #{target_count} bots...") 98 | 99 | EtsMetrics.run(target_count) 100 | 101 | Enum.each( 102 | 1..target_count, 103 | fn i -> 104 | {:ok, bot_pid} = start_bot(state.last_id + i, bot) 105 | 106 | Process.monitor(bot_pid) 107 | Bot.run(bot_pid, tree) 108 | 109 | # "preflighting" the first bot seems to "warm up" httpoison and prevent time 110 | # outs ¯\_(ツ)_/¯ 111 | if i == 1, do: Process.sleep(1000) 112 | end 113 | ) 114 | 115 | new_state = %{ 116 | state 117 | | bot_count: target_count, 118 | target_bot_count: target_count, 119 | last_id: state.last_id + target_count, 120 | start_time: System.monotonic_time(:millisecond), 121 | tree: tree, 122 | bot: bot 123 | } 124 | 125 | # TODO this locks in the tree that `run` was called with (restarting dead bots 126 | # will use this tree) - might be nice to have more flexability here 127 | 128 | :timer.send_interval(5000, :check_bot_population) 129 | 130 | {:noreply, new_state} 131 | end 132 | 133 | def handle_cast({:one_off, _opts}, %{bot_count: bot_count} = state) when bot_count > 0 do 134 | IO.puts("Warning, a bot is currently running, you must stop the run first, then try again") 135 | 136 | {:noreply, state} 137 | end 138 | 139 | def handle_cast({:one_off, opts}, state) do 140 | Logger.warn("Starting a new one-off run") 141 | 142 | tree = Map.get(opts, :tree) || raise "No tree supplied" 143 | bot = Map.get(opts, :bot, BotArm.Bot.Default) 144 | 145 | tree_with_done = 146 | BehaviorTree.Node.sequence([ 147 | BehaviorTree.Node.select([ 148 | tree, 149 | Actions.action(Actions, :error, ["Error: tree exited with `:fail`"]) 150 | ]), 151 | Actions.action(Actions, :done) 152 | ]) 153 | 154 | {:ok, bot_pid} = start_bot(:one_off, bot) 155 | Process.monitor(bot_pid) 156 | Bot.run(bot_pid, tree_with_done) 157 | 158 | new_state = %{ 159 | state 160 | | bot_count: 1, 161 | start_time: System.monotonic_time(:millisecond), 162 | tree: tree, 163 | bot: bot 164 | } 165 | 166 | {:noreply, new_state} 167 | end 168 | 169 | def handle_call(:get_bot_count, _from, state) do 170 | {:reply, state.bot_count, state} 171 | end 172 | 173 | def handle_info(:check_bot_population, state) do 174 | # credo:disable-for-next-line 175 | IO.inspect( 176 | state.bot_count, 177 | label: :bot_count 178 | ) 179 | 180 | # credo:disable-for-next-line 181 | IO.inspect( 182 | (System.monotonic_time(:millisecond) - state.start_time) 183 | |> Timex.Duration.from_milliseconds() 184 | |> Timex.format_duration(:humanized) 185 | ) 186 | 187 | Logger.info( 188 | "", 189 | bot_count: state.bot_count, 190 | uptime: 191 | (System.monotonic_time(:millisecond) - state.start_time) 192 | |> Timex.Duration.from_milliseconds() 193 | |> Timex.format_duration(:humanized) 194 | ) 195 | 196 | if state.bot_count < state.target_bot_count do 197 | Logger.warn( 198 | "Bot population has waned, starting up more bots...", 199 | bot_count: state.bot_count 200 | ) 201 | 202 | # add more bots in batches 203 | bots_to_start = min(50, state.target_bot_count - state.bot_count) 204 | 205 | Enum.each( 206 | 1..bots_to_start, 207 | fn i -> 208 | {:ok, bot_pid} = start_bot(state.last_id + i, Map.get(state, :bot)) 209 | 210 | Process.monitor(bot_pid) 211 | Bot.run(bot_pid, state.tree) 212 | end 213 | ) 214 | 215 | new_state = %{ 216 | state 217 | | bot_count: state.bot_count + bots_to_start, 218 | last_id: state.last_id + bots_to_start 219 | } 220 | 221 | {:noreply, new_state} 222 | else 223 | {:noreply, state} 224 | end 225 | end 226 | 227 | def handle_info({:DOWN, _ref, :process, _object, :shutdown}, state) do 228 | Logger.warn("Bot finished work.", bot_count: state.bot_count - 1) 229 | 230 | if state.bot_count - 1 == 0, 231 | do: 232 | Logger.warn( 233 | "All bots have finished", 234 | uptime: 235 | (System.monotonic_time(:millisecond) - state.start_time) 236 | |> Timex.Duration.from_milliseconds() 237 | |> Timex.format_duration(:humanized) 238 | ) 239 | 240 | {:noreply, %{state | bot_count: state.bot_count - 1}} 241 | end 242 | 243 | def handle_info({:DOWN, _ref, :process, _object, reason}, state) do 244 | Logger.error("Bot died!", error: inspect(reason), bot_count: state.bot_count - 1) 245 | 246 | {:noreply, %{state | bot_count: state.bot_count - 1}} 247 | end 248 | 249 | defp start_bot(id, bot_callback_module) do 250 | DynamicSupervisor.start_child( 251 | BotSupervisor, 252 | {bot_callback_module, [id: id]} 253 | ) 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/log_formatters/json_log_formatter.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.LogFormatters.JSONLogFormatter do 11 | @moduledoc """ 12 | Use this logger formatter for nice JSON formatted logs with information useful for 13 | syncing timing. 14 | 15 | Based on https://github.com/soundtrackyourbrand/exlogger, but uses unix epoch 16 | timestamps and shows errors. 17 | """ 18 | 19 | @spec format( 20 | Logger.level(), 21 | Logger.message(), 22 | Logger.Formatter.time(), 23 | Logger.Formatter.keyword() 24 | ) :: IO.chardata() 25 | def format(level, message, timestamp, metadata) do 26 | {{y, mo, d}, {h, m, s, mm}} = timestamp 27 | 28 | date_time = %DateTime{ 29 | calendar: Calendar.ISO, 30 | year: y, 31 | month: mo, 32 | day: d, 33 | zone_abbr: "UTC", 34 | hour: h, 35 | minute: m, 36 | second: s, 37 | microsecond: {mm * 1000, 6}, 38 | utc_offset: 0, 39 | std_offset: 0, 40 | time_zone: "Etc/UTC" 41 | } 42 | 43 | unix_timestamp = DateTime.to_unix(date_time, :millisecond) 44 | 45 | run_id = BotArmy.SharedData.get(:bot_run_id) 46 | 47 | bot_run_log_data = 48 | if run_id do 49 | %{"bot_run_id" => run_id} 50 | else 51 | %{} 52 | end 53 | 54 | default_log_data = %{ 55 | "msg" => "#{message}", 56 | "level" => level, 57 | "timestamp" => unix_timestamp 58 | } 59 | 60 | log_data = 61 | default_log_data 62 | |> Map.merge(bot_run_log_data) 63 | |> Map.merge(Map.new(metadata)) 64 | # pids aren't encodable and we don't really need them anyway 65 | |> Map.delete(:bot_pid) 66 | 67 | "#{Jason.encode!(log_data)}\n" 68 | rescue 69 | e -> 70 | "ERROR with log #{inspect(e)}\n" <> 71 | "Raw log: #{inspect(timestamp)} #{metadata[level]} #{level} #{message}\n" 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/metrics_formatters/export.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.Metrics.Export do 11 | @moduledoc """ 12 | Formats metrics data for export (via the `/metrics` http endpoint) 13 | """ 14 | 15 | alias BotArmy.{ 16 | EtsMetrics 17 | } 18 | 19 | @derive Jason.Encoder 20 | defstruct bot_count: nil, total_error_count: nil, actions: %{} 21 | 22 | def generate_report() do 23 | [{"metrics", %EtsMetrics{actions: actions, n: n}}] = :ets.lookup(:metrics, "metrics") 24 | 25 | total_error_count = 26 | actions 27 | |> Enum.reduce(0, fn {_, %{error_count: errors}}, acc -> acc + errors end) 28 | 29 | %__MODULE__{ 30 | bot_count: n, 31 | total_error_count: total_error_count, 32 | actions: actions 33 | } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/metrics_formatters/summary_report.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.Metrics.SummaryReport do 11 | @moduledoc """ 12 | Prints out a helpful summary about a bot run 13 | """ 14 | 15 | require Logger 16 | 17 | def build_report do 18 | case :ets.lookup(:metrics, "metrics") do 19 | [{"metrics", state}] -> 20 | compile_and_print_report(state) 21 | 22 | err -> 23 | err 24 | end 25 | end 26 | 27 | defp compile_and_print_report(state) do 28 | stop_time = Timex.now() 29 | 30 | report = """ 31 | BOT RUN SUMMARY #{state.start_time} (UTC) 32 | #{state.n} bots for #{duration(state.start_time, stop_time)} 33 | ---------------------------------------- 34 | #{ 35 | Enum.reduce(state.actions, "", fn {key, %{runs: runs, avg_duration: avg_duration}}, acc -> 36 | acc <> 37 | "#{key} #{runs} times total (about #{round(runs / state.n)} times per bot) with an average duration of #{ 38 | round(avg_duration) 39 | } ms\n\n" 40 | end) 41 | } 42 | """ 43 | 44 | IO.puts("\n\n" <> report) 45 | end 46 | 47 | defp duration(start, stop) do 48 | stop 49 | |> Timex.diff(start, :duration) 50 | |> Timex.format_duration(:humanized) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/mix/tasks/bots.extract_actions.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule Mix.Tasks.Bots.ExtractActions do 11 | @moduledoc """ 12 | Generates "custom action nodes" for the [behavior tree 13 | editor](https://github.com/adobe/behavior_tree_editor). 14 | 15 | This will scan all actions defined in the supplied `actions-dir` directory to build 16 | a json representation. If you provide a `bt-json-file`, it will insert the 17 | generated nodes into the `custom_nodes` section (replacing any existing nodes!), 18 | otherwise it will print the json to screen for you to copy and paste via the 19 | "Project > Import > Nodes as JSON" menu option (this appends to existing custom 20 | nodes). 21 | 22 | Parameters: 23 | 24 | * `actions-dir` - [required] Path to the directory containing all of your actions. 25 | * `module-base` - [optional] If all of your actions start with a common prefix (Ex: 26 | `MyProject.Actions`), you can include this parameter to strip that prefix, making 27 | it easier to read the nodes in the visual editor. Be sure to include the 28 | `module_base` option in `BotArmy.BTParser.parse!/2` to ensure the stripped base 29 | gets re-appended. 30 | * `bt-json-file` - [optional] Location of behavior tree editor project file. 31 | """ 32 | 33 | use Mix.Task 34 | 35 | @shortdoc "Extract actions or behavior tree editor" 36 | 37 | def run(args) do 38 | # Needs this line to boot the actual tests application 39 | Mix.Task.run("app.start") 40 | 41 | Code.compiler_options(ignore_module_conflict: true) 42 | 43 | {flags, _, _} = 44 | OptionParser.parse(args, 45 | strict: [actions_dir: :string, module_base: :string, bt_json_file: :string] 46 | ) 47 | 48 | actions_dir = 49 | Keyword.get(flags, :actions_dir) || raise "You must specify the \"actions_dir\" parameter" 50 | 51 | module_base = 52 | flags 53 | |> Keyword.get(:module_base, "") 54 | |> String.trim_trailing(".") 55 | |> String.replace_suffix("", ".") 56 | 57 | unless File.dir?(actions_dir), do: raise("#{inspect(actions_dir)} is not a valid directory") 58 | 59 | exported_actions = 60 | actions_dir 61 | |> Path.join("/**/*.ex") 62 | |> Path.wildcard() 63 | |> Enum.flat_map(&process_file(&1, module_base)) 64 | 65 | bt_json_file = Keyword.get(flags, :bt_json_file) 66 | 67 | if bt_json_file do 68 | json = 69 | bt_json_file 70 | |> File.read!() 71 | |> Jason.decode!() 72 | 73 | json_with_custom_nodes = put_in(json, ["data", "custom_nodes"], exported_actions) 74 | 75 | File.write!(bt_json_file, Jason.encode!(json_with_custom_nodes)) 76 | 77 | IO.puts( 78 | "SUCCESS, open (or close and reopen) #{bt_json_file} in the behavior tree editor to see the custom actions." 79 | ) 80 | else 81 | inner = 82 | exported_actions 83 | |> Enum.map_join(",", &Jason.encode!/1) 84 | 85 | IO.puts("[" <> inner <> "]") 86 | IO.puts("SUCCESS, copy the above to import custom nodes") 87 | end 88 | end 89 | 90 | defp process_file(file, module_base) do 91 | [{mod, _} | _] = Code.compile_file(file) 92 | 93 | case Code.fetch_docs(mod) do 94 | {:docs_v1, _annotation1, _beam_language, _format, _module_doc, _metadata1, fn_docs} -> 95 | Enum.map(fn_docs, fn 96 | {{_kind, _function_name, _arity}, _annotation2, signature, fn_docs, _metadata2} -> 97 | signature = 98 | mod 99 | |> Module.split() 100 | |> Enum.concat(signature) 101 | |> Enum.join(".") 102 | |> String.replace_prefix(module_base, "") 103 | |> String.replace(~r/\(\s?[^,\s\)]+,?\s*/, "(") 104 | 105 | docs = 106 | (is_map(fn_docs) && Map.get(fn_docs, "en")) || 107 | raise "No docs defined for #{signature}. Please add some docs and retry" 108 | 109 | %{ 110 | version: "0.3.0", 111 | scope: "node", 112 | name: signature, 113 | category: "action", 114 | title: signature, 115 | description: docs, 116 | properties: %{} 117 | } 118 | end) 119 | 120 | e -> 121 | raise "Error processing #{file}: #{inspect(e, pretty: true)}" 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/mix/tasks/bots.load_test.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule Mix.Tasks.Bots.LoadTest do 11 | @moduledoc """ 12 | Task to run the bots. Can call with various flags. Opens an interactive window to 13 | control the bots, and prints a nice summary at the end. 14 | 15 | Supported arguments: 16 | 17 | * `n` number of bots, defaults to 10 18 | * `tree` - [required] The full name of the module defining the test 19 | tree (must be in scope). Must expose the function `tree/0`. Ex: 20 | "MyService.Workflow.Simple" 21 | * `bot` - [optional] A custom callback module implementing `BotArmy.Bot`, otherwise 22 | uses `BotArmy.Bot.Default` 23 | * `custom` - [optional] Configs for your custom domain. You must specify these in 24 | quotes as an Elixir map or keyword list (ex: --custom '[host: "dev"]'). Each 25 | key/value pair will be placed into `BotArmy.SharedData` for access in your actions, 26 | and other custom code. 27 | * `disable-log-file` - [optional] Disables file-based logging. 28 | * `format-json-logs` - [optional] BotArmy will output JSON-formatted log entries. 29 | """ 30 | use Mix.Task 31 | 32 | @shortdoc "Interactive loadtesting shell" 33 | def run(args) do 34 | Mix.Tasks.LoadTestRelease.run(args) 35 | 36 | IO.puts("Bots are running! Enter 'q' to stop them and exit") 37 | receive_command() 38 | end 39 | 40 | defp receive_command do 41 | "" 42 | |> IO.gets() 43 | |> String.trim() 44 | |> String.downcase() 45 | |> execute_command() 46 | end 47 | 48 | defp execute_command("q") do 49 | IO.puts("\nStopping bots") 50 | BotArmy.Metrics.SummaryReport.build_report() 51 | end 52 | 53 | defp execute_command(_), do: receive_command() 54 | end 55 | -------------------------------------------------------------------------------- /lib/mix/tasks/helpers.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule Mix.Tasks.Bots.Helpers do 11 | @moduledoc false 12 | 13 | alias BotArmy.SharedData 14 | 15 | @doc "Supply the provided flags, receive the bot module. Errors if it fails." 16 | def get_bot_mod(flags) do 17 | bot_string = flags |> Keyword.get(:bot) 18 | 19 | if is_nil(bot_string), 20 | do: BotArmy.Bot.Default, 21 | else: parse_module(bot_string) 22 | end 23 | 24 | @doc "Supply the provided flags, receive the tree function. Errors if it fails." 25 | def get_tree_mod(flags) do 26 | tree_mod_string = flags |> Keyword.get(:tree) 27 | 28 | if is_nil(tree_mod_string), 29 | do: raise("You must specify the module defining the tree (Ex: `--tree Test.Load`)") 30 | 31 | tree_mod = parse_module(tree_mod_string) 32 | 33 | if not function_exported?(tree_mod, :tree, 0), 34 | do: raise("Cannot find `#{tree_mod}.tree/0`. Does it exist?") 35 | 36 | tree_mod 37 | end 38 | 39 | @doc """ 40 | Saves custom config in SharedData. 41 | """ 42 | def save_custom_config(flags) do 43 | flags 44 | |> Keyword.get(:custom, "[]") 45 | |> TermParser.parse() 46 | |> case do 47 | {:ok, term} -> 48 | IO.puts("Custom config: #{inspect(term)}") 49 | 50 | Enum.each(term, fn {key, value} -> 51 | SharedData.put(key, value) 52 | end) 53 | 54 | {:error, e} -> 55 | raise "Invalid `custom` config (#{inspect(e)})" 56 | end 57 | end 58 | 59 | @doc false 60 | defp parse_module(string) when is_binary(string) do 61 | {:module, mod} = 62 | string 63 | |> String.split(".") 64 | |> Module.safe_concat() 65 | |> Code.ensure_loaded() 66 | 67 | mod 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/mix/tasks/load_test_release.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule Mix.Tasks.LoadTestRelease do 11 | @moduledoc """ 12 | Intended to be used with Distillery releases, not invoked directly, see 13 | `Mix.Tasks.LoadTest` to run locally and for docs. There is also an http route 14 | option. 15 | 16 | """ 17 | require Logger 18 | 19 | use Mix.Task 20 | alias BotArmy.LoadTest 21 | alias Mix.Tasks.Bots.Helpers 22 | alias BotArmy.LogFormatters.JSONLogFormatter 23 | 24 | def run(args) do 25 | Application.ensure_all_started(:logger) 26 | Application.ensure_all_started(:bot_army) 27 | Mix.Task.run("app.start") 28 | 29 | {flags, _, _} = OptionParser.parse(args, switches: []) 30 | 31 | {log_flags, _, _} = 32 | OptionParser.parse(args, strict: [disable_log_file: :boolean, format_json_logs: :boolean]) 33 | 34 | metadata = [ 35 | :bot_id, 36 | :bot_run_id, 37 | :action, 38 | :outcome, 39 | :error, 40 | :duration, 41 | :uptime, 42 | :bot_pid, 43 | :session_id, 44 | :bot_count 45 | ] 46 | 47 | Logger.configure(level: :debug) 48 | 49 | unless Keyword.get(log_flags, :disable_log_file) do 50 | Logger.add_backend({LoggerFileBackend, :bot_log}) 51 | 52 | Logger.configure_backend({LoggerFileBackend, :bot_log}, 53 | path: "bot_run.log", 54 | level: :debug, 55 | metadata: metadata 56 | ) 57 | end 58 | 59 | Logger.configure_backend(:console, metadata: metadata, level: :debug) 60 | 61 | if Keyword.get(log_flags, :format_json_logs) do 62 | Logger.configure_backend(:console, format: {JSONLogFormatter, :format}) 63 | end 64 | 65 | {num, _} = Integer.parse(Keyword.get(flags, :n, "10")) 66 | bot_mod = Helpers.get_bot_mod(flags) 67 | tree_mod = Helpers.get_tree_mod(flags) 68 | 69 | IO.puts("Starting bot run with #{num} bots") 70 | IO.puts("USING TREE: #{tree_mod}") 71 | IO.puts("USING BOT: #{bot_mod}") 72 | 73 | Helpers.save_custom_config(flags) 74 | 75 | LoadTest.run(%{n: num, tree: tree_mod.tree(), bot: bot_mod}) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/router.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.Router do 11 | @moduledoc """ 12 | The exposed HTTP routes for communiating with the bots. 13 | 14 | The parameters are similar to the docs in the mix tasks under `mix/tasks`. 15 | """ 16 | 17 | require Logger 18 | use Plug.Router 19 | plug(Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: Jason) 20 | alias BotArmy.Metrics.Export 21 | alias Mix.Tasks.Bots.Helpers 22 | alias BotArmy.LoadTest 23 | alias BotArmy.LogFormatters.JSONLogFormatter 24 | 25 | plug(:match) 26 | plug(:dispatch) 27 | 28 | post "/load_test/start" do 29 | %{ 30 | "n" => num, 31 | "tree" => tree, 32 | "bot" => bot, 33 | "custom" => custom, 34 | "log_options" => log_opts 35 | } = conn.body_params 36 | 37 | start_logs(log_opts) 38 | 39 | bot_mod = Helpers.get_bot_mod(bot: bot) 40 | tree_mod = Helpers.get_tree_mod(tree: tree) 41 | Helpers.save_custom_config(custom: custom) 42 | 43 | LoadTest.run(%{n: num, tree: tree_mod.tree(), bot: bot_mod}) 44 | 45 | send_resp(conn, 200, "Bots started") 46 | end 47 | 48 | delete "/load_test/stop" do 49 | LoadTest.stop() 50 | send_resp(conn, 200, "Bots stopped") 51 | end 52 | 53 | get "/metrics" do 54 | with %Export{} = report <- Export.generate_report(), 55 | {:ok, report_json} <- Jason.encode(report) do 56 | conn 57 | |> put_resp_content_type("application/json") 58 | |> send_resp(200, report_json) 59 | else 60 | e -> 61 | send_resp(conn, 500, inspect(e)) 62 | end 63 | end 64 | 65 | get "/logs" do 66 | send_file(conn, 200, "bot_run.log") 67 | end 68 | 69 | get "/health" do 70 | send_resp(conn, 200, "healthy") 71 | end 72 | 73 | defp start_logs(opts) do 74 | metadata = [ 75 | :bot_id, 76 | :bot_run_id, 77 | :action, 78 | :outcome, 79 | :error, 80 | :duration, 81 | :uptime, 82 | :bot_pid, 83 | :bot_count 84 | ] 85 | 86 | if Map.get(opts, "disable-log-file", "false") == "false" do 87 | Logger.add_backend({LoggerFileBackend, :bot_log}) 88 | 89 | Logger.configure_backend({LoggerFileBackend, :bot_log}, 90 | path: "bot_run.log", 91 | level: :debug, 92 | metadata: metadata 93 | ) 94 | end 95 | 96 | Logger.configure_backend(:console, metadata: metadata, level: :debug) 97 | 98 | if Map.get(opts, "format-json-logs", "false") == "true" do 99 | Logger.configure_backend(:console, format: {JSONLogFormatter, :format}) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/shared_data.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Adobe 2 | # All Rights Reserved. 3 | 4 | # NOTICE: Adobe permits you to use, modify, and distribute this file in 5 | # accordance with the terms of the Adobe license agreement accompanying 6 | # it. If you have received this file from a source other than Adobe, 7 | # then your use, modification, or distribution of it requires the prior 8 | # written permission of Adobe. 9 | 10 | defmodule BotArmy.SharedData do 11 | @moduledoc """ 12 | While the "context" lets you share state between actions, `SharedData` lets you 13 | share state between bots. In addition, it is a central place to hold global data, 14 | like runtime config data. 15 | 16 | This module is a simple wrapper around a basic ETS table. As noted above, the 17 | runner tasks/router will store runtime config here as well. 18 | 19 | Note that this does not supply any kind of locking mechanism, so be aware of race 20 | conditions. This is by design for two reasons. First, config is a read-only use 21 | case. Second, for data-sharing, bots represent users, which operate independently 22 | of each other in real life with async data sharing patterns (email, slack). 23 | """ 24 | 25 | use Agent 26 | @cache_name :bot_shared_data 27 | 28 | @doc false 29 | def start_link(_) do 30 | ConCache.start_link( 31 | name: @cache_name, 32 | ttl_check_interval: false, 33 | ets_options: [write_concurrency: true, read_concurrency: true] 34 | ) 35 | end 36 | 37 | @doc "Get a value by key (returns `nil` if not found)" 38 | def get(key) do 39 | ConCache.get(@cache_name, key) 40 | end 41 | 42 | @doc "Put a value by key." 43 | def put(key, value) do 44 | ConCache.put(@cache_name, key, value) 45 | end 46 | 47 | @doc "Update a value by key. `update_fn` is val -> val." 48 | def update(key, update_fn) do 49 | ConCache.update(@cache_name, key, &{:ok, update_fn.(&1)}) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/term_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule TermParser do 2 | @moduledoc """ 3 | Taken verbatim from https://gist.github.com/mmmries/b657c77845b07ee8cd34. 4 | """ 5 | def parse(str) when is_binary(str) do 6 | case str |> Code.string_to_quoted() do 7 | {:ok, terms} -> hydrate_terms(terms) 8 | {:error, err} -> {:error, err} 9 | end 10 | end 11 | 12 | defp hydrate_terms(terms) do 13 | try do 14 | {:ok, _parse(terms)} 15 | rescue 16 | e in ArgumentError -> {:error, e} 17 | end 18 | end 19 | 20 | # atomic terms 21 | defp _parse(term) when is_atom(term), do: term 22 | defp _parse(term) when is_integer(term), do: term 23 | defp _parse(term) when is_float(term), do: term 24 | defp _parse(term) when is_binary(term), do: term 25 | 26 | defp _parse([]), do: [] 27 | defp _parse([h | t]), do: [_parse(h) | _parse(t)] 28 | 29 | defp _parse({a, b}), do: {_parse(a), _parse(b)} 30 | 31 | defp _parse({:-, _place, [i]}) when is_integer(i), do: -i 32 | 33 | defp _parse({:{}, _place, terms}) do 34 | terms 35 | |> Enum.map(&_parse/1) 36 | |> List.to_tuple() 37 | end 38 | 39 | defp _parse({:%{}, _place, terms}) do 40 | for {k, v} <- terms, into: %{}, do: {_parse(k), _parse(v)} 41 | end 42 | 43 | defp _parse(e) do 44 | raise ArgumentError, message: "string contains non-literal term(s) #{inspect(e)}" 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BotArmy.MixProject do 2 | use Mix.Project 3 | 4 | def project, 5 | do: [ 6 | app: :bot_army, 7 | version: "1.0.0", 8 | description: "Testing library/runner for load and integration testing using intelligent bots", 9 | package: [ 10 | maintainers: ["Jeff Schomay"], 11 | licenses: ["MIT"], 12 | links: %{ 13 | "GitHub" => "https://github.com/adobe/bot_army", 14 | } 15 | ], 16 | source_url: "https://github.com/adobe/bot_army", 17 | homepage_url: "https://github.com/adobe/bot_army", 18 | elixir: "~> 1.6", 19 | start_permanent: Mix.env() == :prod, 20 | test_coverage: [tool: ExCoveralls], 21 | deps: deps(), 22 | docs: [main: "readme", extras: ["README.md"]], 23 | elixirc_paths: elixirc_paths(Mix.env()), 24 | preferred_cli_env: [ 25 | integration_test_local: :test 26 | ] 27 | ] 28 | 29 | def application do 30 | [ 31 | mod: {BotArmy, []}, 32 | extra_applications: [:logger] 33 | ] 34 | end 35 | 36 | defp deps, 37 | do: [ 38 | {:behavior_tree, "~> 0.3.1"}, 39 | {:credo, "~> 0.8.10", only: [:dev, :test]}, 40 | {:con_cache, "~> 0.13.1"}, 41 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 42 | {:excoveralls, "~> 0.8", only: :test}, 43 | {:jason, "~> 1.1"}, 44 | {:httpoison, "~> 1.0"}, 45 | {:logger_file_backend, "~> 0.0.10"}, 46 | {:mix_test_watch, "~> 0.9", only: :dev, runtime: false}, 47 | {:mox, "~> 0.4", only: :test}, 48 | {:plug_cowboy, "~> 2.0"}, 49 | {:poison, "~> 3.1.0"}, 50 | {:timex, "~> 3.0"} 51 | ] 52 | 53 | defp elixirc_paths(:test), do: ["test/support", "lib"] 54 | defp elixirc_paths(_), do: ["lib"] 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "behavior_tree": {:hex, :behavior_tree, "0.3.1", "8c2f556b34e75e923662b0fefe0cc5e794ef2d146524e1d2c084420b97ca27c2", [:mix], [{:ex_zipper, "~> 0.1.3", [hex: :ex_zipper, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 4 | "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, 6 | "con_cache": {:hex, :con_cache, "0.13.1", "047e097ab2a8c6876e12d0c29e29a86d487b592df97b98e3e2abedad574e215d", [:mix], [], "hexpm"}, 7 | "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"}, 9 | "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 11 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "ex_zipper": {:hex, :ex_zipper, "0.1.3", "886452d9e889c94f74048d8467bf386777d7462f270ac0ecefb2d7644a8828e7", [:mix], [{:stream_data, "~> 0.3.0", [hex: :stream_data, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "excoveralls": {:hex, :excoveralls, "0.10.0", "a4508bdd408829f38e7b2519f234b7fd5c83846099cda348efcb5291b081200c", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"}, 15 | "gettext": {:hex, :gettext, "0.16.0", "4a7e90408cef5f1bf57c5a39e2db8c372a906031cc9b1466e963101cb927dafc", [:mix], [], "hexpm"}, 16 | "hackney": {:hex, :hackney, "1.13.0", "24edc8cd2b28e1c652593833862435c80661834f6c9344e84b6a2255e7aeef03", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "httpoison": {:hex, :httpoison, "1.3.0", "edfd6ca53b57377a7a8db226bc42a2bddfeb39951c61ddfe15ec930225e6e46b", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "jason": {:hex, :jason, "1.1.1", "d3ccb840dfb06f2f90a6d335b536dd074db748b3e7f5b11ab61d239506585eb2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 20 | "logger_file_backend": {:hex, :logger_file_backend, "0.0.10", "876f9f84ae110781207c54321ffbb62bebe02946fe3c13f0d7c5f5d8ad4fa910", [:mix], [], "hexpm"}, 21 | "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 23 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 24 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 25 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 26 | "mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"}, 27 | "mox": {:hex, :mox, "0.4.0", "7f120840f7d626184a3d65de36189ca6f37d432e5d63acd80045198e4c5f7e6e", [:mix], [], "hexpm"}, 28 | "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"}, 29 | "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, 30 | "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 31 | "plug_cowboy": {:hex, :plug_cowboy, "2.0.1", "d798f8ee5acc86b7d42dbe4450b8b0dadf665ce588236eb0a751a132417a980e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 32 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 33 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 34 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, 35 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 36 | "stream_data": {:hex, :stream_data, "0.3.0", "cbfc8e3212f64683615657ea27804126a42ded634adfdfee258bf087ee605d46", [:mix], [], "hexpm"}, 37 | "timex": {:hex, :timex, "3.4.1", "e63fc1a37453035e534c3febfe9b6b9e18583ec7b37fd9c390efdef97397d70b", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, 38 | "tzdata": {:hex, :tzdata, "0.5.19", "7962a3997bf06303b7d1772988ede22260f3dae1bf897408ebdac2b4435f4e6a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 39 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, 40 | } 41 | -------------------------------------------------------------------------------- /test/base_name_sample.json: -------------------------------------------------------------------------------- 1 | {"name":"base_name_sample","description":"","data":{"version":"0.3.0","scope":"project","selectedTree":"b709614c-b3b2-403f-8985-bb934efa904b","trees":[{"version":"0.3.0","scope":"tree","id":"b709614c-b3b2-403f-8985-bb934efa904b","title":"Root","description":"The root of this tree. The title of this node sets the title of the tree. You must have one tree called \"Root\". You can set tree-wide properties on this node and reference them in other places with the following template syntax: `{{key_name}}`.","root":"b818c29a-4426-4ac4-9e9d-2e365b6870d1","properties":{},"nodes":{"63756788-9049-48ab-afef-948bf34a69b3":{"id":"63756788-9049-48ab-afef-948bf34a69b3","name":"runner","title":"A.test()","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":408,"y":-180}},"b818c29a-4426-4ac4-9e9d-2e365b6870d1":{"id":"b818c29a-4426-4ac4-9e9d-2e365b6870d1","name":"select","title":"Select","description":"Takes multiple children and runs them from top to bottom (or left to right), succeeding when any one succeeds. Fails if all fail.","properties":{},"display":{"x":204,"y":0},"children":["63756788-9049-48ab-afef-948bf34a69b3","42f8017d-ce71-4096-ad4e-661ef7cced28","7d441cbc-e05b-45b9-89ab-a86a3fe47e2f","ee966fbf-5b06-40a9-82b4-185f0dc3af96","b3d485e9-c64c-49e6-8d86-062e8436a077"]},"42f8017d-ce71-4096-ad4e-661ef7cced28":{"id":"42f8017d-ce71-4096-ad4e-661ef7cced28","name":"runner","title":"A.Nested.test()","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":408,"y":-96}},"7d441cbc-e05b-45b9-89ab-a86a3fe47e2f":{"id":"7d441cbc-e05b-45b9-89ab-a86a3fe47e2f","name":"runner","title":"B.test()","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":408,"y":0}},"ee966fbf-5b06-40a9-82b4-185f0dc3af96":{"id":"ee966fbf-5b06-40a9-82b4-185f0dc3af96","name":"Module.Base.Custom.fn()","title":"Custom.test()","description":"A custom node","properties":{},"display":{"x":408,"y":84}},"b3d485e9-c64c-49e6-8d86-062e8436a077":{"id":"b3d485e9-c64c-49e6-8d86-062e8436a077","name":"wait","title":"wait(1)","description":"\"Pauses\" the bot for the specified number of seconds. You can specify two numbers (like `wait(1,10)`) to wait a random number of seconds between those numbers.","properties":{},"display":{"x":408,"y":168}}},"display":{"camera_x":420,"camera_y":366,"camera_z":1,"x":0,"y":0}}],"custom_nodes":[{"version":"0.3.0","scope":"node","name":"Module.Base.Custom.fn()","category":"action","title":"Module.Base.Custom.fn()","description":"A custom node","properties":{}}]}} -------------------------------------------------------------------------------- /test/bot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BotArmy.BotTest do 2 | use ExUnit.Case 3 | 4 | import BotArmy.Actions, only: [action: 3] 5 | alias BotArmy.Actions 6 | alias BotArmy.Bot 7 | alias BehaviorTree.Node 8 | 9 | describe "handling errors" do 10 | test "dies with the proper reason" do 11 | tree = 12 | Node.sequence([ 13 | action(Actions, :error, [:error_reason]) 14 | ]) 15 | 16 | {:ok, bot_pid} = Bot.Default.start_link(id: :test_bot) 17 | 18 | Process.flag(:trap_exit, true) 19 | ref = Process.monitor(bot_pid) 20 | 21 | :ok = Bot.run(bot_pid, tree) 22 | 23 | assert_receive {:DOWN, ^ref, :process, ^bot_pid, {:error, :error_reason}}, 500 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/bt_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule A do 2 | @moduledoc false 3 | 4 | def simple(_context), do: :ok 5 | 6 | def one_arg(_context, _x), do: :ok 7 | 8 | def with_args(_context, _num, _string, _opts \\ []), do: :ok 9 | end 10 | 11 | defmodule A.Nested do 12 | @moduledoc false 13 | 14 | def test(_context), do: :ok 15 | end 16 | 17 | defmodule B do 18 | @moduledoc false 19 | 20 | def test(_context, _a, _b \\ 22, _c \\ 33), do: :ok 21 | end 22 | 23 | defmodule Module.Base.A do 24 | @moduledoc false 25 | def test(_context), do: :ok 26 | end 27 | 28 | defmodule Module.Base.A.Nested do 29 | @moduledoc false 30 | def test(_context), do: :ok 31 | end 32 | 33 | defmodule Module.Base.B do 34 | @moduledoc false 35 | def test(_context), do: :ok 36 | end 37 | 38 | defmodule Module.Base.Custom do 39 | @moduledoc false 40 | def test(_context), do: :ok 41 | end 42 | 43 | defmodule BotArmy.BTParserTest do 44 | @moduledoc false 45 | 46 | use ExUnit.Case 47 | 48 | alias BehaviorTree.Node 49 | alias BotArmy.BTParser 50 | import BotArmy.Actions, only: [action: 2, action: 3] 51 | 52 | describe "BTParser" do 53 | test "parse/1" do 54 | path = "test/bt_sample.json" 55 | parsed = BTParser.parse!(path, "Root", context: %{x: "from context"}) 56 | assert parsed == expected_parsed_tree() 57 | end 58 | 59 | test "specifying a different tree" do 60 | path = "test/bt_sample.json" 61 | parsed = BTParser.parse!(path, "Tree B", context: %{x: "from context"}) 62 | 63 | assert parsed == 64 | Node.sequence([ 65 | action(B, :test, [1, 2, 3]), 66 | action(B, :test, [999]), 67 | action(B, :test, [999, 999, 999]) 68 | ]) 69 | end 70 | 71 | test "using module_base" do 72 | expected = 73 | Node.select([ 74 | action(Module.Base.A, :test, []), 75 | action(Module.Base.A.Nested, :test, []), 76 | action(Module.Base.B, :test, []), 77 | action(Module.Base.Custom, :test, []), 78 | action(BotArmy.Actions, :wait, [1]) 79 | ]) 80 | 81 | parsed = BTParser.parse!("test/base_name_sample.json", "Root", module_base: "Module.Base") 82 | assert parsed == expected 83 | end 84 | end 85 | 86 | defp expected_parsed_tree, 87 | do: 88 | Node.sequence([ 89 | Node.select([ 90 | action(A, :simple, []), 91 | action(A, :one_arg, [1]), 92 | action(A.Nested, :test), 93 | action(A, :simple, []), 94 | action(A, :with_args, [1, "hi", [name: false]]), 95 | action(A, :with_args, [2, "bye"]), 96 | action(BotArmy.Actions, :error, ["Oops"]) 97 | ]), 98 | Node.repeat_until_succeed(Node.negate(action(A, :simple))), 99 | Node.repeat_until_fail( 100 | Node.sequence([ 101 | action(A, :simple), 102 | action(BotArmy.Actions, :succeed_rate, [0.5]) 103 | ]) 104 | ), 105 | Node.repeat_n(5, action(A, :with_args, [2, "from context"])), 106 | action(BotArmy.Actions, :wait, [1]), 107 | action(BotArmy.Actions, :wait, [1, 10]), 108 | Node.select([ 109 | Node.random([ 110 | Node.always_fail(action(BotArmy.Actions, :log, ["This fails no matter what"])), 111 | Node.always_succeed(action(BotArmy.Actions, :log, ["This succeeds no matter what"])) 112 | ]), 113 | Node.random_weighted([ 114 | {action(A, :simple, []), 1}, 115 | {action(A, :with_args, [9, "ok"]), 1}, 116 | {action(A.Nested, :test, []), 10} 117 | ]) 118 | ]), 119 | Node.sequence([ 120 | action(B, :test, [1, 2, 3]), 121 | action(B, :test, [1]), 122 | action(B, :test, [1, 999, 999]) 123 | ]), 124 | Node.sequence([ 125 | action(B, :test, [1, 2, 3]), 126 | action(B, :test, [111]), 127 | action(B, :test, [111, 222, 333]) 128 | ]), 129 | action(BotArmy.Actions, :done, []) 130 | ]) 131 | end 132 | -------------------------------------------------------------------------------- /test/bt_sample.json: -------------------------------------------------------------------------------- 1 | {"name":"bt_sample","description":"","data":{"version":"0.3.0","scope":"project","selectedTree":"35236aa4-bbc6-4a92-832c-7609cd6e6476","trees":[{"version":"0.3.0","scope":"tree","id":"35236aa4-bbc6-4a92-832c-7609cd6e6476","title":"Root","description":"The root of this tree. You can set tree-wide properties on this node and use them in an action's \"args\" property with this notation: `{{key_name}}`.","root":"bd573adb-5b96-437a-8ac5-fbf1d318de27","properties":{"c":333,"weight_a":1,"repeat_count":5},"nodes":{"bd573adb-5b96-437a-8ac5-fbf1d318de27":{"id":"bd573adb-5b96-437a-8ac5-fbf1d318de27","name":"sequence","title":"Sequence","description":"Takes multiple children and runs them from top to bottom (or left to right). If any fail, this node fails, if all succeed, this node succeeds.","properties":{},"display":{"x":-228,"y":-48},"children":["9f284429-911b-40ee-8bf1-2eef0323ff10","7b26689c-e48d-4d79-85d6-93131cb70449","e5c549aa-3119-487c-800f-8115bc2b849d","c46d7062-b97a-4684-aa1b-324b42d05287","5e0ef8dc-e5b6-4ef6-8acc-4963c8068546","d8f44515-dffa-413c-aaf3-fd542734d46e","83f62ab8-a702-4448-be76-215380b81af2","aa3dda4a-4db4-484e-b355-728b3d4e8675","b4766a8c-badc-49f9-8a1d-948d175888b0","d43977fb-1601-48e3-905e-a9443bb60240"]},"9f284429-911b-40ee-8bf1-2eef0323ff10":{"id":"9f284429-911b-40ee-8bf1-2eef0323ff10","name":"select","title":"Select","description":"Takes multiple children and runs them from top to bottom (or left to right), succeeding when any one succeeds. Fails if all fail.","properties":{},"display":{"x":-24,"y":-876},"children":["c2b6fa20-19e4-45ab-8567-d1d4491fb204","e14808f3-ec84-4fd6-a876-15dd94e20340","9076b9f6-5be5-4750-90a6-ee877d841f02","3215def2-2acf-4011-9806-ae3ca3a9cddb","cb194a93-87c6-4e17-a0f8-946b0dff6469","bc1635c6-5c7d-45d0-8f4e-54a564839fc0","38509c18-9871-4ebb-ba1a-50b9c21a7c22"]},"5e175926-0c6f-4495-9df9-e9eeccd5c8e7":{"id":"5e175926-0c6f-4495-9df9-e9eeccd5c8e7","name":"random","title":"Random","description":"Takes multiple children and runs one of them at random.","properties":{},"display":{"x":192,"y":48},"children":["a1173df0-2452-4129-88fd-b4be3c2a5152","71370706-9495-4c49-843a-44532755409b"]},"918d4b07-b321-4feb-94db-52073aab2b36":{"id":"918d4b07-b321-4feb-94db-52073aab2b36","name":"random_weighted","title":"Random weighted","description":"Takes multiple children and runs one of them at random based on their weightings. Each child node MUST have a \"weight\" property with a value greater than 0.","properties":{},"display":{"x":192,"y":264},"children":["a3263be3-c8d9-40e9-8353-cafc624c1311","75683913-bf2a-4b6c-8233-aeeb27a56b90","98fc354e-7158-4fa9-8c64-b7f557095569"]},"c46d7062-b97a-4684-aa1b-324b42d05287":{"id":"c46d7062-b97a-4684-aa1b-324b42d05287","name":"repeat_n","title":"Repeat x","description":"Takes one child and runs it \"n\" times, where \"n\" is defined in this node's properties.","properties":{"n":"{{repeat_count}}"},"display":{"x":-24,"y":-264},"child":"48065152-3a67-4d9a-851c-245058cc79f0"},"e5c549aa-3119-487c-800f-8115bc2b849d":{"id":"e5c549aa-3119-487c-800f-8115bc2b849d","name":"repeat_until_fail","title":"Repeat until fail","description":"Takes one child which it repeats until it fails. This node always succeeds.","properties":{},"display":{"x":-24,"y":-396},"child":"ac2bff36-a4e6-4e93-ab3f-70c30697d780"},"7b26689c-e48d-4d79-85d6-93131cb70449":{"id":"7b26689c-e48d-4d79-85d6-93131cb70449","name":"repeat_until_succeed","title":"Repeat until succeed","description":"Takes one child which it repeats until it succeeds. This node always succeeds.","properties":{},"display":{"x":-24,"y":-528},"child":"107244d1-92e8-45af-83e6-ef228f7e60c0"},"107244d1-92e8-45af-83e6-ef228f7e60c0":{"id":"107244d1-92e8-45af-83e6-ef228f7e60c0","name":"negate","title":"Negate","description":"Takes one child. If that child succeeds, this node fails, and vice versa.","properties":{},"display":{"x":192,"y":-528},"child":"cf9eadbd-913d-4d23-807f-b6d021311597"},"a1173df0-2452-4129-88fd-b4be3c2a5152":{"id":"a1173df0-2452-4129-88fd-b4be3c2a5152","name":"always_fail","title":"Always fail","description":"Takes one child and fails regardless of its outcome.","properties":{},"display":{"x":396,"y":0},"child":"6feb6a19-ea38-406e-869d-879246c1faea"},"71370706-9495-4c49-843a-44532755409b":{"id":"71370706-9495-4c49-843a-44532755409b","name":"always_succeed","title":"Always succeed","description":"Takes one child and succeeds regardless of its outcome.","properties":{},"display":{"x":396,"y":96},"child":"734e0d36-f847-4347-b0e5-e09651613732"},"9076b9f6-5be5-4750-90a6-ee877d841f02":{"id":"9076b9f6-5be5-4750-90a6-ee877d841f02","name":"runner","title":"A.Nested.test()","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":192,"y":-960}},"c2b6fa20-19e4-45ab-8567-d1d4491fb204":{"id":"c2b6fa20-19e4-45ab-8567-d1d4491fb204","name":"runner","title":"A.simple()","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":192,"y":-1140}},"cb194a93-87c6-4e17-a0f8-946b0dff6469":{"id":"cb194a93-87c6-4e17-a0f8-946b0dff6469","name":"runner","title":"A.with_args(1,\"hi\", [name: false])","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":192,"y":-792}},"a3263be3-c8d9-40e9-8353-cafc624c1311":{"id":"a3263be3-c8d9-40e9-8353-cafc624c1311","name":"runner","title":"A.simple() weight=","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{"weight":1},"display":{"x":396,"y":180}},"48065152-3a67-4d9a-851c-245058cc79f0":{"id":"48065152-3a67-4d9a-851c-245058cc79f0","name":"runner","title":"A.with_args(2, {{x}})","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":192,"y":-264}},"3b7ec805-645d-4b06-8505-b8be46603e29":{"id":"3b7ec805-645d-4b06-8505-b8be46603e29","name":"runner","title":"A.simple()","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":396,"y":-432}},"cf9eadbd-913d-4d23-807f-b6d021311597":{"id":"cf9eadbd-913d-4d23-807f-b6d021311597","name":"runner","title":"A.simple()","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":396,"y":-528}},"e14808f3-ec84-4fd6-a876-15dd94e20340":{"id":"e14808f3-ec84-4fd6-a876-15dd94e20340","name":"runner","title":"A.one_arg(1)","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":192,"y":-1056}},"aa3dda4a-4db4-484e-b355-728b3d4e8675":{"id":"aa3dda4a-4db4-484e-b355-728b3d4e8675","name":"3e7a8d4c-75af-41cd-8d98-844a584ca033","title":"Tree B a=","description":"An instance of the tree. You can add properties to this node and they will be available in action nodes in this tree (using syntax like `{{key}}`). Properties defined on this node will overwrite the same property defined on the tree's root node.","properties":{"a":1},"display":{"x":-24,"y":444}},"b4766a8c-badc-49f9-8a1d-948d175888b0":{"id":"b4766a8c-badc-49f9-8a1d-948d175888b0","name":"3e7a8d4c-75af-41cd-8d98-844a584ca033","title":"Tree B a= b= c=","description":"An instance of the tree. You can add properties to this node and they will be available in action nodes in this tree (using syntax like `{{key}}`). Properties defined on this node will overwrite the same property defined on the tree's root node.","properties":{"a":111,"b":222,"c":"{{c}}"},"display":{"x":-24,"y":528}},"75683913-bf2a-4b6c-8233-aeeb27a56b90":{"id":"75683913-bf2a-4b6c-8233-aeeb27a56b90","name":"runner","title":"A.with_args(9, \"ok\") weight=","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{"weight":"{{weight_a}}"},"display":{"x":396,"y":264}},"98fc354e-7158-4fa9-8c64-b7f557095569":{"id":"98fc354e-7158-4fa9-8c64-b7f557095569","name":"runner","title":"A.Nested.test() weight=","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{"weight":10},"display":{"x":396,"y":360}},"ac2bff36-a4e6-4e93-ab3f-70c30697d780":{"id":"ac2bff36-a4e6-4e93-ab3f-70c30697d780","name":"sequence","title":"Sequence","description":"Takes multiple children and runs them from top to bottom (or left to right). If any fail, this node fails, if all succeed, this node succeeds.","properties":{},"display":{"x":192,"y":-396},"children":["3b7ec805-645d-4b06-8505-b8be46603e29","80f2f965-1aa0-4dbc-8b32-7b80f235ff75"]},"83f62ab8-a702-4448-be76-215380b81af2":{"id":"83f62ab8-a702-4448-be76-215380b81af2","name":"select","title":"Select","description":"Takes multiple children and runs them from top to bottom (or left to right), succeeding when any one succeeds. Fails if all fail.","properties":{},"display":{"x":-24,"y":156},"children":["5e175926-0c6f-4495-9df9-e9eeccd5c8e7","918d4b07-b321-4feb-94db-52073aab2b36"]},"38509c18-9871-4ebb-ba1a-50b9c21a7c22":{"id":"38509c18-9871-4ebb-ba1a-50b9c21a7c22","name":"error","title":"error(\"Oops\")","description":"Raises an error with the supplied message.","properties":{},"display":{"x":192,"y":-612}},"80f2f965-1aa0-4dbc-8b32-7b80f235ff75":{"id":"80f2f965-1aa0-4dbc-8b32-7b80f235ff75","name":"succeed_rate","title":"succeed_rate(0.5)","description":"Succeeds randomly at the specified rate, expressed as a number between 0 and 1. For example, a rate of 0.25 will succeed one out of every 4 times on average.","properties":{},"display":{"x":396,"y":-348}},"5e0ef8dc-e5b6-4ef6-8acc-4963c8068546":{"id":"5e0ef8dc-e5b6-4ef6-8acc-4963c8068546","name":"wait","title":"wait(1)","description":"\"Pauses\" the bot for the specified number of seconds. You can specify two numbers (like `wait(1,10)`) to wait a random number of seconds between those numbers.","properties":{},"display":{"x":-24,"y":-168}},"d8f44515-dffa-413c-aaf3-fd542734d46e":{"id":"d8f44515-dffa-413c-aaf3-fd542734d46e","name":"wait","title":"wait(1, 10)","description":"\"Pauses\" the bot for the specified number of seconds. You can specify two numbers (like `wait(1,10)`) to wait a random number of seconds between those numbers.","properties":{},"display":{"x":-24,"y":-84}},"6feb6a19-ea38-406e-869d-879246c1faea":{"id":"6feb6a19-ea38-406e-869d-879246c1faea","name":"log","title":"log(\"This fails no matter what\")","description":"Logs the specified message.","properties":{},"display":{"x":600,"y":0}},"734e0d36-f847-4347-b0e5-e09651613732":{"id":"734e0d36-f847-4347-b0e5-e09651613732","name":"log","title":"log(\"This succeeds no matter what\")","description":"Logs the specified message.","properties":{},"display":{"x":600,"y":96}},"d43977fb-1601-48e3-905e-a9443bb60240":{"id":"d43977fb-1601-48e3-905e-a9443bb60240","name":"done","title":"done()","description":"Stops the behavior tree.","properties":{},"display":{"x":-24,"y":624}},"3215def2-2acf-4011-9806-ae3ca3a9cddb":{"id":"3215def2-2acf-4011-9806-ae3ca3a9cddb","name":"A.simple","title":"A.simple()","description":"This is a custom action node, docs would go here...","properties":{},"display":{"x":192,"y":-876}},"bc1635c6-5c7d-45d0-8f4e-54a564839fc0":{"id":"bc1635c6-5c7d-45d0-8f4e-54a564839fc0","name":"A.with_args","title":"A.with_args(2, \"bye\")","description":"A custom action node, docs, would go here...","properties":{},"display":{"x":192,"y":-696}}},"display":{"camera_x":553,"camera_y":995.5,"camera_z":0.75,"x":-432,"y":-48}},{"version":"0.3.0","scope":"tree","id":"3e7a8d4c-75af-41cd-8d98-844a584ca033","title":"Tree B","description":"The root of this tree. You can set tree-wide properties on this node and use them in an action's \"args\" property with this notation: `{{key_name}}`.","root":"d29d84ba-b481-4998-9600-c1919feb4f17","properties":{"a":999,"b":999,"c":999},"nodes":{"5ea3e0a4-892f-41fb-a852-30d34e45f62c":{"id":"5ea3e0a4-892f-41fb-a852-30d34e45f62c","name":"runner","title":"B.test(1,2,3)","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":252,"y":-120}},"5b8121d9-7a2b-40d9-8d2f-044ef0bc2a99":{"id":"5b8121d9-7a2b-40d9-8d2f-044ef0bc2a99","name":"runner","title":"B.test({{a}})","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":252,"y":-24}},"536d5f1f-a936-4ee3-8bac-a0039ae3a5b5":{"id":"536d5f1f-a936-4ee3-8bac-a0039ae3a5b5","name":"runner","title":"B.test({{a}},{{b}},{{c}})","description":"An action that calls the function specified in the title (must be in valid Elixir terms). The title can contain \"template variables\" (like `{{mod}}.rename({{new_name}}, true)`) which will be replaced with corresponding values looked up on the parent tree.","properties":{},"display":{"x":252,"y":60}},"d29d84ba-b481-4998-9600-c1919feb4f17":{"id":"d29d84ba-b481-4998-9600-c1919feb4f17","name":"sequence","title":"Sequence","description":"Takes multiple children and runs them from top to bottom (or left to right). If any fail, this node fails, if all succeed, this node succeeds.","properties":{},"display":{"x":48,"y":-24},"children":["5ea3e0a4-892f-41fb-a852-30d34e45f62c","5b8121d9-7a2b-40d9-8d2f-044ef0bc2a99","536d5f1f-a936-4ee3-8bac-a0039ae3a5b5"]}},"display":{"camera_x":633,"camera_y":416,"camera_z":0.75,"x":-156,"y":-24}}],"custom_nodes":[{"version":"0.3.0","scope":"node","name":"A.simple","category":"action","title":"A.simple()","description":"This is a custom action node, docs would go here...","properties":{}},{"version":"0.3.0","scope":"node","name":"A.with_args","category":"action","title":"A.with_args(a,b,c)","description":"A custom action node, docs, would go here...","properties":{}}]}} -------------------------------------------------------------------------------- /test/ets_metrics_export_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BotArmy.EtsMetricsExportTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias BotArmy.Metrics.Export 5 | alias BotArmy.EtsMetrics 6 | 7 | @moduletag :ets_metrics 8 | 9 | setup _context do 10 | EtsMetrics.run(10) 11 | end 12 | 13 | # Note, the reference modules don't actually exist, but they are treated as atoms 14 | describe "metrics export" do 15 | test "total_error_count is the sum of all action errors" do 16 | send(EtsMetrics, {:action, Actions.Renditions, :join, 212, :error}) 17 | send(EtsMetrics, {:action, Actions.Renditions, :request, 293, :error}) 18 | 19 | Process.sleep(100) 20 | 21 | report = Export.generate_report() 22 | 23 | assert Map.get(report, :total_error_count) == 2 24 | end 25 | 26 | test "successful actions are additive" do 27 | send(EtsMetrics, {:action, Actions.Asset, :upload_image, 190, :succeed}) 28 | send(EtsMetrics, {:action, Actions.Asset, :upload_image, 320, :succeed}) 29 | 30 | Process.sleep(100) 31 | 32 | report = Export.generate_report() 33 | 34 | actions = Map.get(report, :actions) 35 | success_count = Map.get(actions, "Asset.upload_image")[:success_count] 36 | assert success_count == 2 37 | end 38 | 39 | test "error actions are additive" do 40 | send(EtsMetrics, {:action, Actions.Renditions, :request, 212, :error}) 41 | send(EtsMetrics, {:action, Actions.Renditions, :request, 293, :error}) 42 | 43 | Process.sleep(100) 44 | 45 | report = Export.generate_report() 46 | 47 | actions = Map.get(report, :actions) 48 | error_count = Map.get(actions, "Renditions.request")[:error_count] 49 | assert error_count == 2 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------