├── .babelrc ├── .clang-format ├── .clang-tidy ├── .cmake-format ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ ├── install-ninja │ │ └── action.yml │ └── install-sccache │ │ └── action.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── build-and-test-pr.yml │ └── npm-publish.yml ├── .gitignore ├── CMakeLists.txt ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── cmake ├── FocusriteE2EConfig.cmake ├── fetch-juce.cmake └── set_common_target_properties.cmake ├── dependabot.yml ├── documentation ├── cmake.md ├── cplusplus.md ├── integration-guide.md └── javascript.md ├── eslint.config.js ├── example ├── app │ ├── CMakeLists.txt │ └── source │ │ ├── Application.h │ │ ├── CustomCommandHandler.h │ │ ├── Main.cpp │ │ ├── MainComponent.h │ │ └── MainWindow.h └── tests │ ├── accessibility.spec.ts │ ├── app-path.ts │ ├── counter-app.spec.ts │ └── failure-modes.spec.ts ├── jest.config.ts ├── package-lock.json ├── package.json ├── source ├── cpp │ ├── CMakeLists.txt │ ├── include │ │ └── focusrite │ │ │ └── e2e │ │ │ ├── ClickableComponent.h │ │ │ ├── Command.h │ │ │ ├── CommandHandler.h │ │ │ ├── ComponentSearch.h │ │ │ ├── Event.h │ │ │ ├── Response.h │ │ │ └── TestCentre.h │ ├── source │ │ ├── Command.cpp │ │ ├── ComponentSearch.cpp │ │ ├── Connection.cpp │ │ ├── Connection.h │ │ ├── DefaultCommandHandler.cpp │ │ ├── DefaultCommandHandler.h │ │ ├── Event.cpp │ │ ├── KeyPress.cpp │ │ ├── KeyPress.h │ │ ├── Response.cpp │ │ └── TestCentre.cpp │ └── tests │ │ ├── TestCommand.cpp │ │ ├── TestComponentSearch.cpp │ │ ├── TestResponse.cpp │ │ └── main.cpp └── ts │ ├── app-connection.ts │ ├── app-process.ts │ ├── binary-protocol.ts │ ├── commands.ts │ ├── component-handle.ts │ ├── connection.ts │ ├── index.ts │ ├── poll.ts │ ├── response-stream.ts │ ├── responses.ts │ └── server.ts ├── tests ├── poll-until.spec.ts ├── response-stream.spec.ts └── wait-for-result.spec.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", {"targets": {"node": "current"}}], 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [] 7 | } -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | BasedOnStyle: Microsoft 4 | AccessModifierOffset: -4 5 | AlignAfterOpenBracket: Align 6 | AlignConsecutiveMacros: false 7 | AlignConsecutiveAssignments: false 8 | AlignConsecutiveBitFields: false 9 | AlignConsecutiveDeclarations: false 10 | AlignEscapedNewlines: Left 11 | AlignOperands: Align 12 | AlignTrailingComments: true 13 | AllowAllArgumentsOnNextLine: true 14 | AllowAllConstructorInitializersOnNextLine: false 15 | AllowAllParametersOfDeclarationOnNextLine: false 16 | AllowShortBlocksOnASingleLine: Never 17 | AllowShortCaseLabelsOnASingleLine: false 18 | AllowShortEnumsOnASingleLine: false 19 | AllowShortFunctionsOnASingleLine: None 20 | AllowShortIfStatementsOnASingleLine: Never 21 | AllowShortLambdasOnASingleLine: All 22 | AllowShortLoopsOnASingleLine: false 23 | AlwaysBreakAfterDefinitionReturnType: None 24 | AlwaysBreakAfterReturnType: None 25 | AlwaysBreakBeforeMultilineStrings: false 26 | AlwaysBreakTemplateDeclarations: Yes 27 | BinPackArguments: false 28 | BinPackParameters: false 29 | BraceWrapping: 30 | AfterCaseLabel: true 31 | AfterClass: true 32 | AfterControlStatement: Always 33 | AfterEnum: true 34 | AfterFunction: true 35 | AfterNamespace: true 36 | AfterObjCDeclaration: true 37 | AfterStruct: true 38 | AfterUnion: true 39 | AfterExternBlock: true 40 | BeforeCatch: true 41 | BeforeElse: true 42 | BeforeLambdaBody: true 43 | BeforeWhile: true 44 | IndentBraces: false 45 | SplitEmptyFunction: true 46 | SplitEmptyRecord: true 47 | SplitEmptyNamespace: true 48 | BreakBeforeBinaryOperators: None 49 | BreakBeforeBraces: Custom 50 | BreakBeforeTernaryOperators: true 51 | BreakConstructorInitializers: BeforeComma 52 | BreakBeforeInheritanceComma: false 53 | BreakInheritanceList: BeforeComma 54 | BreakConstructorInitializersBeforeComma: false 55 | BreakAfterJavaFieldAnnotations: false 56 | BreakStringLiterals: false 57 | ColumnLimit: 100 58 | CommentPragmas: '^ IWYU pragma:' 59 | CompactNamespaces: false 60 | ConstructorInitializerAllOnOneLineOrOnePerLine: false 61 | ConstructorInitializerIndentWidth: 4 62 | ContinuationIndentWidth: 4 63 | Cpp11BracedListStyle: true 64 | DeriveLineEnding: true 65 | DerivePointerAlignment: false 66 | DisableFormat: false 67 | ExperimentalAutoDetectBinPacking: false 68 | FixNamespaceComments: false 69 | ForEachMacros: 70 | - foreach 71 | - Q_FOREACH 72 | - BOOST_FOREACH 73 | IncludeBlocks: Regroup 74 | IncludeCategories: 75 | - Regex: '^"(llvm|llvm-c|clang|clang-c)/' 76 | Priority: 2 77 | SortPriority: 0 78 | - Regex: '^(<|"(gtest|gmock|isl|json)/)' 79 | Priority: 3 80 | SortPriority: 0 81 | - Regex: '.*' 82 | Priority: 1 83 | SortPriority: 0 84 | IncludeIsMainRegex: '(Test)?$' 85 | IncludeIsMainSourceRegex: '' 86 | IndentCaseBlocks: true 87 | IndentCaseLabels: true 88 | IndentGotoLabels: true 89 | IndentPPDirectives: BeforeHash 90 | IndentExternBlock: NoIndent 91 | IndentWidth: 4 92 | IndentWrappedFunctionNames: false 93 | InsertTrailingCommas: None 94 | JavaScriptQuotes: Leave 95 | JavaScriptWrapImports: true 96 | KeepEmptyLinesAtTheStartOfBlocks: false 97 | MacroBlockBegin: '' 98 | MacroBlockEnd: '' 99 | MaxEmptyLinesToKeep: 1 100 | NamespaceIndentation: None 101 | ObjCBinPackProtocolList: Auto 102 | ObjCBlockIndentWidth: 2 103 | ObjCBreakBeforeNestedBlockParam: true 104 | ObjCSpaceAfterProperty: false 105 | ObjCSpaceBeforeProtocolList: true 106 | PenaltyBreakAssignment: 2 107 | PenaltyBreakBeforeFirstCallParameter: 19 108 | PenaltyBreakComment: 300 109 | PenaltyBreakFirstLessLess: 120 110 | PenaltyBreakString: 1000 111 | PenaltyBreakTemplateDeclaration: 10 112 | PenaltyExcessCharacter: 1000000 113 | PenaltyReturnTypeOnItsOwnLine: 60 114 | PointerAlignment: Middle 115 | ReflowComments: true 116 | SortIncludes: true 117 | SortUsingDeclarations: true 118 | SpaceAfterCStyleCast: true 119 | SpaceAfterLogicalNot: true 120 | SpaceAfterTemplateKeyword: true 121 | SpaceBeforeAssignmentOperators: true 122 | SpaceBeforeCpp11BracedList: true 123 | SpaceBeforeCtorInitializerColon: true 124 | SpaceBeforeInheritanceColon: true 125 | SpaceBeforeParens: Always 126 | SpaceBeforeRangeBasedForLoopColon: true 127 | SpaceInEmptyBlock: false 128 | SpaceInEmptyParentheses: false 129 | SpacesBeforeTrailingComments: 1 130 | SpacesInAngles: false 131 | SpacesInConditionalStatement: false 132 | SpacesInContainerLiterals: false 133 | SpacesInCStyleCastParentheses: false 134 | SpacesInParentheses: false 135 | SpacesInSquareBrackets: false 136 | SpaceBeforeSquareBrackets: true 137 | Standard: Latest 138 | StatementMacros: 139 | - Q_UNUSED 140 | - QT_REQUIRE_VERSION 141 | TabWidth: 4 142 | UseCRLF: false 143 | UseTab: Never 144 | WhitespaceSensitiveMacros: 145 | - STRINGIZE 146 | - PP_STRINGIZE 147 | - BOOST_PP_STRINGIZE 148 | ... 149 | 150 | -------------------------------------------------------------------------------- /.clang-tidy: -------------------------------------------------------------------------------- 1 | --- 2 | Checks: > 3 | -*, 4 | 5 | clang-diagnostic-*, 6 | 7 | clang-analyzer-*, 8 | 9 | modernize-*, 10 | -modernize-use-trailing-return-type, 11 | -modernize-use-nodiscard, 12 | 13 | readability-*, 14 | -readability-braces-around-statements, 15 | -readability-uppercase-literal-suffix, 16 | -readability-function-cognitive-complexity, 17 | 18 | bugprone-*, 19 | -bugprone-easily-swappable-parameters, 20 | -bugprone-exception-escape, 21 | -bugprone-suspicious-include, 22 | 23 | WarningsAsErrors: '*' 24 | 25 | CheckOptions: 26 | - { key: readability-identifier-length.IgnoredVariableNames, value: ^(id|it)$ } 27 | - { key: readability-identifier-length.IgnoredParameterNames, value: ^(id|it)$ } 28 | 29 | 30 | HeaderFilterRegex: '' 31 | AnalyzeTemporaryDtors: false 32 | FormatStyle: none 33 | ... 34 | 35 | -------------------------------------------------------------------------------- /.cmake-format: -------------------------------------------------------------------------------- 1 | { 2 | "_help_parse": "Options affecting listfile parsing", 3 | "parse": { 4 | "_help_additional_commands": [ 5 | "Specify structure for custom cmake functions" 6 | ], 7 | "additional_commands": { 8 | "foo": { 9 | "flags": [ 10 | "BAR", 11 | "BAZ" 12 | ], 13 | "kwargs": { 14 | "HEADERS": "*", 15 | "SOURCES": "*", 16 | "DEPENDS": "*" 17 | } 18 | } 19 | }, 20 | "_help_override_spec": [ 21 | "Override configurations per-command where available" 22 | ], 23 | "override_spec": {}, 24 | "_help_vartags": [ 25 | "Specify variable tags." 26 | ], 27 | "vartags": [], 28 | "_help_proptags": [ 29 | "Specify property tags." 30 | ], 31 | "proptags": [] 32 | }, 33 | "_help_format": "Options affecting formatting.", 34 | "format": { 35 | "_help_disable": [ 36 | "Disable formatting entirely, making cmake-format a no-op" 37 | ], 38 | "disable": false, 39 | "_help_line_width": [ 40 | "How wide to allow formatted cmake files" 41 | ], 42 | "line_width": 80, 43 | "_help_tab_size": [ 44 | "How many spaces to tab for indent" 45 | ], 46 | "tab_size": 2, 47 | "_help_use_tabchars": [ 48 | "If true, lines are indented using tab characters (utf-8", 49 | "0x09) instead of space characters (utf-8 0x20).", 50 | "In cases where the layout would require a fractional tab", 51 | "character, the behavior of the fractional indentation is", 52 | "governed by " 53 | ], 54 | "use_tabchars": false, 55 | "_help_fractional_tab_policy": [ 56 | "If is True, then the value of this variable", 57 | "indicates how fractional indentions are handled during", 58 | "whitespace replacement. If set to 'use-space', fractional", 59 | "indentation is left as spaces (utf-8 0x20). If set to", 60 | "`round-up` fractional indentation is replaced with a single", 61 | "tab character (utf-8 0x09) effectively shifting the column", 62 | "to the next tabstop" 63 | ], 64 | "fractional_tab_policy": "use-space", 65 | "_help_max_subgroups_hwrap": [ 66 | "If an argument group contains more than this many sub-groups", 67 | "(parg or kwarg groups) then force it to a vertical layout." 68 | ], 69 | "max_subgroups_hwrap": 4, 70 | "_help_max_pargs_hwrap": [ 71 | "If a positional argument group contains more than this many", 72 | "arguments, then force it to a vertical layout." 73 | ], 74 | "max_pargs_hwrap": 4, 75 | "_help_max_rows_cmdline": [ 76 | "If a cmdline positional group consumes more than this many", 77 | "lines without nesting, then invalidate the layout (and nest)" 78 | ], 79 | "max_rows_cmdline": 4, 80 | "_help_separate_ctrl_name_with_space": [ 81 | "If true, separate flow control names from their parentheses", 82 | "with a space" 83 | ], 84 | "separate_ctrl_name_with_space": true, 85 | "_help_separate_fn_name_with_space": [ 86 | "If true, separate function names from parentheses with a", 87 | "space" 88 | ], 89 | "separate_fn_name_with_space": true, 90 | "_help_dangle_parens": [ 91 | "If a statement is wrapped to more than one line, than dangle", 92 | "the closing parenthesis on its own line." 93 | ], 94 | "dangle_parens": false, 95 | "_help_dangle_align": [ 96 | "If the trailing parenthesis must be 'dangled' on its on", 97 | "line, then align it to this reference: `prefix`: the start", 98 | "of the statement, `prefix-indent`: the start of the", 99 | "statement, plus one indentation level, `child`: align to", 100 | "the column of the arguments" 101 | ], 102 | "dangle_align": "prefix", 103 | "_help_min_prefix_chars": [ 104 | "If the statement spelling length (including space and", 105 | "parenthesis) is smaller than this amount, then force reject", 106 | "nested layouts." 107 | ], 108 | "min_prefix_chars": 4, 109 | "_help_max_prefix_chars": [ 110 | "If the statement spelling length (including space and", 111 | "parenthesis) is larger than the tab width by more than this", 112 | "amount, then force reject un-nested layouts." 113 | ], 114 | "max_prefix_chars": 10, 115 | "_help_max_lines_hwrap": [ 116 | "If a candidate layout is wrapped horizontally but it exceeds", 117 | "this many lines, then reject the layout." 118 | ], 119 | "max_lines_hwrap": 4, 120 | "_help_line_ending": [ 121 | "What style line endings to use in the output." 122 | ], 123 | "line_ending": "unix", 124 | "_help_command_case": [ 125 | "Format command names consistently as 'lower' or 'upper' case" 126 | ], 127 | "command_case": "lower", 128 | "_help_keyword_case": [ 129 | "Format keywords consistently as 'lower' or 'upper' case" 130 | ], 131 | "keyword_case": "upper", 132 | "_help_always_wrap": [ 133 | "A list of command names which should always be wrapped" 134 | ], 135 | "always_wrap": [], 136 | "_help_enable_sort": [ 137 | "If true, the argument lists which are known to be sortable", 138 | "will be sorted lexicographicall" 139 | ], 140 | "enable_sort": true, 141 | "_help_autosort": [ 142 | "If true, the parsers may infer whether or not an argument", 143 | "list is sortable (without annotation)." 144 | ], 145 | "autosort": false, 146 | "_help_require_valid_layout": [ 147 | "By default, if cmake-format cannot successfully fit", 148 | "everything into the desired linewidth it will apply the", 149 | "last, most agressive attempt that it made. If this flag is", 150 | "True, however, cmake-format will print error, exit with non-", 151 | "zero status code, and write-out nothing" 152 | ], 153 | "require_valid_layout": false, 154 | "_help_layout_passes": [ 155 | "A dictionary mapping layout nodes to a list of wrap", 156 | "decisions. See the documentation for more information." 157 | ], 158 | "layout_passes": {} 159 | }, 160 | "_help_markup": "Options affecting comment reflow and formatting.", 161 | "markup": { 162 | "_help_bullet_char": [ 163 | "What character to use for bulleted lists" 164 | ], 165 | "bullet_char": "*", 166 | "_help_enum_char": [ 167 | "What character to use as punctuation after numerals in an", 168 | "enumerated list" 169 | ], 170 | "enum_char": ".", 171 | "_help_first_comment_is_literal": [ 172 | "If comment markup is enabled, don't reflow the first comment", 173 | "block in each listfile. Use this to preserve formatting of", 174 | "your copyright/license statements." 175 | ], 176 | "first_comment_is_literal": false, 177 | "_help_literal_comment_pattern": [ 178 | "If comment markup is enabled, don't reflow any comment block", 179 | "which matches this (regex) pattern. Default is `None`", 180 | "(disabled)." 181 | ], 182 | "literal_comment_pattern": null, 183 | "_help_fence_pattern": [ 184 | "Regular expression to match preformat fences in comments", 185 | "default= ``r'^\\s*([`~]{3}[`~]*)(.*)$'``" 186 | ], 187 | "fence_pattern": "^\\s*([`~]{3}[`~]*)(.*)$", 188 | "_help_ruler_pattern": [ 189 | "Regular expression to match rulers in comments default=", 190 | "``r'^\\s*[^\\w\\s]{3}.*[^\\w\\s]{3}$'``" 191 | ], 192 | "ruler_pattern": "^\\s*[^\\w\\s]{3}.*[^\\w\\s]{3}$", 193 | "_help_explicit_trailing_pattern": [ 194 | "If a comment line matches starts with this pattern then it", 195 | "is explicitly a trailing comment for the preceeding", 196 | "argument. Default is '#<'" 197 | ], 198 | "explicit_trailing_pattern": "#<", 199 | "_help_hashruler_min_length": [ 200 | "If a comment line starts with at least this many consecutive", 201 | "hash characters, then don't lstrip() them off. This allows", 202 | "for lazy hash rulers where the first hash char is not", 203 | "separated by space" 204 | ], 205 | "hashruler_min_length": 10, 206 | "_help_canonicalize_hashrulers": [ 207 | "If true, then insert a space between the first hash char and", 208 | "remaining hash chars in a hash ruler, and normalize its", 209 | "length to fill the column" 210 | ], 211 | "canonicalize_hashrulers": true, 212 | "_help_enable_markup": [ 213 | "enable comment markup parsing and reflow" 214 | ], 215 | "enable_markup": true 216 | }, 217 | "_help_lint": "Options affecting the linter", 218 | "lint": { 219 | "_help_disabled_codes": [ 220 | "a list of lint codes to disable" 221 | ], 222 | "disabled_codes": [], 223 | "_help_function_pattern": [ 224 | "regular expression pattern describing valid function names" 225 | ], 226 | "function_pattern": "[0-9a-z_]+", 227 | "_help_macro_pattern": [ 228 | "regular expression pattern describing valid macro names" 229 | ], 230 | "macro_pattern": "[0-9A-Z_]+", 231 | "_help_global_var_pattern": [ 232 | "regular expression pattern describing valid names for", 233 | "variables with global (cache) scope" 234 | ], 235 | "global_var_pattern": "[A-Z][0-9A-Z_]+", 236 | "_help_internal_var_pattern": [ 237 | "regular expression pattern describing valid names for", 238 | "variables with global scope (but internal semantic)" 239 | ], 240 | "internal_var_pattern": "_[A-Z][0-9A-Z_]+", 241 | "_help_local_var_pattern": [ 242 | "regular expression pattern describing valid names for", 243 | "variables with local scope" 244 | ], 245 | "local_var_pattern": "[a-z][a-z0-9_]+", 246 | "_help_private_var_pattern": [ 247 | "regular expression pattern describing valid names for", 248 | "privatedirectory variables" 249 | ], 250 | "private_var_pattern": "_[0-9a-z_]+", 251 | "_help_public_var_pattern": [ 252 | "regular expression pattern describing valid names for public", 253 | "directory variables" 254 | ], 255 | "public_var_pattern": "[A-Z][0-9A-Z_]+", 256 | "_help_argument_var_pattern": [ 257 | "regular expression pattern describing valid names for", 258 | "function/macro arguments and loop variables." 259 | ], 260 | "argument_var_pattern": "[a-z][a-z0-9_]+", 261 | "_help_keyword_pattern": [ 262 | "regular expression pattern describing valid names for", 263 | "keywords used in functions or macros" 264 | ], 265 | "keyword_pattern": "[A-Z][0-9A-Z_]+", 266 | "_help_max_conditionals_custom_parser": [ 267 | "In the heuristic for C0201, how many conditionals to match", 268 | "within a loop in before considering the loop a parser." 269 | ], 270 | "max_conditionals_custom_parser": 2, 271 | "_help_min_statement_spacing": [ 272 | "Require at least this many newlines between statements" 273 | ], 274 | "min_statement_spacing": 1, 275 | "_help_max_statement_spacing": [ 276 | "Require no more than this many newlines between statements" 277 | ], 278 | "max_statement_spacing": 2, 279 | "max_returns": 6, 280 | "max_branches": 12, 281 | "max_arguments": 5, 282 | "max_localvars": 15, 283 | "max_statements": 50 284 | }, 285 | "_help_encode": "Options affecting file encoding", 286 | "encode": { 287 | "_help_emit_byteorder_mark": [ 288 | "If true, emit the unicode byte-order mark (BOM) at the start", 289 | "of the file" 290 | ], 291 | "emit_byteorder_mark": false, 292 | "_help_input_encoding": [ 293 | "Specify the encoding of the input file. Defaults to utf-8" 294 | ], 295 | "input_encoding": "utf-8", 296 | "_help_output_encoding": [ 297 | "Specify the encoding of the output file. Defaults to utf-8.", 298 | "Note that cmake only claims to support utf-8 so be careful", 299 | "when using anything else" 300 | ], 301 | "output_encoding": "utf-8" 302 | }, 303 | "_help_misc": "Miscellaneous configurations options.", 304 | "misc": { 305 | "_help_per_command": [ 306 | "A dictionary containing any per-command configuration", 307 | "overrides. Currently only `command_case` is supported." 308 | ], 309 | "per_command": {} 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @FocusriteGroup/focusrite-software 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | **To Reproduce** 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Desktop (please complete the following information):** 31 | 32 | - OS: [e.g. iOS] 33 | - Juce version: [e.g. 6.1.2] 34 | - Version [e.g. 22] 35 | 36 | **Additional context** 37 | 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/actions/install-ninja/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Install Ninja' 2 | 3 | description: 'Installs Ninja' 4 | 5 | runs: 6 | using: "composite" 7 | steps: 8 | - name: Download Ninja 9 | shell: bash 10 | run: | 11 | if [[ "${{ runner.os }}" == "Windows" ]]; then 12 | OSPackage="ninja-win.zip" 13 | else 14 | OSPackage="ninja-mac.zip" 15 | fi 16 | 17 | curl -o ninja.zip -OsL "https://github.com/ninja-build/ninja/releases/latest/download/${OSPackage}" 18 | unzip -d ninja ninja.zip 19 | 20 | - name: Add Ninja to PATH (bash) 21 | shell: bash 22 | run: echo "$(pwd)/ninja" >> "$GITHUB_PATH" 23 | -------------------------------------------------------------------------------- /.github/actions/install-sccache/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Install sccache' 2 | 3 | description: 'Installs sccache' 4 | 5 | outputs: 6 | path: 7 | description: 'Path to the sccache executable' 8 | value: ${{ steps.sccache-location.outputs.sccache-path }} 9 | cache-dir: 10 | description: 'Path to the sccache cache directory' 11 | value: ${{ steps.sccache-location.outputs.sccache-cache-dir }} 12 | 13 | runs: 14 | using: "composite" 15 | steps: 16 | - name: Download sccache (Windows) 17 | if: runner.os == 'Windows' 18 | shell: bash 19 | run: | 20 | curl -OsL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-pc-windows-msvc.zip 21 | unzip -jd sccache sccache-v0.10.0-x86_64-pc-windows-msvc.zip 22 | 23 | - name: Download sccache (macOS) 24 | if: runner.os == 'macOS' 25 | shell: bash 26 | run: | 27 | curl -OsL "https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz" 28 | tar -xzf sccache-v0.10.0-aarch64-apple-darwin.tar.gz 29 | mv sccache-v0.10.0-aarch64-apple-darwin sccache 30 | echo 'SCCACHE_CACHE_MULTIARCH="0"' >> $GITHUB_ENV 31 | 32 | - name: Set and output sccache location 33 | id: sccache-location 34 | shell: bash 35 | run: | 36 | echo 'sccache-path=${{ github.workspace }}/sccache/sccache' >> "$GITHUB_OUTPUT" 37 | echo 'SCCACHE_DIR=${{ github.workspace }}/sccache-cache' >> "$GITHUB_ENV" 38 | echo 'sccache-cache-dir=${{ github.workspace }}/sccache-cache' >> "$GITHUB_OUTPUT" 39 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Describe what your code does** 2 | 3 | A clear description of what your code does 4 | 5 | **Is this pull request to fix a bug?** 6 | 7 | If so, please describe the bug you are fixing, link to any associated issue etc. Please also describe how your pull request fixes it 8 | 9 | **Is this pull request to extend the functionality of the codebase?** 10 | 11 | Is this a feature you are proposing? If so, please describe why it is required, what it does etc. 12 | 13 | **Is the code tested?** 14 | 15 | We require a high level of code test coverage - please make sure that you have run tests on your code. You can learn more about running the tests in the readme.md in the root of the repository. 16 | 17 | By submitting a pull request to this repository you are agreeing to assign the copyright on your submitted code to Focusrite Audio Engineering Ltd. Please check the box below to show you accept these terms. 18 | 19 | - [ ] __I agree to assign copyright of any code accepted via this pull request process to Focusrite Audio engineering Ltd.__ 20 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test-pr.yml: -------------------------------------------------------------------------------- 1 | run-name: '🏗️ Build & test `${{ github.event.pull_request.title }}`' 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | 7 | concurrency: 8 | group: '${{ github.workflow }}-${{ github.event.pull_request.title }}' 9 | cancel-in-progress: true 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | build-and-test: 17 | strategy: 18 | matrix: 19 | runner: [windows-latest, macos-latest] 20 | 21 | runs-on: ${{ matrix.runner }} 22 | 23 | env: 24 | CMAKE_BUILD_CONFIG: 'Release' 25 | CMAKE_BUILD_DIR: 'cmake-build' 26 | CMAKE_GENERATOR: 'Ninja Multi-Config' 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Node 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: latest 36 | cache: npm 37 | 38 | - name: NPM Install 39 | run: npm install 40 | 41 | - name: Linting 42 | run: npx eslint . 43 | 44 | - name: Install Ninja 45 | uses: ./.github/actions/install-ninja 46 | 47 | - name: Install sccache 48 | id: install-sccache 49 | uses: ./.github/actions/install-sccache 50 | 51 | - name: Setup MSVC environment 52 | if: runner.os == 'Windows' 53 | shell: cmd 54 | run: | 55 | for /f "usebackq tokens=*" %%i in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do ( 56 | call "%%i\VC\Auxiliary\Build\vcvars64.bat" 57 | ) 58 | set >> %GITHUB_ENV% 59 | 60 | - name: Restore cached CMake build directory 61 | id: restore-cmake-cache 62 | uses: actions/cache/restore@v4 63 | with: 64 | path: ${{ env.CMAKE_BUILD_DIR }} 65 | key: ${{ runner.os }}-cmake-${{ github.ref }}-${{ github.sha }} 66 | restore-keys: | 67 | ${{ runner.os }}-cmake-${{ github.ref }} 68 | ${{ runner.os }}-cmake- 69 | 70 | - name: Restore sccache 71 | id: restore-sccache 72 | uses: actions/cache/restore@v4 73 | with: 74 | path: ${{ steps.install-sccache.outputs.cache-dir }} 75 | key: ${{ runner.os }}-sccache-${{ github.ref }}-${{ github.sha }} 76 | restore-keys: | 77 | ${{ runner.os }}-sccache-${{ github.ref }} 78 | ${{ runner.os }}-sccache- 79 | 80 | - name: CMake Generate 81 | run: | 82 | cmake \ 83 | -B "${{ env.CMAKE_BUILD_DIR }}" \ 84 | -G "${{ env.CMAKE_GENERATOR }}" \ 85 | -D CMAKE_C_COMPILER_LAUNCHER="${{ steps.install-sccache.outputs.path }}" \ 86 | -D CMAKE_CXX_COMPILER_LAUNCHER="${{ steps.install-sccache.outputs.path }}" \ 87 | -D CMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ 88 | -D FOCUSRITE_E2E_FETCH_JUCE=ON \ 89 | -D FOCUSRITE_E2E_MAKE_TESTS=ON 90 | 91 | - name: CMake Build 92 | run: | 93 | cmake \ 94 | --build "${{ env.CMAKE_BUILD_DIR }}" \ 95 | --config "${{ env.CMAKE_BUILD_CONFIG }}" 96 | 97 | - name: Save sccache 98 | if: ${{ steps.restore-sccache.outputs.cache-hit != 'true' && always() }} 99 | uses: actions/cache/save@v4 100 | with: 101 | path: ${{ steps.install-sccache.outputs.cache-dir }} 102 | key: ${{ runner.os }}-sccache-${{ github.ref }}-${{ github.sha }} 103 | 104 | - name: Cache CMake build directory 105 | if: ${{ steps.restore-cmake-cache.outputs.cache-hit != 'true' && always() }} 106 | uses: actions/cache/save@v4 107 | with: 108 | path: ${{ env.CMAKE_BUILD_DIR }} 109 | key: ${{ runner.os }}-cmake-${{ github.ref }}-${{ github.sha }} 110 | 111 | - name: CTest 112 | run: | 113 | ctest \ 114 | --test-dir "${{ env.CMAKE_BUILD_DIR }}" \ 115 | --build-config "${{ env.CMAKE_BUILD_CONFIG }}" \ 116 | --output-on-failure 117 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | run-name: '🚀 Publish' 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | jobs: 13 | npm-publish: 14 | runs-on: ubuntu-latest 15 | environment: npm-publish-public 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: latest 25 | cache: npm 26 | 27 | - name: NPM Install 28 | run: npm install 29 | 30 | - name: Compile TypeScript 31 | run: npx tsc 32 | 33 | - name: Publish to NPM 34 | run: | 35 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_ACCESS_TOKEN }}" >> ~/.npmrc 36 | npm publish ${{ vars.NPM_PUBLISH_ACCESS }} 37 | 38 | - name: Create GitHub Release 39 | env: 40 | GH_TOKEN: ${{ github.token }} 41 | run: | 42 | gh release create \ 43 | ${{ github.ref_name }} \ 44 | --generate-notes \ 45 | --latest=true \ 46 | --title "${{ github.ref_name }}" \ 47 | --verify-tag 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | cmake-build 3 | cmake-build-debug 4 | cmake-build-release 5 | .idea 6 | .vscode 7 | node_modules 8 | dist 9 | .DS_Store 10 | logs 11 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required (VERSION 3.20) 2 | 3 | option (FOCUSRITE_E2E_MAKE_TESTS "Build example app") 4 | option (FOCUSRITE_E2E_FETCH_JUCE "Download JUCE") 5 | 6 | set (CMAKE_DEBUG_POSTFIX d) 7 | 8 | if (FOCUSRITE_E2E_FETCH_JUCE) 9 | include (cmake/fetch-juce.cmake) 10 | endif () 11 | 12 | include (cmake/set_common_target_properties.cmake) 13 | 14 | project (focusrite-e2e-testing) 15 | 16 | include (CTest) 17 | enable_testing () 18 | 19 | add_subdirectory (source/cpp) 20 | 21 | if (FOCUSRITE_E2E_MAKE_TESTS) 22 | if (WIN32) 23 | set (NPM_COMMAND npm.cmd) 24 | set (NPX_COMMAND npx.cmd) 25 | else() 26 | set (NPM_COMMAND npm) 27 | set (NPX_COMMAND npx) 28 | endif() 29 | 30 | add_test( 31 | NAME npm-install 32 | WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} 33 | COMMAND ${NPM_COMMAND} install 34 | ) 35 | set_tests_properties(npm-install 36 | PROPERTIES 37 | FIXURES_SETUP NPM_INSTALL 38 | ) 39 | 40 | add_subdirectory (example/app) 41 | 42 | add_test( 43 | NAME test-ts-lib 44 | WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} 45 | COMMAND ${NPX_COMMAND} jest "${CMAKE_SOURCE_DIR}/tests" 46 | ) 47 | set_tests_properties(test-ts-lib 48 | PROPERTIES 49 | FIXTURES_REQUIRED NPM_INSTALL 50 | ENVIRONMENT APP_CONFIGURATION=$ 51 | ) 52 | endif () 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | our [Discord server](https://www.ampifymusic.com/community). Please direct message any Ampify team member - all complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Focusrite Audio Engineering Limited 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JUCE End to End 2 | 3 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![Platform](https://img.shields.io/static/v1?label=Platform&message=macOS%20%7C%20windows&color=pink&style=flat)](./documentation/building.md) 4 | 5 | [![Language](https://img.shields.io/static/v1?label=Language&message=C%2B%2B&color=orange&style=flat)](./documentation/building.md) 6 | [![Code Style](https://img.shields.io/static/v1?label=Code%20Style&message=Clang%20Format&color=pink&style=flat)](https://clang.llvm.org/docs/ClangFormat.html) 7 | 8 | [![Language](https://img.shields.io/static/v1?label=Language&message=TypeScript&color=orange&style=flat)](./documentation/building.md) 9 | [![Code Style](https://img.shields.io/static/v1?label=Code%20Style&message=Prettier&color=pink&style=flat)](https://prettier.io) 10 | 11 | [![Language](https://img.shields.io/static/v1?label=Language&message=CMake&color=orange&style=flat)](https://www.cmake.org) 12 | [![Code Style](https://img.shields.io/static/v1?label=Code%20Style&message=CMake%20Format&color=pink&style=flat)](https://github.com/cheshirekow/cmake_format) 13 | 14 | A framework for end-to-end testing JUCE applications using JavaScript. 15 | 16 | ## Prerequisites 17 | 18 | - [JUCE](https://juce.com) 6 or later 19 | - [CMake](https://cmake.org) 3.18 or higher 20 | 21 | ## Integration guide 22 | 23 | See the full [Integration Guide](./documentation/integration-guide.md) for a detailed walkthrough - the major steps are: 24 | 25 | 1. Add the `focusrite-e2e` library to your JUCE application using CMake 26 | 1. Write a `TestCentre` to allow JavaScript to communicate with your app 27 | 1. Create an `AppConnection` object in your favourite JavaScript test framework and use its various methods to test your app 28 | 29 | See the [example app](./example/) for an example of how to integrate this framework in your JUCE app. 30 | 31 | Watch Joe's ADC talk for an even more detailed explanation of the framework, and to see it in action testing Ampify Studio! 32 | 33 | [![Joe's ADC Talk](https://img.youtube.com/vi/3gi7CO71414/0.jpg)](https://www.youtube.com/watch?v=3gi7CO71414) 34 | 35 | ## Code formatting 36 | 37 | We use a variety of code formatting tools. Please make sure you have these installed on your system to keep the codebase styling consistent. 38 | 39 | - [C++](./documentation/cplusplus.md) 40 | - [JavaScript](./documentation/javascript.md) 41 | - [CMake](./documentation/cmake.md) 42 | 43 | ## Scripts 44 | 45 | We have a variety of scripts available in our package.json. 46 | -------------------------------------------------------------------------------- /cmake/FocusriteE2EConfig.cmake: -------------------------------------------------------------------------------- 1 | find_package (PkgConfig) 2 | pkg_check_modules (PC_FOCUSRITE_E2E QUIET FocusriteE2E) 3 | 4 | set (FOCUSRITE_E2E_ROOT_DIR ${FocusriteE2E_DIR}/../..) 5 | 6 | if (APPLE) 7 | set (LIBRARY_LOCATION ${FOCUSRITE_E2E_ROOT_DIR}/lib/libfocusrite-e2e.a) 8 | set (LIBRARY_LOCATION_DEBUG ${FOCUSRITE_E2E_ROOT_DIR}/lib/libfocusrite-e2ed.a) 9 | elseif (WIN32) 10 | set (LIBRARY_LOCATION ${FOCUSRITE_E2E_ROOT_DIR}/lib/focusrite-e2e.lib) 11 | set (LIBRARY_LOCATION_DEBUG ${FOCUSRITE_E2E_ROOT_DIR}/lib/focusrite-e2ed.lib) 12 | endif () 13 | 14 | mark_as_advanced (FOCUSRITE_E2E_FOUND FOCUSRITE_E2E_ROOT_DIR) 15 | 16 | include (FindPackageHandleStandardArgs) 17 | find_package_handle_standard_args (FocusriteE2E 18 | REQUIRED_VARS FOCUSRITE_E2E_ROOT_DIR) 19 | 20 | if (FocusriteE2E_FOUND AND NOT focusrite-e2e::focusrite-e2e) 21 | add_library (focusrite-e2e::focusrite-e2e STATIC IMPORTED) 22 | 23 | target_include_directories (focusrite-e2e::focusrite-e2e 24 | INTERFACE ${FOCUSRITE_E2E_ROOT_DIR}/include) 25 | 26 | set_target_properties (focusrite-e2e::focusrite-e2e 27 | PROPERTIES IMPORTED_LOCATION ${LIBRARY_LOCATION}) 28 | set_target_properties ( 29 | focusrite-e2e::focusrite-e2e PROPERTIES IMPORTED_LOCATION_DEBUG 30 | ${LIBRARY_LOCATION_DEBUG}) 31 | endif () 32 | -------------------------------------------------------------------------------- /cmake/fetch-juce.cmake: -------------------------------------------------------------------------------- 1 | include (FetchContent) 2 | 3 | fetchcontent_declare (juce 4 | GIT_REPOSITORY https://github.com/juce-framework/JUCE 5 | GIT_TAG 8.0.7 6 | GIT_SUBMODULES "" 7 | ) 8 | fetchcontent_makeavailable (juce) 9 | -------------------------------------------------------------------------------- /cmake/set_common_target_properties.cmake: -------------------------------------------------------------------------------- 1 | function (set_common_target_properties TARGET) 2 | set_target_properties (${TARGET} PROPERTIES CXX_STANDARD 17) 3 | target_compile_definitions (${TARGET} PRIVATE $<$:DEBUG>) 4 | target_link_libraries (${TARGET} 5 | PUBLIC 6 | juce::juce_recommended_config_flags 7 | juce::juce_recommended_lto_flags 8 | juce::juce_recommended_warning_flags 9 | ) 10 | 11 | if (APPLE) 12 | target_compile_options (${TARGET} PRIVATE -Wextra -Werror) 13 | endif () 14 | 15 | if (MSVC) 16 | target_compile_options (${TARGET} PRIVATE /WX) 17 | endif () 18 | endfunction () 19 | -------------------------------------------------------------------------------- /dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "07:00" 10 | groups: 11 | npm-package-updates: 12 | applies-to: version-updates 13 | patterns: 14 | - "package-lock.json" 15 | - "package.json" 16 | -------------------------------------------------------------------------------- /documentation/cmake.md: -------------------------------------------------------------------------------- 1 | # CMake 2 | 3 | For _CMake_ formatting we use [_cmake-format_](https://github.com/cheshirekow/cmake_format) 4 | 5 | ## Installation 6 | 7 | ```sh 8 | pip install cmakelang 9 | ``` 10 | 11 | ## Format on save in VScode 12 | 13 | To have format on save in VSCode, you can install the [cmake-format](https://marketplace.visualstudio.com/items?itemName=cheshirekow.cmake-format) extension. 14 | 15 | You will then need to update your VSCode [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file, to include the following: 16 | 17 | ```json 18 | "files.associations": { 19 | "CMakeLists.txt": "cmake", 20 | "*.cmake": "cmake" 21 | }, 22 | "[cmake]": { 23 | "editor.formatOnSave": true, 24 | "editor.defaultFormatter": "cheshirekow.cmake-format" 25 | }, 26 | ``` 27 | -------------------------------------------------------------------------------- /documentation/cplusplus.md: -------------------------------------------------------------------------------- 1 | # C++ 2 | 3 | For _C++_ we use _clang-format_. 4 | 5 | ## Installation (XCode) 6 | 7 | ```sh 8 | brew install clang-format 9 | ``` 10 | 11 | ## Installation (CLion) 12 | 13 | If you are using _CLion_, code formatting is automatically provided by the IDE, because we have a `.clang-format` file in the root of the directory. 14 | In order to enable this behaviour, you need to 15 | 16 | Use the save actions plug-in to enable format on save. 17 | The settings are: 18 | 19 | - 'Activate save action on save' should be enabled 20 | - Formatting actions: 'Re-format file' should be enabled 21 | - File path inclusions should have: modules /_and source/_ 22 | - File path exclusions should have: CMakeLists.txt and lib/* 23 | 24 | ## Installation (Visual Studio 2019) 25 | 26 | _Visual Studio 2019_ has built in clang-format support, but it is a very old version. To use a more modern version, you need to install the [clang tools](http://llvm.org/releases/) then add `%LLVM%\bin` to your path (`%LLVM%` is where you installed to) 27 | -------------------------------------------------------------------------------- /documentation/integration-guide.md: -------------------------------------------------------------------------------- 1 | # Integration Guide 2 | 3 | To run end-to-end tests on your application, you need to integrate the C++ 4 | library, as well as using the JavaScript package. 5 | 6 | ## C++ Library 7 | 8 | ### Option 1: CMake (using a submodule) 9 | 10 | 1. Add [FocusriteGroup/juce-end-to-end](https://github.com/FocusriteGroup/juce-end-to-end) 11 | as a submodule to your project (for example, to `lib/juce-end-to-end`) 12 | 1. In your CMakeLists.txt, add the directory (after JUCE has been made 13 | available) 14 | 15 | ```CMake 16 | add_subdirectory (lib/juce-end-to-end) 17 | ``` 18 | 19 | 1. Link your target with the `focusrite-e2e::focusrite-e2e` target 20 | 21 | ```CMake 22 | target_link_libraries (MyApp PRIVATE focusrite-e2e::focusrite-e2e) 23 | ``` 24 | 25 | ### Option 2: CMake (using FetchContent) 26 | 27 | 1. Add the following to your CMake project to fetch juce-end-to-end, swapping the version for the version that you wish to use. 28 | This should be done after JUCE has been made available. 29 | 30 | ```CMake 31 | include (FetchContent) 32 | FetchContent_Declare (juce-end-to-end 33 | GIT_REPOSITORY https://github.com/FocusriteGroup/juce-end-to-end 34 | GIT_TAG v0.0.15) 35 | 36 | FetchContent_MakeAvailable (juce) 37 | ``` 38 | 39 | 1. Link your target with the `focusrite-e2e::focusrite-e2e` target 40 | 41 | ```CMake 42 | target_link_libraries (MyApp PRIVATE focusrite-e2e::focusrite-e2e) 43 | ``` 44 | 45 | ### Option 3: Manually 46 | 47 | 1. Add [FocusriteGroup/juce-end-to-end](https://github.com/FocusriteGroup/juce-end-to-end) 48 | as a submodule to your project 49 | 1. Add the enclosed `include` folder to your include path 50 | 1. Compile all sources in the `sources` folder 51 | 1. Ensure `juce/modules` is available on your include path 52 | 1. Ensure JUCE is linked with your executable 53 | 54 | ### Create a `TestCentre` 55 | 56 | All you need to do to make your application testable is create a 57 | `focusrite::e2e::TestCentre` that has the same lifecycle as your application. 58 | For example: 59 | 60 | ```C++ 61 | #include 62 | 63 | class Application : public JUCEApplication 64 | { 65 | public: 66 | 67 | void initialise (const juce::String &) override 68 | { 69 | testCentre = focusrite::e2e::TestCentre::create (); 70 | } 71 | 72 | void shutdown () override 73 | { 74 | testCentre.reset (); 75 | } 76 | 77 | private: 78 | std::unique_ptr testCentre; 79 | } 80 | ``` 81 | 82 | If you wish to reference a `Component` by ID, you can set the component ID on the component: 83 | 84 | ```C++ 85 | myComponent.setComponentID ("my-component-id"); 86 | ``` 87 | 88 | If you happen to be using the component ID for something else, you can set the test ID as follows: 89 | 90 | ```C++ 91 | #include 92 | // ... 93 | focusrite::e2e::ComponentSearch::setTestId ("my-component-id"); 94 | ``` 95 | 96 | If you wish to install a custom request handler in addition to the default one, 97 | you can do so as follows: 98 | 99 | ```C++ 100 | #include 101 | 102 | class MyCommandHandler : public focusrite::e2e::CommandHandler 103 | { 104 | public: 105 | 106 | std::optional process (const focusrite::e2e::Command & command) override 107 | { 108 | if (command.getType () == "my-custom-type-1") 109 | return generateResponse1 (command); 110 | else if (command.getType () == "my-custom-type-2") 111 | return generateResponse2 (command); 112 | 113 | return std::nullopt; 114 | } 115 | }; 116 | 117 | MyCommandHandler commandHandler; 118 | testCentre->addCommandHandler (commandHandler); 119 | ``` 120 | 121 | You can also send custom events at any time, without needing to wait for a 122 | request: 123 | 124 | ```C++ 125 | const auto event = focusrite::e2e::Event ("something-happened") 126 | .withParameter("count", 5)); 127 | testCentre->sendEvent (event); 128 | ``` 129 | 130 | ## JavaScript library 131 | 132 | The JavaScript library provides utilities to start the application, send it 133 | commands and query its state. You can use it with any testing/assertion library 134 | (e.g. Jest, Mocha). The examples below are written in TypeScript using Jest. 135 | 136 | 1. Install Node (you can use a Node installer, a system package manager, or a Node version manager) 137 | 2. Initialise an npm package at the root of your repository using 138 | 139 | ```sh 140 | npm init 141 | ``` 142 | 143 | 3. Install the library using npm 144 | 145 | ```sh 146 | npm install @focusritegroup/juce-end-to-end 147 | ``` 148 | 149 | 4. Install a test framework (e.g. Jest) 150 | 5. In your test setup, before each test, create an `AppConnection` (passing it 151 | the path to your built application) and wait for it to launch. You can pass the 152 | app extra arguments or set environment variables if you need to put it into 153 | special state. 154 | 155 | ```TypeScript 156 | import {AppConnection} from '@focusritegroup/juce-end-to-end'; 157 | 158 | describe('My app tests', () => { 159 | let appConnection: AppConnection; 160 | 161 | beforeEach(async () => { 162 | appConnection = new AppConnection({appPath: 'path/to/app/binary'}); 163 | await appConnection.launch(); 164 | }); 165 | } 166 | ``` 167 | 168 | 6. After each test, quit the app and wait for it to shut down: 169 | 170 | ```TypeScript 171 | afterEach(async () => { 172 | await appConnection.quit(); 173 | }); 174 | ``` 175 | 176 | 7. During each test, you can send the application commands to query the state 177 | of the user interface. Here is a simple example: 178 | 179 | ```TypeScript 180 | it('Increments using the increment button', async () => { 181 | const valueBefore = await appConnection.getComponentText('value-label'); 182 | expect(valueBefore).toEqual('0'); 183 | 184 | await appConnection.clickComponent('increment-button'); 185 | 186 | const valueAfter = await appConnection.getComponentText('value-label'); 187 | expect(valueAfter).toEqual('1'); 188 | }); 189 | ``` 190 | 191 | See the documentation for `AppConnection` for the full range of supported 192 | commands and responses. If you need to extend this, you can send custom commands, 193 | as long as it can be serialised to JSON using 194 | `appConnection.sendCommand (myCustomCommand);` 195 | -------------------------------------------------------------------------------- /documentation/javascript.md: -------------------------------------------------------------------------------- 1 | # JavaScript/TypeScript 2 | 3 | For _JavaScript_, _TypeScript_ and _HTML_ and other similar languages, we use Prettier. 4 | 5 | ## Installation 6 | 7 | [TODO] 8 | 9 | ## Format on save in VScode 10 | 11 | Install the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode). 12 | 13 | You will then need to update your VSCode [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file, to include the following: 14 | 15 | ```json 16 | "[typescriptreact]": { 17 | "editor.tabSize": 2, 18 | "editor.formatOnSave": true, 19 | "editor.defaultFormatter": "esbenp.prettier-vscode" 20 | }, 21 | "[javascript]": { 22 | "editor.tabSize": 2, 23 | "editor.formatOnSave": true, 24 | "editor.defaultFormatter": "esbenp.prettier-vscode" 25 | }, 26 | "[typescript]": { 27 | "editor.tabSize": 2, 28 | "editor.formatOnSave": true, 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | }, 31 | "[json]": { 32 | "editor.defaultFormatter": "esbenp.prettier-vscode" 33 | }, 34 | "[html]": { 35 | "editor.defaultFormatter": "esbenp.prettier-vscode" 36 | }, 37 | ``` 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const eslint = require('@typescript-eslint/eslint-plugin'); 2 | const parser = require('@typescript-eslint/parser'); 3 | const {configs} = require('@eslint/js'); 4 | 5 | module.exports = [ 6 | configs.recommended, 7 | {ignores: ['**/node_modules/*', '**/dist/*']}, 8 | { 9 | rules: { 10 | 'no-undef': 'off', 11 | 'no-unused-vars': 'off', 12 | }, 13 | languageOptions: { 14 | parser: parser, 15 | }, 16 | plugins: {eslint}, 17 | files: ['**/*.ts', '**/*.tsx', '**/*.js'], 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /example/app/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | juce_add_gui_app (e2e-example-app VERSION 0.0.0) 2 | 3 | target_sources ( 4 | e2e-example-app PRIVATE source/Application.h source/Main.cpp 5 | source/MainComponent.h source/MainWindow.h) 6 | 7 | target_include_directories (e2e-example-app PRIVATE source) 8 | 9 | target_link_libraries (e2e-example-app PRIVATE focusrite-e2e) 10 | 11 | target_compile_definitions ( 12 | e2e-example-app 13 | PRIVATE 14 | DONT_SET_USING_JUCE_NAMESPACE=1 15 | JUCE_WEB_BROWSER=0 16 | JUCE_USE_CURL=0 17 | JUCE_APPLICATION_NAME_STRING="$" 18 | JUCE_APPLICATION_VERSION_STRING="$" 19 | ) 20 | 21 | target_link_libraries (e2e-example-app 22 | PRIVATE 23 | juce::juce_gui_extra 24 | ) 25 | 26 | set_target_properties (e2e-example-app PROPERTIES CXX_STANDARD 17) 27 | 28 | add_test( 29 | NAME e2e-test-example-app 30 | WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} 31 | COMMAND ${NPX_COMMAND} jest "${CMAKE_SOURCE_DIR}/example/tests" 32 | ) 33 | set_tests_properties(e2e-test-example-app 34 | PROPERTIES 35 | FIXTURES_REQUIRED NPM_INSTALL 36 | ENVIRONMENT APP_CONFIGURATION=$ 37 | ) 38 | 39 | set_common_target_properties (e2e-example-app) 40 | -------------------------------------------------------------------------------- /example/app/source/Application.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "CustomCommandHandler.h" 4 | #include "MainWindow.h" 5 | 6 | #include 7 | #include 8 | 9 | class Application final : public juce::JUCEApplication 10 | { 11 | public: 12 | const juce::String getApplicationName () override // NOLINT 13 | { 14 | return "E2E Test Application"; 15 | } 16 | 17 | const juce::String getApplicationVersion () override // NOLINT 18 | { 19 | return "0.0.0"; 20 | } 21 | 22 | void initialise ([[maybe_unused]] const juce::String & commandLineArguments) override 23 | { 24 | _testCentre = focusrite::e2e::TestCentre::create (); 25 | _testCentre->addCommandHandler (_customCommandHandler); 26 | 27 | _mainWindow.setVisible (true); 28 | } 29 | 30 | void shutdown () override 31 | { 32 | _mainWindow.setVisible (false); 33 | 34 | _testCentre.reset (); 35 | } 36 | 37 | private: 38 | MainWindow _mainWindow; 39 | CustomCommandHandler _customCommandHandler; 40 | std::unique_ptr _testCentre; 41 | }; 42 | -------------------------------------------------------------------------------- /example/app/source/CustomCommandHandler.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | class CustomCommandHandler : public focusrite::e2e::CommandHandler 7 | { 8 | public: 9 | std::optional 10 | process (const focusrite::e2e::Command & command) override 11 | { 12 | if (command.getType () == "abort") 13 | std::abort (); 14 | 15 | return {}; 16 | } 17 | }; -------------------------------------------------------------------------------- /example/app/source/Main.cpp: -------------------------------------------------------------------------------- 1 | #include "Application.h" 2 | 3 | START_JUCE_APPLICATION (Application) -------------------------------------------------------------------------------- /example/app/source/MainComponent.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | class MainComponent final : public juce::Component 6 | { 7 | public: 8 | MainComponent () 9 | { 10 | static constexpr auto width = 400; 11 | static constexpr auto height = 240; 12 | setSize (width, height); 13 | 14 | _incrementButton.onClick = [this] { increment (); }; 15 | _decrementButton.onClick = [this] { decrement (); }; 16 | _enableButton.onClick = [this] { toggleEnableButton (); }; 17 | 18 | addAndMakeVisible (_incrementButton); 19 | addAndMakeVisible (_decrementButton); 20 | addAndMakeVisible (_enableButton); 21 | addAndMakeVisible (_valueLabel); 22 | addAndMakeVisible (_slider); 23 | addAndMakeVisible (_textEditor); 24 | addAndMakeVisible (_comboBox); 25 | 26 | _valueLabel.setJustificationType (juce::Justification::centred); 27 | _valueLabel.setColour (juce::Label::textColourId, juce::Colours::black); 28 | 29 | updateLabel (); 30 | 31 | _incrementButton.setComponentID ("increment-button"); 32 | _decrementButton.setComponentID ("decrement-button"); 33 | _enableButton.setComponentID ("enable-button"); 34 | _valueLabel.setComponentID ("value-label"); 35 | _slider.setComponentID ("slider"); 36 | _textEditor.setComponentID ("text-editor"); 37 | _comboBox.setComponentID ("combo-box"); 38 | 39 | _textEditor.onTextChange = [this] 40 | { 41 | const auto text = _textEditor.getText (); 42 | setValue (text.getIntValue ()); 43 | }; 44 | 45 | _slider.onValueChange = [this] { setValue (static_cast (_slider.getValue ())); }; 46 | 47 | _incrementButton.setTitle ("Increment button title"); 48 | _incrementButton.setDescription ("Increment button description"); 49 | _incrementButton.setHelpText ("Increment button help text"); 50 | _incrementButton.setTooltip ("Increment button tool tip"); 51 | 52 | _decrementButton.setAccessible (false); 53 | 54 | _comboBox.addItem ("First", 1); 55 | _comboBox.addItem ("Second", 2); 56 | _comboBox.addItem ("Third", 3); 57 | _comboBox.setSelectedItemIndex (0); 58 | } 59 | 60 | void resized () override 61 | { 62 | juce::FlexBox flexBox; 63 | flexBox.flexDirection = juce::FlexBox::Direction::column; 64 | 65 | const auto spacer = juce::FlexItem ().withHeight (8.f); 66 | static constexpr auto rowHeight = 30.f; 67 | 68 | flexBox.items = { 69 | juce::FlexItem (_enableButton).withHeight (rowHeight), 70 | spacer, 71 | juce::FlexItem (_incrementButton).withHeight (rowHeight), 72 | spacer, 73 | juce::FlexItem (_decrementButton).withHeight (rowHeight), 74 | spacer, 75 | juce::FlexItem (_slider).withHeight (rowHeight), 76 | spacer, 77 | juce::FlexItem (_textEditor).withHeight (rowHeight), 78 | spacer, 79 | juce::FlexItem (_valueLabel).withHeight (rowHeight), 80 | spacer, 81 | juce::FlexItem (_comboBox).withHeight (rowHeight), 82 | }; 83 | 84 | static constexpr auto margin = 10; 85 | flexBox.performLayout (getLocalBounds ().reduced (margin)); 86 | } 87 | 88 | private: 89 | void increment () 90 | { 91 | setValue (_value + 1); 92 | } 93 | 94 | void decrement () 95 | { 96 | setValue (_value - 1); 97 | } 98 | 99 | void setValue (int newValue) 100 | { 101 | if (newValue != _value) 102 | { 103 | _value = newValue; 104 | _slider.setValue (_value, juce::NotificationType::dontSendNotification); 105 | updateLabel (); 106 | } 107 | } 108 | 109 | void updateLabel () 110 | { 111 | _valueLabel.setText (juce::String (_value), juce::dontSendNotification); 112 | } 113 | 114 | void toggleEnableButton () 115 | { 116 | auto willEnable = ! _incrementButton.isEnabled (); 117 | _incrementButton.setEnabled (willEnable); 118 | _decrementButton.setEnabled (willEnable); 119 | _slider.setEnabled (willEnable); 120 | _textEditor.setEnabled (willEnable); 121 | _enableButton.setButtonText (_incrementButton.isEnabled () ? "Disable" : "Enable"); 122 | } 123 | 124 | int _value = 0; 125 | juce::TextButton _incrementButton {"Increment"}; 126 | juce::TextButton _decrementButton {"Decrement"}; 127 | juce::TextButton _enableButton {"Disable"}; 128 | juce::Label _valueLabel; 129 | juce::Slider _slider {juce::Slider::LinearHorizontal, juce::Slider::NoTextBox}; 130 | juce::TextEditor _textEditor; 131 | juce::ComboBox _comboBox; 132 | }; 133 | -------------------------------------------------------------------------------- /example/app/source/MainWindow.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "MainComponent.h" 4 | 5 | #include 6 | 7 | class MainWindow final : public juce::DocumentWindow 8 | { 9 | public: 10 | MainWindow () 11 | : juce::DocumentWindow (juce::JUCEApplication::getInstance ()->getApplicationName (), 12 | juce::Colours::white, 13 | juce::DocumentWindow::allButtons) 14 | { 15 | setComponentID ("main-window"); 16 | setContentNonOwned (&_mainComponent, true); 17 | setUsingNativeTitleBar (true); 18 | setResizable (true, true); 19 | toFront (true); 20 | } 21 | 22 | void closeButtonPressed () override 23 | { 24 | juce::JUCEApplication::quit (); 25 | } 26 | 27 | private: 28 | MainComponent _mainComponent; 29 | }; -------------------------------------------------------------------------------- /example/tests/accessibility.spec.ts: -------------------------------------------------------------------------------- 1 | import {AppConnection} from '../../source/ts'; 2 | import {ComponentHandle} from '../../source/ts/component-handle'; 3 | import {appPath} from './app-path'; 4 | 5 | describe('Accessibility tests', () => { 6 | let appConnection: AppConnection; 7 | let valueLabel: ComponentHandle; 8 | let incrementButton: ComponentHandle; 9 | let decrementButton: ComponentHandle; 10 | let enableButton: ComponentHandle; 11 | let slider: ComponentHandle; 12 | let window: ComponentHandle; 13 | 14 | beforeEach(async () => { 15 | appConnection = new AppConnection({appPath}); 16 | valueLabel = appConnection.getComponent('value-label'); 17 | incrementButton = appConnection.getComponent('increment-button'); 18 | decrementButton = appConnection.getComponent('decrement-button'); 19 | enableButton = appConnection.getComponent('enable-button'); 20 | slider = appConnection.getComponent('slider'); 21 | window = appConnection.getComponent('main-window'); 22 | 23 | await appConnection.launch(); 24 | await valueLabel.waitToBeVisible(); 25 | }); 26 | 27 | afterEach(async () => { 28 | await appConnection.quit(); 29 | }); 30 | 31 | it('Increment button is accessible', async () => { 32 | expect(incrementButton.getAccessibilityState()).resolves.toEqual({ 33 | title: 'Increment button title', 34 | description: 'Increment button description', 35 | help: 'Increment button tool tip', 36 | accessible: true, 37 | handler: true, 38 | display: '', 39 | }); 40 | }); 41 | 42 | it('Decrement button is not accessible', async () => { 43 | const state = await decrementButton.getAccessibilityState(); 44 | expect(state.accessible).toBeFalsy(); 45 | expect(state.handler).toBeFalsy(); 46 | }); 47 | 48 | it('Enable button is enabled for accessibility but has no text', async () => { 49 | const state = await enableButton.getAccessibilityState(); 50 | expect(state.accessible).toBeTruthy(); 51 | expect(state.handler).toBeTruthy(); 52 | }); 53 | 54 | it('Slider is enabled for accessibility but has no text', async () => { 55 | const state = await slider.getAccessibilityState(); 56 | expect(state.accessible).toBeTruthy(); 57 | expect(state.handler).toBeTruthy(); 58 | expect(parseFloat(state.display)).toEqual(0); 59 | }); 60 | 61 | it('Slider display changes with the value change', async () => { 62 | expect(parseFloat((await slider.getAccessibilityState()).display)).toEqual( 63 | 0 64 | ); 65 | 66 | await incrementButton.click(); 67 | expect(parseFloat((await slider.getAccessibilityState()).display)).toEqual( 68 | 1 69 | ); 70 | 71 | await decrementButton.click(); 72 | expect(parseFloat((await slider.getAccessibilityState()).display)).toEqual( 73 | 0 74 | ); 75 | }); 76 | 77 | it('Increment button has accessibility parent', async () => { 78 | expect(incrementButton.getAccessibilityParent()).resolves.toEqual( 79 | window.componentID 80 | ); 81 | expect(enableButton.getAccessibilityParent()).resolves.toEqual( 82 | window.componentID 83 | ); 84 | expect(slider.getAccessibilityParent()).resolves.toEqual( 85 | window.componentID 86 | ); 87 | }); 88 | 89 | it('Decrement button has no accessibility parent', async () => { 90 | expect(decrementButton.getAccessibilityParent()).resolves.toEqual(''); 91 | }); 92 | 93 | it('Main window has multiple accessible children', async () => { 94 | const children = await window.getAccessibilityChildren(); 95 | expect(children.length).toBeGreaterThan(0); 96 | expect(children).toContainEqual(incrementButton.componentID); 97 | expect(children).toContainEqual(enableButton.componentID); 98 | expect(children).toContainEqual(slider.componentID); 99 | expect(children.includes(decrementButton.componentID)).toBeFalsy(); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /example/tests/app-path.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | const configuration = process.env['APP_CONFIGURATION'] || 'Release'; 4 | const repoRoot = path.join(__dirname, '..', '..'); 5 | const artefactDir = path.join( 6 | repoRoot, 7 | 'cmake-build/example/app/e2e-example-app_artefacts' 8 | ); 9 | const configDir = path.join(artefactDir, configuration); 10 | 11 | export const appPath = 12 | process.env['APP_PATH'] || 13 | (process.platform === 'darwin' 14 | ? path.join(configDir, 'e2e-example-app.app/Contents/MacOS/e2e-example-app') 15 | : path.join(configDir, 'e2e-example-app.exe')); 16 | -------------------------------------------------------------------------------- /example/tests/counter-app.spec.ts: -------------------------------------------------------------------------------- 1 | import {AppConnection} from '../../source/ts'; 2 | import {appPath} from './app-path'; 3 | import {ComponentHandle} from '../../source/ts/component-handle'; 4 | 5 | describe('Count App tests', () => { 6 | let appConnection: AppConnection; 7 | let valueLabel: ComponentHandle; 8 | let incrementButton: ComponentHandle; 9 | let decrementButton: ComponentHandle; 10 | let enableButton: ComponentHandle; 11 | let slider: ComponentHandle; 12 | let textEditor: ComponentHandle; 13 | let comboBox: ComponentHandle; 14 | 15 | beforeEach(async () => { 16 | appConnection = new AppConnection({appPath}); 17 | valueLabel = appConnection.getComponent('value-label'); 18 | incrementButton = appConnection.getComponent('increment-button'); 19 | decrementButton = appConnection.getComponent('decrement-button'); 20 | enableButton = appConnection.getComponent('enable-button'); 21 | slider = appConnection.getComponent('slider'); 22 | textEditor = appConnection.getComponent('text-editor'); 23 | comboBox = appConnection.getComponent('combo-box'); 24 | 25 | await appConnection.launch(); 26 | await valueLabel.waitToBeVisible(); 27 | }); 28 | 29 | afterEach(async () => { 30 | await appConnection.quit(); 31 | }); 32 | 33 | it('starts at 0', async () => { 34 | expect(valueLabel.getText()).resolves.toEqual('0'); 35 | }); 36 | 37 | it('increments using the increment button', async () => { 38 | await incrementButton.click(); 39 | expect(valueLabel.getText()).resolves.toEqual('1'); 40 | }); 41 | 42 | it('decrements using the decrement button', async () => { 43 | await decrementButton.click(); 44 | expect(valueLabel.getText()).resolves.toEqual('-1'); 45 | }); 46 | 47 | it('can be disabled', async () => { 48 | expect(incrementButton.getEnablement()).resolves.toBeTruthy(); 49 | expect(decrementButton.getEnablement()).resolves.toBeTruthy(); 50 | 51 | await enableButton.click(); 52 | 53 | expect(incrementButton.getEnablement()).resolves.toBeFalsy(); 54 | expect(decrementButton.getEnablement()).resolves.toBeFalsy(); 55 | }); 56 | 57 | it('sets value using the slider', async () => { 58 | expect(slider.getSliderValue()).resolves.toBe(0); 59 | 60 | const expectedValue = 6; 61 | slider.setSliderValue(expectedValue); 62 | expect(slider.getSliderValue()).resolves.toBe(expectedValue); 63 | }); 64 | 65 | it('sets value using the text editor', async () => { 66 | const expectedValue = '789'; 67 | await textEditor.setTextEditorText(expectedValue); 68 | expect(valueLabel.getText()).resolves.toBe(expectedValue); 69 | }); 70 | 71 | it('sets value using the combo-box', async () => { 72 | const expectedValue = 2; 73 | await comboBox.setComboBoxSelectedItemIndex(expectedValue); 74 | expect(comboBox.getComboBoxSelectedItemIndex()).resolves.toBe( 75 | expectedValue 76 | ); 77 | }); 78 | 79 | it('checks that all values in combox box are present and in the correct order', async () => { 80 | expect(comboBox.getComboBoxItems()).resolves.toStrictEqual([ 81 | 'First', 82 | 'Second', 83 | 'Third', 84 | ]); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /example/tests/failure-modes.spec.ts: -------------------------------------------------------------------------------- 1 | import {AppConnection} from '../../source/ts'; 2 | import {ComponentHandle} from '../../source/ts/component-handle'; 3 | import {appPath} from './app-path'; 4 | 5 | describe('invalid component', () => { 6 | let app: AppConnection; 7 | 8 | beforeEach(async () => { 9 | app = new AppConnection({appPath, logDirectory: 'logs'}); 10 | await app.launch(); 11 | }); 12 | 13 | afterEach(async () => { 14 | await app.quit(); 15 | }); 16 | 17 | it.failing('fails when waiting for invalid components', async () => { 18 | await app.getComponent('invalid').waitToBeVisible(100); 19 | }); 20 | 21 | it.failing('fails when waiting for an event that never happens', async () => { 22 | await app.waitForEvent('my-event', undefined, 100); 23 | }); 24 | }); 25 | 26 | it.failing('rejects requests after the app has quit', async () => { 27 | const app = new AppConnection({appPath, logDirectory: 'logs'}); 28 | await app.launch(); 29 | await app.quit(); 30 | await app.getComponent('value-label').waitToBeVisible(100); 31 | }); 32 | 33 | it.failing('fails when the app crashes', async () => { 34 | const app = new AppConnection({appPath, logDirectory: 'logs'}); 35 | await app.launch(); 36 | app.sendCommand({type: 'abort'}); 37 | await app.waitForExit(); 38 | }); 39 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type {Config} from 'jest'; 2 | 3 | const config: Config = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@focusritegroup/juce-end-to-end", 3 | "version": "1.2.1", 4 | "description": "End-to-end testing library for Focusrite", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist", 9 | "source" 10 | ], 11 | "directories": { 12 | "example": "example" 13 | }, 14 | "scripts": { 15 | "test-cpp": "cd cmake-build && ctest -j 8 -C Release --output-on-failure --output-junit unit-tests.xml", 16 | "lint": "eslint .", 17 | "build": "tsc", 18 | "test": "jest ./tests/**", 19 | "test-example": "jest ./example/tests/**" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git://github.com/FocusriteGroup/juce-end-to-end.git" 24 | }, 25 | "keywords": [], 26 | "author": "", 27 | "license": "Apache-2.0", 28 | "bugs": { 29 | "url": "https://github.com/FocusriteGroup/juce-end-to-end/issues" 30 | }, 31 | "homepage": "https://github.com/FocusriteGroup/juce-end-to-end#readme", 32 | "dependencies": { 33 | "minimatch": "^10.0.1", 34 | "uuid": "^10.0.0" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.16.0", 38 | "@babel/preset-env": "^7.15.8", 39 | "@babel/preset-typescript": "^7.27.1", 40 | "@types/glob": "^8.1.0", 41 | "@types/jest": "^29.5.14", 42 | "@types/minimatch": "^5.1.2", 43 | "@types/node": "^22.10.5", 44 | "@types/tar": "^6.1.1", 45 | "@types/uuid": "^10.0.0", 46 | "@typescript-eslint/eslint-plugin": "^8.19.1", 47 | "@typescript-eslint/parser": "^8.19.1", 48 | "babel-jest": "^29.0.3", 49 | "eslint": "^9.25.1", 50 | "glob": "^11.0.2", 51 | "jest": "^29.7.0", 52 | "tar": "^6.1.11", 53 | "ts-jest": "^29.3.2", 54 | "ts-node": "^10.4.0", 55 | "typescript": "^5.7.3" 56 | }, 57 | "engines": { 58 | "node": ">=20" 59 | }, 60 | "prettier": { 61 | "trailingComma": "es5", 62 | "tabWidth": 2, 63 | "semi": true, 64 | "singleQuote": true, 65 | "bracketSpacing": false, 66 | "quoteProps": "consistent", 67 | "arrowParens": "always", 68 | "printWidth": 80 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /source/cpp/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | add_library ( 2 | focusrite-e2e 3 | include/focusrite/e2e/ClickableComponent.h 4 | include/focusrite/e2e/Command.h 5 | include/focusrite/e2e/CommandHandler.h 6 | include/focusrite/e2e/ComponentSearch.h 7 | include/focusrite/e2e/Event.h 8 | include/focusrite/e2e/Response.h 9 | include/focusrite/e2e/TestCentre.h 10 | source/Command.cpp 11 | source/ComponentSearch.cpp 12 | source/Connection.cpp 13 | source/Connection.h 14 | source/DefaultCommandHandler.cpp 15 | source/DefaultCommandHandler.h 16 | source/Event.cpp 17 | source/KeyPress.cpp 18 | source/KeyPress.h 19 | source/Response.cpp 20 | source/TestCentre.cpp) 21 | 22 | add_library (focusrite-e2e::focusrite-e2e ALIAS focusrite-e2e) 23 | 24 | target_include_directories (focusrite-e2e PUBLIC include) 25 | 26 | set (JUCE_MODULES juce_core juce_events) 27 | 28 | foreach (JUCE_MODULE ${JUCE_MODULES}) 29 | 30 | if (NOT TARGET ${JUCE_MODULE}) 31 | message(FATAL_ERROR "Missing JUCE module: ${JUCE_MODULE}, enable FOCUSRITE_E2E_FETCH_JUCE to fetch JUCE") 32 | endif() 33 | 34 | get_target_property (MODULE_INCLUDES ${JUCE_MODULE} 35 | INTERFACE_INCLUDE_DIRECTORIES) 36 | target_include_directories (focusrite-e2e PUBLIC ${MODULE_INCLUDES}) 37 | endforeach () 38 | 39 | target_include_directories (focusrite-e2e PRIVATE ${juce_SOURCE_DIR}/modules) 40 | 41 | target_compile_definitions (focusrite-e2e 42 | PRIVATE JUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1) 43 | 44 | set_common_target_properties (focusrite-e2e) 45 | 46 | get_target_property (FOCUSRITE_E2E_SOURCES focusrite-e2e SOURCES) 47 | source_group (TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${FOCUSRITE_E2E_SOURCES}) 48 | 49 | if (FOCUSRITE_E2E_MAKE_TESTS) 50 | 51 | add_executable ( 52 | focusrite-e2e-tests 53 | ./tests/main.cpp ./tests/TestCommand.cpp ./tests/TestComponentSearch.cpp 54 | ./tests/TestResponse.cpp) 55 | 56 | target_link_libraries (focusrite-e2e-tests PRIVATE focusrite-e2e) 57 | 58 | add_test (NAME focusrite-e2e-tests COMMAND focusrite-e2e-tests) 59 | 60 | set_common_target_properties (focusrite-e2e-tests) 61 | 62 | target_compile_definitions ( 63 | focusrite-e2e-tests PRIVATE JUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1 64 | JUCE_STANDALONE_APPLICATION=1) 65 | 66 | target_link_libraries (focusrite-e2e-tests 67 | PRIVATE 68 | juce::juce_core 69 | juce::juce_events 70 | juce::juce_gui_basics 71 | ) 72 | 73 | set_target_properties (focusrite-e2e-tests 74 | PROPERTIES 75 | WIN32_EXECUTABLE true) 76 | 77 | endif () 78 | -------------------------------------------------------------------------------- /source/cpp/include/focusrite/e2e/ClickableComponent.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | namespace focusrite::e2e 4 | { 5 | class ClickableComponent 6 | { 7 | public: 8 | virtual ~ClickableComponent () = default; 9 | 10 | virtual void performClick () 11 | { 12 | } 13 | 14 | virtual void performDoubleClick () 15 | { 16 | } 17 | 18 | virtual void performRightClick () 19 | { 20 | } 21 | }; 22 | 23 | } 24 | -------------------------------------------------------------------------------- /source/cpp/include/focusrite/e2e/Command.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | namespace focusrite::e2e 6 | { 7 | class Command 8 | { 9 | public: 10 | static Command fromJson (const juce::String & json); 11 | 12 | [[nodiscard]] bool isValid () const; 13 | 14 | [[nodiscard]] juce::String getType () const; 15 | [[nodiscard]] juce::Uuid getUuid () const; 16 | [[nodiscard]] juce::String getArgument (const juce::String & argument) const; 17 | [[nodiscard]] juce::var getArgumentAsVar (const juce::String & argument) const; 18 | [[nodiscard]] juce::var getArgs () const; 19 | 20 | template 21 | [[nodiscard]] T getArgumentAs (const juce::String & argument) const; 22 | 23 | [[nodiscard]] juce::String describe () const; 24 | 25 | private: 26 | Command () = default; 27 | Command (juce::String type, const juce::Uuid & uuid, juce::var args); 28 | 29 | juce::String _type; 30 | juce::Uuid _uuid = juce::Uuid::null (); 31 | juce::var _args; 32 | }; 33 | 34 | template 35 | [[nodiscard]] T Command::getArgumentAs (const juce::String & argument) const 36 | { 37 | return T (getArgumentAsVar (argument)); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /source/cpp/include/focusrite/e2e/CommandHandler.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | namespace focusrite::e2e 8 | { 9 | class Command; 10 | 11 | class CommandHandler 12 | { 13 | public: 14 | virtual ~CommandHandler () = default; 15 | 16 | virtual std::optional process (const Command & command) = 0; 17 | }; 18 | 19 | } -------------------------------------------------------------------------------- /source/cpp/include/focusrite/e2e/ComponentSearch.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | namespace focusrite::e2e 6 | { 7 | class ComponentSearch 8 | { 9 | public: 10 | static juce::Component * findWithId (const juce::String & componentId, int skip = 0); 11 | static juce::TopLevelWindow * findWindowWithId (const juce::String & windowId = {}); 12 | 13 | static int countChildComponents (const juce::Component & parent, 14 | const juce::String & matchingId); 15 | 16 | static void setTestId (juce::Component & component, const juce::String & id); 17 | static void setWindowId (juce::TopLevelWindow & window, const juce::String & id); 18 | }; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /source/cpp/include/focusrite/e2e/Event.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | namespace focusrite::e2e 7 | { 8 | class Event 9 | { 10 | public: 11 | explicit Event (juce::String name); 12 | Event (const Event & other) = default; 13 | ~Event () = default; 14 | 15 | [[nodiscard]] Event withParameter (const juce::String & name, const juce::var & value) const; 16 | 17 | [[nodiscard]] juce::String toJson () const; 18 | 19 | void addParameter (const juce::String & name, const juce::var & value); 20 | 21 | private: 22 | juce::String _name; 23 | std::map _parameters; 24 | }; 25 | 26 | } 27 | -------------------------------------------------------------------------------- /source/cpp/include/focusrite/e2e/Response.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | namespace focusrite::e2e 6 | { 7 | class Response 8 | { 9 | public: 10 | static Response ok (); 11 | static Response fail (const juce::String & message); 12 | 13 | [[nodiscard]] Response withParameter (const juce::String & name, const juce::var & value) const; 14 | 15 | [[nodiscard]] Response withUuid (const juce::Uuid & uuid) const; 16 | 17 | [[nodiscard]] juce::String toJson () const; 18 | [[nodiscard]] juce::String describe () const; 19 | 20 | void addParameter (const juce::String & name, const juce::var & value); 21 | 22 | private: 23 | explicit Response (juce::Result result); 24 | 25 | juce::Uuid _uuid = juce::Uuid::null (); 26 | juce::Result _result; 27 | std::map _parameters; 28 | }; 29 | 30 | } 31 | -------------------------------------------------------------------------------- /source/cpp/include/focusrite/e2e/TestCentre.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | namespace focusrite::e2e 8 | { 9 | class Event; 10 | class TestCentre 11 | { 12 | public: 13 | enum class LogLevel 14 | { 15 | silent, 16 | verbose, 17 | }; 18 | 19 | static std::unique_ptr create (LogLevel logLevel = LogLevel::silent); 20 | 21 | virtual ~TestCentre () = default; 22 | 23 | virtual void addCommandHandler (CommandHandler & handler) = 0; 24 | virtual void removeCommandHandler (CommandHandler & handler) = 0; 25 | 26 | virtual void sendEvent (const Event & event) = 0; 27 | }; 28 | 29 | } 30 | -------------------------------------------------------------------------------- /source/cpp/source/Command.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | namespace focusrite::e2e 4 | { 5 | juce::String Command::getType () const 6 | { 7 | return _type; 8 | } 9 | 10 | juce::Uuid Command::getUuid () const 11 | { 12 | return _uuid; 13 | } 14 | 15 | Command Command::fromJson (const juce::String & json) 16 | { 17 | const auto root = juce::JSON::parse (json); 18 | return root == juce::var () ? Command () 19 | : Command (root.getProperty ("type", {}), 20 | juce::Uuid (root.getProperty ("uuid", {})), 21 | root.getProperty ("args", {})); 22 | } 23 | 24 | bool Command::isValid () const 25 | { 26 | return _type.isNotEmpty () && ! _uuid.isNull (); 27 | } 28 | 29 | juce::String Command::getArgument (const juce::String & argument) const 30 | { 31 | return _args.getProperty (argument, {}).toString (); 32 | } 33 | 34 | juce::var Command::getArgumentAsVar (const juce::String & argument) const 35 | { 36 | return _args.getProperty (argument, {}); 37 | } 38 | 39 | juce::var Command::getArgs () const 40 | { 41 | return _args; 42 | } 43 | 44 | juce::String Command::describe () const 45 | { 46 | juce::String response; 47 | response << "Type: " << getType () << juce::newLine; 48 | response << "Args: " << juce::JSON::toString (_args) << juce::newLine; 49 | return response; 50 | } 51 | 52 | Command::Command (juce::String type, const juce::Uuid & uuid, juce::var args) 53 | : _type (std::move (type)) 54 | , _uuid (uuid) 55 | , _args (std::move (args)) 56 | { 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /source/cpp/source/ComponentSearch.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | namespace focusrite::e2e 4 | { 5 | static constexpr auto testId = "test-id"; 6 | static constexpr auto windowId = "window-id"; 7 | 8 | [[nodiscard]] static std::vector 9 | getDirectDescendantsMatching (juce::Component & parent, 10 | const std::function & predicate) 11 | { 12 | auto children = parent.getChildren (); 13 | children.removeIf ([&] (auto && child) { return child == nullptr || ! predicate (*child); }); 14 | return {children.begin (), children.end ()}; 15 | } 16 | 17 | [[nodiscard]] static juce::Component * 18 | matchChildComponent (juce::Component & component, 19 | const std::function & predicate, 20 | int & skip) 21 | { 22 | jassert (skip >= 0); 23 | 24 | auto matchingChildren = getDirectDescendantsMatching (component, predicate); 25 | 26 | if (skip < int (matchingChildren.size ())) 27 | return matchingChildren [static_cast (skip)]; 28 | 29 | skip -= int (matchingChildren.size ()); 30 | 31 | for (auto * child : component.getChildren ()) 32 | { 33 | if (child == nullptr) 34 | continue; 35 | 36 | if (auto * foundComponent = matchChildComponent (*child, predicate, skip)) 37 | return foundComponent; 38 | } 39 | 40 | return nullptr; 41 | } 42 | 43 | [[nodiscard]] static juce::Component * 44 | findChildComponent (juce::Component & component, 45 | const std::function & predicate, 46 | int skip = 0) 47 | { 48 | return matchChildComponent (component, predicate, skip); 49 | } 50 | 51 | [[nodiscard]] static std::vector getTopLevelWindows () 52 | { 53 | std::vector windows; 54 | 55 | for (int windowIndex = 0; windowIndex < juce::TopLevelWindow::getNumTopLevelWindows (); 56 | ++windowIndex) 57 | if (auto * window = juce::TopLevelWindow::getTopLevelWindow (windowIndex)) 58 | windows.push_back (window); 59 | 60 | return windows; 61 | } 62 | 63 | [[nodiscard]] static juce::Component * 64 | findComponent (const std::function & predicate, int skip = 0) 65 | { 66 | for (auto & window : getTopLevelWindows ()) 67 | if (auto * component = findChildComponent (*window, predicate, skip)) 68 | return component; 69 | 70 | return nullptr; 71 | } 72 | 73 | [[nodiscard]] static bool componentHasMatchingProperty (const juce::Component & component, 74 | const juce::String & pattern, 75 | const juce::String & propertyName) 76 | { 77 | const auto componentTestId = 78 | component.getProperties ().getWithDefault (propertyName, {}).toString (); 79 | const auto componentId = component.getComponentID (); 80 | 81 | return componentTestId.matchesWildcard (pattern, false) || 82 | componentId.matchesWildcard (pattern, false); 83 | } 84 | 85 | [[nodiscard]] static bool componentHasId (const juce::Component & component, 86 | const juce::String & idPattern) 87 | { 88 | return componentHasMatchingProperty (component, idPattern, testId); 89 | } 90 | 91 | [[nodiscard]] static bool windowHasId (const juce::TopLevelWindow & window, 92 | const juce::String & idPattern) 93 | { 94 | return componentHasMatchingProperty (window, idPattern, windowId); 95 | } 96 | 97 | [[nodiscard]] static std::function 98 | createComponentMatcher (const juce::String & componentId) 99 | { 100 | return [componentId] (auto && component) -> bool 101 | { 102 | return componentHasId (component, componentId) && component.isVisible () && 103 | component.isShowing (); 104 | }; 105 | } 106 | 107 | juce::TopLevelWindow * ComponentSearch::findWindowWithId (const juce::String & id) 108 | { 109 | auto topWindows = getTopLevelWindows (); 110 | if (topWindows.empty ()) 111 | return nullptr; 112 | 113 | if (id.isEmpty ()) 114 | return topWindows.front (); 115 | 116 | auto it = std::find_if (topWindows.begin (), 117 | topWindows.end (), 118 | [&] (auto && window) { return windowHasId (*window, id); }); 119 | 120 | return it == topWindows.end () ? nullptr : *it; 121 | } 122 | 123 | int ComponentSearch::countChildComponents (const juce::Component & root, 124 | const juce::String & componentId) 125 | { 126 | const auto predicate = createComponentMatcher (componentId); 127 | 128 | return std::accumulate (root.getChildren ().begin (), 129 | root.getChildren ().end (), 130 | 0, 131 | [=] (auto && total, auto && child) 132 | { 133 | if (predicate (*child)) 134 | ++total; 135 | 136 | total += countChildComponents (*child, componentId); 137 | 138 | return total; 139 | }); 140 | } 141 | 142 | juce::Component * ComponentSearch::findWithId (const juce::String & componentId, int skip) 143 | { 144 | auto componentIds = juce::StringArray::fromTokens (componentId, "/", ""); 145 | if (componentIds.isEmpty ()) 146 | return nullptr; 147 | 148 | auto * component = findComponent (createComponentMatcher (componentIds [0]), skip); 149 | componentIds.remove (0); 150 | 151 | for (auto && id : componentIds) 152 | { 153 | if (component == nullptr) 154 | return nullptr; 155 | 156 | component = findChildComponent (*component, createComponentMatcher (id), 0); 157 | } 158 | 159 | return component; 160 | } 161 | 162 | void ComponentSearch::setTestId (juce::Component & component, const juce::String & id) 163 | { 164 | component.getProperties ().set (testId, id); 165 | } 166 | 167 | void ComponentSearch::setWindowId (juce::TopLevelWindow & window, const juce::String & id) 168 | { 169 | window.getProperties ().set (windowId, id); 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /source/cpp/source/Connection.cpp: -------------------------------------------------------------------------------- 1 | #include "Connection.h" 2 | 3 | #include 4 | 5 | #if JUCE_MAC 6 | #include 7 | #endif 8 | 9 | namespace 10 | { 11 | #pragma pack(push, 1) 12 | struct Header 13 | { 14 | static constexpr uint32_t magicNumber = 0x30061990; 15 | 16 | uint32_t magic = 0; 17 | uint32_t size = 0; 18 | }; 19 | #pragma pack(pop) 20 | 21 | static_assert (sizeof (Header) == 2 * sizeof (uint32_t), "Expecting header to be 8 bytes"); 22 | 23 | bool writeBytesToSocket (juce::StreamingSocket & socket, const juce::MemoryBlock & data) 24 | { 25 | int offset = 0; 26 | 27 | while (static_cast (offset) < data.getSize ()) 28 | { 29 | const auto numBytesWritten = 30 | socket.write (&data [offset], static_cast (data.getSize ()) - offset); 31 | 32 | if (numBytesWritten < 0) 33 | return false; 34 | 35 | offset += numBytesWritten; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | } 42 | 43 | namespace focusrite::e2e 44 | { 45 | std::shared_ptr Connection::create (int port) 46 | { 47 | return std::shared_ptr (new Connection (port)); 48 | } 49 | 50 | Connection::Connection (int port) 51 | : Thread ("Test fixture connection") 52 | , _port (port) 53 | { 54 | } 55 | 56 | Connection::~Connection () 57 | { 58 | closeSocket (); 59 | 60 | constexpr int timeoutMs = 1000; 61 | stopThread (timeoutMs); 62 | } 63 | 64 | void Connection::start () 65 | { 66 | startThread (); 67 | } 68 | 69 | void Connection::run () 70 | { 71 | preventSigPipeExceptions (); 72 | 73 | const auto connected = _socket.connect ("localhost", _port); 74 | 75 | if (! connected) 76 | return; 77 | 78 | try 79 | { 80 | while (! threadShouldExit ()) 81 | { 82 | Header header; 83 | 84 | auto headerBytesRead = _socket.read (&header, sizeof (header), true); 85 | 86 | if (headerBytesRead != sizeof (header)) 87 | { 88 | closeSocket (); 89 | break; 90 | } 91 | 92 | header.magic = juce::ByteOrder::swapIfBigEndian (header.magic); 93 | header.size = juce::ByteOrder::swapIfBigEndian (header.size); 94 | 95 | if (header.magic != Header::magicNumber) 96 | { 97 | closeSocket (); 98 | break; 99 | } 100 | 101 | juce::MemoryBlock block (header.size); 102 | auto bytesRead = _socket.read (block.getData (), int (header.size), true); 103 | if (bytesRead != int (header.size)) 104 | { 105 | closeSocket (); 106 | break; 107 | } 108 | 109 | notifyData (block); 110 | } 111 | } 112 | catch (...) 113 | { 114 | } 115 | } 116 | 117 | void Connection::send (const juce::MemoryBlock & data) 118 | { 119 | jassert (isConnected ()); 120 | 121 | if (const Header header {juce::ByteOrder::swapIfBigEndian (Header::magicNumber), 122 | juce::ByteOrder::swapIfBigEndian (uint32_t (data.getSize ()))}; 123 | ! writeBytesToSocket (_socket, {&header, sizeof (header)})) 124 | { 125 | closeSocket (); 126 | return; 127 | } 128 | 129 | if (! writeBytesToSocket (_socket, data)) 130 | closeSocket (); 131 | } 132 | 133 | bool Connection::isConnected () const 134 | { 135 | return _socket.isConnected (); 136 | } 137 | 138 | void Connection::closeSocket () 139 | { 140 | if (_socket.isConnected ()) 141 | _socket.close (); 142 | } 143 | 144 | void Connection::notifyData (const juce::MemoryBlock & data) 145 | { 146 | juce::MessageManager::callAsync ( 147 | [weakSelf = weak_from_this (), data] 148 | { 149 | if (auto connection = weakSelf.lock ()) 150 | if (connection->_onDataReceived) 151 | connection->_onDataReceived (data); 152 | }); 153 | } 154 | 155 | void Connection::preventSigPipeExceptions () 156 | { 157 | #if JUCE_MAC 158 | auto socketFd = _socket.getRawSocketHandle (); 159 | const int set = 1; 160 | setsockopt (socketFd, SOL_SOCKET, SO_NOSIGPIPE, (void *) &set, sizeof (int)); 161 | #endif 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /source/cpp/source/Connection.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | namespace focusrite::e2e 6 | { 7 | class Connection 8 | : private juce::Thread 9 | , public std::enable_shared_from_this 10 | { 11 | public: 12 | static std::shared_ptr create (int port); 13 | 14 | ~Connection () override; 15 | 16 | Connection (const Connection &) = delete; 17 | Connection (Connection &&) = delete; 18 | Connection & operator= (const Connection &) = delete; 19 | 20 | std::function _onDataReceived; 21 | 22 | void start (); 23 | void send (const juce::MemoryBlock & data); 24 | [[nodiscard]] bool isConnected () const; 25 | 26 | private: 27 | explicit Connection (int port); 28 | 29 | void run () override; 30 | 31 | void closeSocket (); 32 | void preventSigPipeExceptions (); 33 | void notifyData (const juce::MemoryBlock & data); 34 | 35 | int _port = 0; 36 | juce::StreamingSocket _socket; 37 | }; 38 | 39 | } 40 | -------------------------------------------------------------------------------- /source/cpp/source/DefaultCommandHandler.cpp: -------------------------------------------------------------------------------- 1 | #include "DefaultCommandHandler.h" 2 | 3 | #include "KeyPress.h" 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | namespace focusrite::e2e 12 | { 13 | enum class CommandArgument 14 | { 15 | componentId, 16 | focusComponent, 17 | keyCode, 18 | modifiers, 19 | numClicks, 20 | rootId, 21 | skip, 22 | title, 23 | value, 24 | windowId, 25 | }; 26 | 27 | [[nodiscard]] static juce::StringRef toString (CommandArgument argument) 28 | { 29 | switch (argument) 30 | { 31 | case CommandArgument::componentId: 32 | return "component-id"; 33 | case CommandArgument::focusComponent: 34 | return "focus-component"; 35 | case CommandArgument::keyCode: 36 | return "key-code"; 37 | case CommandArgument::modifiers: 38 | return "modifiers"; 39 | case CommandArgument::numClicks: 40 | return "num-clicks"; 41 | case CommandArgument::rootId: 42 | return "root-id"; 43 | case CommandArgument::skip: 44 | return "skip"; 45 | case CommandArgument::title: 46 | return "title"; 47 | case CommandArgument::value: 48 | return "value"; 49 | case CommandArgument::windowId: 50 | return "window-id"; 51 | } 52 | 53 | jassertfalse; 54 | return {}; 55 | } 56 | 57 | static void clickButton (juce::Button & button) 58 | { 59 | if (button.onClick != nullptr) 60 | { 61 | if (button.getClickingTogglesState ()) 62 | button.setToggleState (! button.getToggleState (), 63 | juce::NotificationType::dontSendNotification); 64 | 65 | button.onClick (); 66 | } 67 | else 68 | { 69 | button.triggerClick (); 70 | } 71 | } 72 | 73 | static void clickClickableComponent (focusrite::e2e::ClickableComponent & clickable, int numClicks) 74 | { 75 | if (numClicks == 1) 76 | clickable.performClick (); 77 | 78 | if (numClicks == 2) 79 | clickable.performDoubleClick (); 80 | } 81 | 82 | [[nodiscard]] static juce::String snapshotComponent (juce::Component & component) 83 | { 84 | auto image = component.createComponentSnapshot (component.getLocalBounds ()); 85 | 86 | if (image.isNull ()) 87 | return {}; 88 | 89 | juce::MemoryOutputStream rawStream; 90 | 91 | juce::PNGImageFormat imageFormat; 92 | if (! imageFormat.writeImageToStream (image, rawStream)) 93 | return {}; 94 | 95 | return juce::Base64::toBase64 (rawStream.getData (), rawStream.getDataSize ()); 96 | } 97 | 98 | [[nodiscard]] static bool clickButton (juce::Component & component) 99 | { 100 | if (auto * button = dynamic_cast (&component)) 101 | { 102 | clickButton (*button); 103 | return true; 104 | } 105 | 106 | return false; 107 | } 108 | 109 | [[nodiscard]] static bool clickTextEditor (juce::Component & component) 110 | { 111 | if (auto * textBox = dynamic_cast (&component)) 112 | { 113 | textBox->grabKeyboardFocus (); 114 | return true; 115 | } 116 | 117 | return false; 118 | } 119 | 120 | [[nodiscard]] static bool clickClickableComponent (juce::Component & component, 121 | const Command & command) 122 | { 123 | if (auto * clickable = dynamic_cast (&component)) 124 | { 125 | clickClickableComponent ( 126 | *clickable, 127 | juce::jlimit ( 128 | 1, 2, command.getArgument (toString (CommandArgument::numClicks)).getIntValue ())); 129 | return true; 130 | } 131 | 132 | return false; 133 | } 134 | 135 | [[nodiscard]] static Response clickComponent (const Command & command) 136 | { 137 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 138 | if (componentId.isEmpty ()) 139 | return Response::fail ("Missing component-id"); 140 | 141 | auto skip = command.getArgument (toString (CommandArgument::skip)).getIntValue (); 142 | 143 | auto * component = ComponentSearch::findWithId (componentId, skip); 144 | if (component == nullptr) 145 | return Response::fail ("Component not found: " + juce::String (componentId)); 146 | 147 | auto handled = clickButton (*component); 148 | handled = handled || clickTextEditor (*component); 149 | handled = handled || clickClickableComponent (*component, command); 150 | 151 | return handled ? Response::ok () 152 | : Response::fail ("Component not clickable: " + juce::String (componentId)); 153 | } 154 | 155 | [[nodiscard]] static Response keyPressOnComponent (const juce::String & componentId, 156 | const juce::KeyPress & keyPress) 157 | { 158 | auto * component = ComponentSearch::findWithId (componentId); 159 | if (component == nullptr) 160 | return Response::fail ("Component not found for key press: " + componentId); 161 | 162 | component->keyPressed (keyPress); 163 | return Response::ok (); 164 | } 165 | 166 | [[nodiscard]] static Response keyPressOnWindow (const juce::String & windowId, 167 | const juce::KeyPress & keyPress) 168 | { 169 | auto * window = ComponentSearch::findWindowWithId (windowId); 170 | if (window == nullptr) 171 | return Response::fail ("Couldn't find window"); 172 | 173 | auto * peer = window->getPeer (); 174 | if (peer == nullptr) 175 | return Response::fail ("Window doesn't have peer"); 176 | 177 | peer->handleKeyPress (keyPress); 178 | return Response::ok (); 179 | } 180 | 181 | [[nodiscard]] static Response keyPress (const Command & command) 182 | { 183 | auto keyCode = command.getArgument (toString (CommandArgument::keyCode)); 184 | if (keyCode.isEmpty ()) 185 | return Response::fail ("Missing key-code argument"); 186 | 187 | auto modifiers = command.getArgument (toString (CommandArgument::modifiers)); 188 | auto componentId = command.getArgument (toString (CommandArgument::focusComponent)); 189 | auto windowId = command.getArgument (toString (CommandArgument::windowId)); 190 | 191 | const auto keyPress = constructKeyPress (keyCode, modifiers); 192 | 193 | if (componentId.isNotEmpty ()) 194 | return keyPressOnComponent (componentId, keyPress); 195 | 196 | return keyPressOnWindow (windowId, keyPress); 197 | } 198 | 199 | [[nodiscard]] static Response grabFocus (const Command & command) 200 | { 201 | if (auto * window = ComponentSearch::findWindowWithId ( 202 | command.getArgument (toString (CommandArgument::windowId)))) 203 | window->grabKeyboardFocus (); 204 | 205 | return Response::ok (); 206 | } 207 | 208 | [[nodiscard]] static Response getScreenshot (const Command & command) 209 | { 210 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 211 | const auto windowId = command.getArgument (toString (CommandArgument::windowId)); 212 | 213 | auto * component = componentId.isEmpty () ? ComponentSearch::findWindowWithId (windowId) 214 | : ComponentSearch::findWithId (componentId); 215 | 216 | if (component == nullptr) 217 | return Response::fail ("Component not found: " + juce::String (componentId)); 218 | 219 | auto image = snapshotComponent (*component); 220 | if (image.isEmpty ()) 221 | return Response::fail ("Failed to snapshot component"); 222 | 223 | return Response::ok ().withParameter ("image", image); 224 | } 225 | 226 | [[nodiscard]] static Response getComponentVisibility (const Command & command) 227 | { 228 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 229 | if (componentId.isEmpty ()) 230 | return Response::fail ("Missing component-id"); 231 | 232 | auto exists = false; 233 | auto showing = false; 234 | 235 | if (auto * component = ComponentSearch::findWithId (componentId)) 236 | { 237 | exists = true; 238 | showing = component->isShowing (); 239 | } 240 | 241 | return Response::ok ().withParameter ("exists", exists).withParameter ("showing", showing); 242 | } 243 | 244 | [[nodiscard]] static Response getComponentEnablement (const Command & command) 245 | { 246 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 247 | if (componentId.isEmpty ()) 248 | return Response::fail ("Missing component-id"); 249 | 250 | bool exists = false; 251 | bool enabled = false; 252 | 253 | if (auto * component = ComponentSearch::findWithId (componentId)) 254 | { 255 | exists = true; 256 | enabled = component->isEnabled (); 257 | } 258 | 259 | return Response::ok ().withParameter ("exists", exists).withParameter ("enabled", enabled); 260 | } 261 | 262 | [[nodiscard]] static Response getComponentText (const Command & command) 263 | { 264 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 265 | if (componentId.isEmpty ()) 266 | return Response::fail ("Missing component-id"); 267 | 268 | auto * component = ComponentSearch::findWithId (componentId); 269 | if (component == nullptr) 270 | return Response::fail ("No matching component"); 271 | 272 | if (const auto * textBox = dynamic_cast (component)) 273 | return Response::ok ().withParameter ("text", textBox->getText ()); 274 | 275 | if (const auto * label = dynamic_cast (component)) 276 | return Response::ok ().withParameter ("text", label->getText ()); 277 | 278 | if (const auto * button = dynamic_cast (component)) 279 | return Response::ok ().withParameter ("text", button->getButtonText ()); 280 | 281 | return Response::fail ("Component doesn't have text"); 282 | } 283 | 284 | [[nodiscard]] static Response getFocusComponent (const Command & command) 285 | { 286 | juce::ignoreUnused (command); 287 | 288 | juce::String testId; 289 | 290 | if (auto * focusComponent = juce::Component::getCurrentlyFocusedComponent ()) 291 | testId = focusComponent->getProperties ().getWithDefault ("test-id", {}).toString (); 292 | 293 | return Response::ok ().withParameter ("component-id", testId); 294 | } 295 | 296 | [[nodiscard]] static Response countComponents (const Command & command) 297 | { 298 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 299 | 300 | if (componentId.isEmpty ()) 301 | return Response::fail ("Missing component-id"); 302 | 303 | const auto rootId = command.getArgument (toString (CommandArgument::rootId)); 304 | const auto windowId = command.getArgument (toString (CommandArgument::windowId)); 305 | 306 | juce::Component * rootComponent = ComponentSearch::findWindowWithId (windowId); 307 | 308 | if (rootId.isNotEmpty ()) 309 | { 310 | rootComponent = ComponentSearch::findWithId (rootId); 311 | if (rootComponent == nullptr) 312 | return Response::fail ("Couldn't find specified root component"); 313 | } 314 | 315 | jassert (rootComponent != nullptr); 316 | 317 | return Response::ok ().withParameter ( 318 | "count", ComponentSearch::countChildComponents (*rootComponent, componentId)); 319 | } 320 | 321 | [[nodiscard]] static Response quit (const Command & command) 322 | { 323 | juce::ignoreUnused (command); 324 | return Response::ok (); 325 | } 326 | 327 | [[nodiscard]] static Response invokeMenu (const Command & command) 328 | { 329 | auto * application = juce::JUCEApplication::getInstance (); 330 | if (application == nullptr) 331 | return Response::fail ("Invalid application"); 332 | 333 | const auto menuTitle = command.getArgument (toString (CommandArgument::title)); 334 | if (menuTitle.isEmpty ()) 335 | return Response::fail ("Missing menu title"); 336 | 337 | juce::Array commands; 338 | application->getAllCommands (commands); 339 | 340 | for (auto commandID : commands) 341 | { 342 | juce::ApplicationCommandInfo info (commandID); 343 | application->getCommandInfo (commandID, info); 344 | 345 | if (info.shortName != menuTitle) 346 | continue; 347 | 348 | application->invoke (juce::ApplicationCommandTarget::InvocationInfo (commandID), false); 349 | return Response::ok (); 350 | } 351 | 352 | return Response::fail ("Not handled"); 353 | } 354 | 355 | template 356 | [[nodiscard]] static std::variant getComponent (const Command & command) 357 | { 358 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 359 | if (componentId.isEmpty ()) 360 | return "Missing component-id"; 361 | 362 | const auto skip = command.getArgument (toString (CommandArgument::skip)).getIntValue (); 363 | 364 | auto * component = ComponentSearch::findWithId (componentId, skip); 365 | if (component == nullptr) 366 | return "Component not found: " + componentId; 367 | 368 | auto * castedComponent = dynamic_cast (component); 369 | if (castedComponent == nullptr) 370 | return "Component is not a " + juce::String (typeid (T).name ()) + ": " + componentId; 371 | 372 | return castedComponent; 373 | } 374 | 375 | [[nodiscard]] static Response getSliderValue (const Command & command) 376 | { 377 | auto sliderVariant = getComponent (command); 378 | if (std::holds_alternative (sliderVariant)) 379 | { 380 | auto * slider = std::get (sliderVariant); 381 | return Response::ok ().withParameter (toString (CommandArgument::value), 382 | slider->getValue ()); 383 | } 384 | 385 | const auto error = std::get (sliderVariant); 386 | return Response::fail (error); 387 | } 388 | 389 | [[nodiscard]] static Response setSliderValue (const Command & command) 390 | { 391 | auto sliderVariant = getComponent (command); 392 | if (std::holds_alternative (sliderVariant)) 393 | { 394 | auto * slider = std::get (sliderVariant); 395 | 396 | const auto value = 397 | command.getArgument (toString (CommandArgument::value)).getDoubleValue (); 398 | 399 | if (value > slider->getMaximum () || value < slider->getMinimum ()) 400 | return Response::fail ("Slider value out of range: " + juce::String (value)); 401 | 402 | slider->setValue (value, juce::sendNotificationSync); 403 | 404 | return Response::ok (); 405 | } 406 | 407 | const auto error = std::get (sliderVariant); 408 | return Response::fail (error); 409 | } 410 | 411 | [[nodiscard]] static Response setTextEditorText (const Command & command) 412 | { 413 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 414 | if (componentId.isEmpty ()) 415 | return Response::fail ("Missing component-id"); 416 | 417 | const auto text = command.getArgument (toString (CommandArgument::value)); 418 | 419 | if (auto * component = ComponentSearch::findWithId (componentId)) 420 | { 421 | if (auto * textEditor = dynamic_cast (component)) 422 | { 423 | static constexpr auto sendTextChangeMessage = true; 424 | textEditor->setText (text, sendTextChangeMessage); 425 | return Response::ok (); 426 | } 427 | 428 | return Response::fail (componentId + " is not a juce::TextEditor"); 429 | } 430 | 431 | return Response::fail (componentId + " not found"); 432 | } 433 | 434 | [[nodiscard]] static Response getComboBoxSelectedItemIndex (const Command & command) 435 | { 436 | auto comboBoxVariant = getComponent (command); 437 | if (std::holds_alternative (comboBoxVariant)) 438 | { 439 | auto * comboBox = std::get (comboBoxVariant); 440 | return Response::ok ().withParameter (toString (CommandArgument::value), 441 | comboBox->getSelectedItemIndex ()); 442 | } 443 | 444 | const auto error = std::get (comboBoxVariant); 445 | return Response::fail (error); 446 | } 447 | 448 | [[nodiscard]] static Response getComboBoxNumItems (const Command & command) 449 | { 450 | auto comboBoxVariant = getComponent (command); 451 | if (std::holds_alternative (comboBoxVariant)) 452 | { 453 | auto * comboBox = std::get (comboBoxVariant); 454 | return Response::ok ().withParameter (toString (CommandArgument::value), 455 | comboBox->getNumItems ()); 456 | } 457 | 458 | const auto error = std::get (comboBoxVariant); 459 | return Response::fail (error); 460 | } 461 | 462 | [[nodiscard]] static Response getComboBoxItems (const Command & command) 463 | { 464 | auto comboBoxVariant = getComponent (command); 465 | if (std::holds_alternative (comboBoxVariant)) 466 | { 467 | auto * comboBox = std::get (comboBoxVariant); 468 | const auto numItems = comboBox->getNumItems (); 469 | 470 | juce::StringArray items; 471 | 472 | for (auto i = 0; i < numItems; ++i) 473 | { 474 | items.add (comboBox->getItemText (i)); 475 | } 476 | 477 | return Response::ok ().withParameter ("items", items); 478 | } 479 | 480 | const auto error = std::get (comboBoxVariant); 481 | return Response::fail (error); 482 | } 483 | 484 | [[nodiscard]] static Response setComboBoxSelectedItemIndex (const Command & command) 485 | { 486 | auto comboBoxVariant = getComponent (command); 487 | if (std::holds_alternative (comboBoxVariant)) 488 | { 489 | auto * comboBox = std::get (comboBoxVariant); 490 | 491 | const auto value = command.getArgument (toString (CommandArgument::value)).getIntValue (); 492 | 493 | if (value > comboBox->getNumItems () || value < 0) 494 | return Response::fail ("ComboBox value out of range: " + juce::String (value)); 495 | 496 | comboBox->setSelectedItemIndex (value, juce::sendNotificationSync); 497 | 498 | return Response::ok (); 499 | } 500 | 501 | const auto error = std::get (comboBoxVariant); 502 | return Response::fail (error); 503 | } 504 | 505 | [[nodiscard]] static juce::String getAccessibilityHandlerDisplay (juce::Component & component) 506 | { 507 | if (! component.isAccessible () || component.getAccessibilityHandler () == nullptr) 508 | return ""; 509 | 510 | if (auto * value = component.getAccessibilityHandler ()->getValueInterface ()) 511 | return value->getCurrentValueAsString (); 512 | 513 | if (auto * text = component.getAccessibilityHandler ()->getTextInterface ()) 514 | return text->getAllText (); 515 | 516 | return ""; 517 | } 518 | 519 | [[nodiscard]] static juce::String getAccessibilityTitle (juce::Component & component) 520 | { 521 | return (component.isAccessible () && component.getAccessibilityHandler () != nullptr) 522 | ? component.getAccessibilityHandler ()->getTitle () 523 | : component.getTitle (); 524 | } 525 | 526 | [[nodiscard]] static juce::String getAccessibilityDescription (juce::Component & component) 527 | { 528 | return (component.isAccessible () && component.getAccessibilityHandler () != nullptr) 529 | ? component.getAccessibilityHandler ()->getDescription () 530 | : component.getDescription (); 531 | } 532 | 533 | [[nodiscard]] static juce::String getAccessibilityHelpText (juce::Component & component) 534 | { 535 | return (component.isAccessible () && component.getAccessibilityHandler () != nullptr) 536 | ? component.getAccessibilityHandler ()->getHelp () 537 | : component.getHelpText (); 538 | } 539 | 540 | [[nodiscard]] static juce::String getAccessibilityParent (juce::Component & component) 541 | { 542 | if (! component.isAccessible () || component.getAccessibilityHandler () == nullptr) 543 | return {}; 544 | 545 | if (auto * parent = component.getAccessibilityHandler ()->getParent ()) 546 | return parent->getComponent ().getComponentID (); 547 | 548 | return {}; 549 | } 550 | 551 | [[nodiscard]] static juce::StringArray getAccessibilityChildren (juce::Component & component) 552 | { 553 | if (! component.isAccessible () || component.getAccessibilityHandler () == nullptr) 554 | return {}; 555 | 556 | juce::StringArray result; 557 | for (auto childAccessibilityHandler : component.getAccessibilityHandler ()->getChildren ()) 558 | if (childAccessibilityHandler != nullptr) 559 | if (childAccessibilityHandler->getComponent ().isAccessible () && 560 | childAccessibilityHandler->getComponent ().getAccessibilityHandler () != nullptr) 561 | result.add (childAccessibilityHandler->getComponent ().getComponentID ()); 562 | 563 | return result; 564 | } 565 | 566 | [[nodiscard]] static Response getAccessibilityState (const Command & command) 567 | { 568 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 569 | if (componentId.isEmpty ()) 570 | return Response::fail ("Missing component-id"); 571 | 572 | if (auto * component = ComponentSearch::findWithId (componentId)) 573 | return Response::ok () 574 | .withParameter (toString (CommandArgument::title), getAccessibilityTitle (*component)) 575 | .withParameter ("description", getAccessibilityDescription (*component)) 576 | .withParameter ("help", getAccessibilityHelpText (*component)) 577 | .withParameter ("accessible", component->isAccessible ()) 578 | .withParameter ("handler", component->getAccessibilityHandler () != nullptr) 579 | .withParameter ("display", getAccessibilityHandlerDisplay (*component)); 580 | 581 | return Response::fail (componentId + " not found"); 582 | } 583 | 584 | [[nodiscard]] static Response getAccessibilityParent (const Command & command) 585 | { 586 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 587 | if (componentId.isEmpty ()) 588 | return Response::fail ("Missing component-id"); 589 | 590 | if (auto * component = ComponentSearch::findWithId (componentId)) 591 | return Response::ok ().withParameter ("parent", getAccessibilityParent (*component)); 592 | 593 | return Response::fail (componentId + " not found"); 594 | } 595 | 596 | [[nodiscard]] static Response getAccessibilityChildren (const Command & command) 597 | { 598 | const auto componentId = command.getArgument (toString (CommandArgument::componentId)); 599 | if (componentId.isEmpty ()) 600 | return Response::fail ("Missing component-id"); 601 | 602 | if (auto * component = ComponentSearch::findWithId (componentId)) 603 | return Response::ok ().withParameter ("children", getAccessibilityChildren (*component)); 604 | 605 | if (auto * window = ComponentSearch::findWindowWithId (componentId)) 606 | return Response::ok ().withParameter ("children", getAccessibilityChildren (*window)); 607 | 608 | return Response::fail (componentId + " not found"); 609 | } 610 | 611 | std::optional DefaultCommandHandler::process (const Command & commandToProcess) 612 | { 613 | static const std::map> commandHandlers = 614 | { 615 | {"click-component", [&] (auto && command) { return clickComponent (command); }}, 616 | {"key-press", [&] (auto && command) { return keyPress (command); }}, 617 | {"get-screenshot", [&] (auto && command) { return getScreenshot (command); }}, 618 | {"get-component-visibility", 619 | [&] (auto && command) { return getComponentVisibility (command); }}, 620 | {"get-component-enablement", 621 | [&] (auto && command) { return getComponentEnablement (command); }}, 622 | {"get-component-text", [&] (auto && command) { return getComponentText (command); }}, 623 | {"get-focus-component", [&] (auto && command) { return getFocusComponent (command); }}, 624 | {"get-component-count", [&] (auto && command) { return countComponents (command); }}, 625 | {"grab-focus", [&] (auto && command) { return grabFocus (command); }}, 626 | {"quit", [&] (auto && command) { return quit (command); }}, 627 | {"invoke-menu", [&] (auto && command) { return invokeMenu (command); }}, 628 | {"get-slider-value", [&] (auto && command) { return getSliderValue (command); }}, 629 | {"set-slider-value", [&] (auto && command) { return setSliderValue (command); }}, 630 | { 631 | "set-text-editor-text", 632 | [&] (auto && command) { return setTextEditorText (command); }, 633 | }, 634 | {"get-combo-box-selected-item-index", 635 | [&] (auto && command) { return getComboBoxSelectedItemIndex (command); }}, 636 | {"get-combo-box-num-items", 637 | [&] (auto && command) { return getComboBoxNumItems (command); }}, 638 | {"get-combo-box-items", [&] (auto && command) { return getComboBoxItems (command); }}, 639 | {"set-combo-box-selected-item-index", 640 | [&] (auto && command) { return setComboBoxSelectedItemIndex (command); }}, 641 | {"get-accessibility-state", 642 | [&] (auto && command) { return getAccessibilityState (command); }}, 643 | {"get-accessibility-parent", 644 | [&] (auto && command) { return getAccessibilityParent (command); }}, 645 | {"get-accessibility-children", 646 | [&] (auto && command) { return getAccessibilityChildren (command); }}, 647 | }; 648 | 649 | auto it = commandHandlers.find (commandToProcess.getType ()); 650 | 651 | if (it == commandHandlers.end ()) 652 | return std::nullopt; 653 | 654 | return it->second (commandToProcess); 655 | } 656 | } 657 | -------------------------------------------------------------------------------- /source/cpp/source/DefaultCommandHandler.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | namespace focusrite::e2e 6 | { 7 | class DefaultCommandHandler : public CommandHandler 8 | { 9 | public: 10 | std::optional process (const Command & command) override; 11 | }; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /source/cpp/source/Event.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | namespace focusrite::e2e 4 | { 5 | Event::Event (juce::String name) 6 | : _name (std::move (name)) 7 | { 8 | } 9 | 10 | Event Event::withParameter (const juce::String & name, const juce::var & value) const 11 | { 12 | Event other (*this); 13 | other.addParameter (name, value); 14 | return other; 15 | } 16 | 17 | juce::String Event::toJson () const 18 | { 19 | auto root = std::make_unique (); 20 | 21 | root->setProperty ("type", "event"); 22 | root->setProperty ("name", _name); 23 | 24 | if (! _parameters.empty ()) 25 | { 26 | auto data = std::make_unique (); 27 | 28 | for (const auto & [name, value] : _parameters) 29 | data->setProperty (name, value); 30 | 31 | root->setProperty ("data", data.release ()); 32 | } 33 | 34 | return juce::JSON::toString (root.release ()); 35 | } 36 | 37 | void Event::addParameter (const juce::String & name, const juce::var & value) 38 | { 39 | _parameters [name] = value; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /source/cpp/source/KeyPress.cpp: -------------------------------------------------------------------------------- 1 | #include "KeyPress.h" 2 | 3 | namespace focusrite::e2e 4 | { 5 | [[nodiscard]] static int mapKeyCode (const juce::String & code) 6 | { 7 | using KeyPress = juce::KeyPress; 8 | 9 | static const std::map values = { 10 | {"space", KeyPress::spaceKey}, 11 | {"escape", KeyPress::escapeKey}, 12 | {"return", KeyPress::returnKey}, 13 | {"tab", KeyPress::tabKey}, 14 | {"delete", KeyPress::deleteKey}, 15 | {"backspace", KeyPress::backspaceKey}, 16 | {"insert", KeyPress::insertKey}, 17 | {"up", KeyPress::upKey}, 18 | {"down", KeyPress::downKey}, 19 | {"left", KeyPress::leftKey}, 20 | {"right", KeyPress::rightKey}, 21 | {"page-up", KeyPress::pageUpKey}, 22 | {"page-down", KeyPress::pageDownKey}, 23 | {"home", KeyPress::homeKey}, 24 | {"end", KeyPress::endKey}, 25 | {"F1", KeyPress::F1Key}, 26 | {"F2", KeyPress::F2Key}, 27 | {"F3", KeyPress::F3Key}, 28 | {"F4", KeyPress::F4Key}, 29 | {"F5", KeyPress::F5Key}, 30 | {"F6", KeyPress::F6Key}, 31 | {"F7", KeyPress::F7Key}, 32 | {"F8", KeyPress::F8Key}, 33 | {"F9", KeyPress::F9Key}, 34 | {"F10", KeyPress::F10Key}, 35 | {"F11", KeyPress::F11Key}, 36 | {"F12", KeyPress::F12Key}, 37 | {"F13", KeyPress::F13Key}, 38 | {"F14", KeyPress::F14Key}, 39 | {"F15", KeyPress::F15Key}, 40 | {"F16", KeyPress::F16Key}, 41 | {"F17", KeyPress::F17Key}, 42 | {"F18", KeyPress::F18Key}, 43 | {"F19", KeyPress::F19Key}, 44 | {"F20", KeyPress::F20Key}, 45 | {"F21", KeyPress::F21Key}, 46 | {"F22", KeyPress::F22Key}, 47 | {"F23", KeyPress::F23Key}, 48 | {"F24", KeyPress::F24Key}, 49 | {"F25", KeyPress::F25Key}, 50 | {"F26", KeyPress::F26Key}, 51 | {"F27", KeyPress::F27Key}, 52 | {"F28", KeyPress::F28Key}, 53 | {"F29", KeyPress::F29Key}, 54 | {"F30", KeyPress::F30Key}, 55 | {"F31", KeyPress::F31Key}, 56 | {"F32", KeyPress::F32Key}, 57 | {"F33", KeyPress::F33Key}, 58 | {"F34", KeyPress::F34Key}, 59 | {"F35", KeyPress::F35Key}, 60 | {"num-pad-0", KeyPress::numberPad0}, 61 | {"num-pad-1", KeyPress::numberPad1}, 62 | {"num-pad-2", KeyPress::numberPad2}, 63 | {"num-pad-3", KeyPress::numberPad3}, 64 | {"num-pad-4", KeyPress::numberPad4}, 65 | {"num-pad-5", KeyPress::numberPad5}, 66 | {"num-pad-6", KeyPress::numberPad6}, 67 | {"num-pad-7", KeyPress::numberPad7}, 68 | {"num-pad-8", KeyPress::numberPad8}, 69 | {"num-pad-9", KeyPress::numberPad9}, 70 | {"num-pad-add", KeyPress::numberPadAdd}, 71 | {"num-pad-subtract", KeyPress::numberPadSubtract}, 72 | {"num-pad-multiply", KeyPress::numberPadMultiply}, 73 | {"num-pad-divide", KeyPress::numberPadDivide}, 74 | {"num-pad-separator", KeyPress::numberPadSeparator}, 75 | {"num-pad-decimal-point", KeyPress::numberPadDecimalPoint}, 76 | {"num-pad-equals", KeyPress::numberPadEquals}, 77 | {"num-pad-delete", KeyPress::numberPadDelete}, 78 | {"play", KeyPress::playKey}, 79 | {"stop", KeyPress::stopKey}, 80 | {"fast-forward", KeyPress::fastForwardKey}, 81 | {"rewind", KeyPress::rewindKey}}; 82 | 83 | auto it = values.find (code); 84 | return it != values.end () ? it->second : uint8_t (code.getCharPointer () [0]); 85 | } 86 | 87 | [[nodiscard]] static juce::ModifierKeys mapModifierKeys (const juce::String & modifiers) 88 | { 89 | int modifierKeys = juce::ModifierKeys::noModifiers; 90 | if (modifiers.contains ("shift")) 91 | modifierKeys |= juce::ModifierKeys::shiftModifier; 92 | if (modifiers.contains ("control")) 93 | modifierKeys |= juce::ModifierKeys::ctrlModifier; 94 | if (modifiers.contains ("alt")) 95 | modifierKeys |= juce::ModifierKeys::altModifier; 96 | #if JUCE_MAC 97 | if (modifiers.contains ("command")) 98 | modifierKeys |= juce::ModifierKeys::commandModifier; 99 | #endif 100 | 101 | return modifierKeys; 102 | } 103 | 104 | juce::KeyPress constructKeyPress (const juce::String & code, const juce::String & modifiers) 105 | { 106 | return {mapKeyCode (code), mapModifierKeys (modifiers), 0}; 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /source/cpp/source/KeyPress.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | namespace focusrite::e2e 7 | { 8 | juce::KeyPress constructKeyPress (const juce::String & code, const juce::String & modifiers); 9 | } 10 | -------------------------------------------------------------------------------- /source/cpp/source/Response.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | namespace focusrite::e2e 4 | { 5 | Response Response::ok () 6 | { 7 | return Response (juce::Result::ok ()); 8 | } 9 | 10 | Response Response::fail (const juce::String & message) 11 | { 12 | return Response (juce::Result::fail (message)); 13 | } 14 | 15 | Response::Response (juce::Result result) 16 | : _result (std::move (result)) 17 | { 18 | } 19 | 20 | Response Response::withParameter (const juce::String & name, const juce::var & value) const 21 | { 22 | Response other (*this); 23 | other.addParameter (name, value); 24 | return other; 25 | } 26 | 27 | Response Response::withUuid (const juce::Uuid & uuid) const 28 | { 29 | Response other (*this); 30 | other._uuid = uuid; 31 | return other; 32 | } 33 | 34 | juce::String Response::toJson () const 35 | { 36 | auto element = std::make_unique (); 37 | element->setProperty ("type", "response"); 38 | element->setProperty ("uuid", _uuid.toDashedString ()); 39 | element->setProperty ("success", _result.wasOk ()); 40 | 41 | if (! _result) 42 | element->setProperty ("error", _result.getErrorMessage ()); 43 | 44 | if (! _parameters.empty ()) 45 | { 46 | auto data = std::make_unique (); 47 | 48 | for (const auto & [key, value] : _parameters) 49 | data->setProperty (key, value); 50 | 51 | element->setProperty ("data", data.release ()); 52 | } 53 | 54 | return juce::JSON::toString (element.release ()); 55 | } 56 | 57 | juce::String Response::describe () const 58 | { 59 | juce::String description; 60 | 61 | description << "Success: " << juce::String (_result.wasOk () ? "true" : "false") 62 | << juce::newLine; 63 | 64 | if (! _result) 65 | description << "Error: " << _result.getErrorMessage (); 66 | 67 | for (auto && [key, value] : _parameters) 68 | description << key << ": " << value.toString () << juce::newLine; 69 | 70 | return description; 71 | } 72 | 73 | void Response::addParameter (const juce::String & name, const juce::var & value) 74 | { 75 | _parameters [name] = value; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /source/cpp/source/TestCentre.cpp: -------------------------------------------------------------------------------- 1 | #include "Connection.h" 2 | #include "DefaultCommandHandler.h" 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | namespace focusrite::e2e 10 | { 11 | [[nodiscard]] static std::optional getPort () 12 | { 13 | for (const auto & param : juce::JUCEApplicationBase::getCommandLineParameterArray ()) 14 | { 15 | if (param.contains ("--e2e-test-port=")) 16 | { 17 | int port = param.trimCharactersAtStart ("--e2e-test-port=").getIntValue (); 18 | if (std::numeric_limits::min () <= port && 19 | port <= std::numeric_limits::max ()) 20 | return port; 21 | } 22 | } 23 | 24 | return std::nullopt; 25 | } 26 | 27 | class E2ETestCentre final : public TestCentre 28 | { 29 | public: 30 | E2ETestCentre (LogLevel logLevel) 31 | : _logLevel (logLevel) 32 | { 33 | auto port = getPort (); 34 | if (! port) 35 | return; 36 | 37 | addCommandHandler (_defaultCommandHandler); 38 | 39 | _connection = Connection::create (*port); 40 | _connection->_onDataReceived = [this] (auto && block) { onDataReceived (block); }; 41 | _connection->start (); 42 | } 43 | 44 | ~E2ETestCentre () override = default; 45 | 46 | void addCommandHandler (CommandHandler & handler) override 47 | { 48 | _commandHandlers.emplace_back (handler); 49 | } 50 | 51 | void removeCommandHandler (CommandHandler & handler) override 52 | { 53 | auto it = std::remove_if (_commandHandlers.begin (), 54 | _commandHandlers.end (), 55 | [&] (auto && other) { return &handler == &other.get (); }); 56 | 57 | _commandHandlers.erase (it, _commandHandlers.end ()); 58 | } 59 | 60 | void sendEvent (const Event & event) override 61 | { 62 | send (event.toJson ()); 63 | } 64 | 65 | private: 66 | void send (const juce::String & data) 67 | { 68 | if (_connection && _connection->isConnected ()) 69 | _connection->send ({data.toRawUTF8 (), data.getNumBytesAsUTF8 ()}); 70 | } 71 | 72 | void onDataReceived (const juce::MemoryBlock & data) 73 | { 74 | auto command = Command::fromJson (data.toString ()); 75 | if (! command.isValid ()) 76 | return; 77 | 78 | logCommand (command); 79 | 80 | bool responded = false; 81 | 82 | for (auto & commandHandler : _commandHandlers) 83 | { 84 | auto response = commandHandler.get ().process (command); 85 | if (! response) 86 | continue; 87 | 88 | logResponse (*response); 89 | send (response->withUuid (command.getUuid ()).toJson ()); 90 | responded = true; 91 | 92 | if (command.getType () == "quit") 93 | juce::JUCEApplicationBase::quit (); 94 | } 95 | 96 | if (! responded) 97 | send (Response::fail ("Unhandled message").withUuid (command.getUuid ()).toJson ()); 98 | } 99 | 100 | void logCommand (const Command & command) 101 | { 102 | if (_logLevel == LogLevel::silent) 103 | return; 104 | 105 | juce::Logger::writeToLog ("Received command: "); 106 | juce::Logger::writeToLog (command.describe ()); 107 | } 108 | 109 | void logResponse (const Response & response) 110 | { 111 | if (_logLevel == LogLevel::silent) 112 | return; 113 | 114 | juce::Logger::writeToLog ("Sending response: "); 115 | juce::Logger::writeToLog (response.describe ()); 116 | } 117 | 118 | const LogLevel _logLevel; 119 | 120 | DefaultCommandHandler _defaultCommandHandler; 121 | std::vector> _commandHandlers; 122 | std::shared_ptr _connection; 123 | }; 124 | 125 | std::unique_ptr TestCentre::create (LogLevel logLevel) 126 | { 127 | return std::make_unique (logLevel); 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /source/cpp/tests/TestCommand.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | const auto exampleJson = R"identifier( 5 | { 6 | "type": "command-type", 7 | "uuid": "beb16073-dbcd-49aa-b7d1-9466582a1e0e", 8 | "args": { 9 | "string-type": "string argument", 10 | "int-type": 45675, 11 | "bool-type": true, 12 | "object-type": { 13 | "value": "object", 14 | } 15 | } 16 | } 17 | )identifier"; 18 | 19 | namespace focusrite::e2e 20 | { 21 | class CommandTests final : public juce::UnitTest 22 | { 23 | public: 24 | CommandTests () noexcept 25 | : juce::UnitTest ("Command") 26 | { 27 | } 28 | 29 | void runTest () override 30 | { 31 | struct Test 32 | { 33 | juce::String name; 34 | std::function entry; 35 | }; 36 | 37 | auto tests = { 38 | Test {"Converts type from JSON", [this] { convertsTypeFromJson (); }}, 39 | Test {"Converts UUID from JSON", [this] { convertsUuidFromJson (); }}, 40 | Test {"Converts String argument from JSON", 41 | [this] { convertsStringArgumentFromJson (); }}, 42 | Test {"Converts int argument from JSON", [this] { convertsIntArgumentFromJson (); }}, 43 | Test {"Converts bool argument from JSON", [this] { convertsBoolArgumentFromJson (); }}, 44 | Test {"Converts object argument from JSON", 45 | [this] { convertsObjectArgumentFromJson (); }}, 46 | }; 47 | 48 | for (auto && test : tests) 49 | { 50 | beginTest (test.name); 51 | test.entry (); 52 | } 53 | } 54 | 55 | void convertsTypeFromJson () 56 | { 57 | const auto command = Command::fromJson (exampleJson); 58 | expectEquals (command.getType (), juce::String ("command-type")); 59 | } 60 | 61 | void convertsUuidFromJson () 62 | { 63 | const auto command = Command::fromJson (exampleJson); 64 | expect (command.getUuid () == juce::Uuid ("beb16073-dbcd-49aa-b7d1-9466582a1e0e")); 65 | } 66 | 67 | void convertsStringArgumentFromJson () 68 | { 69 | const auto command = Command::fromJson (exampleJson); 70 | expectEquals (command.getArgument ("string-type"), juce::String ("string argument")); 71 | } 72 | 73 | void convertsIntArgumentFromJson () 74 | { 75 | static constexpr auto value = 45675; 76 | const auto command = Command::fromJson (exampleJson); 77 | expectEquals (command.getArgumentAs ("int-type"), value); 78 | } 79 | 80 | void convertsBoolArgumentFromJson () 81 | { 82 | const auto command = Command::fromJson (exampleJson); 83 | expect (command.getArgumentAs ("bool-type")); 84 | } 85 | 86 | void convertsObjectArgumentFromJson () 87 | { 88 | const auto command = Command::fromJson (exampleJson); 89 | const auto var = command.getArgumentAsVar ("object-type"); 90 | expectEquals (var.getProperty ("value", {}).toString (), juce::String ("object")); 91 | } 92 | }; 93 | 94 | [[maybe_unused]] static CommandTests commandTests; 95 | 96 | } 97 | -------------------------------------------------------------------------------- /source/cpp/tests/TestComponentSearch.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | namespace focusrite::e2e 6 | { 7 | template 8 | void runOnMessageQueue (Task task) 9 | { 10 | juce::WaitableEvent event; 11 | 12 | juce::MessageManager::callAsync ( 13 | [&] 14 | { 15 | task (); 16 | event.signal (); 17 | }); 18 | 19 | event.wait (); 20 | } 21 | 22 | class ComponentSearchTests final : public juce::UnitTest 23 | { 24 | public: 25 | ComponentSearchTests () noexcept 26 | : juce::UnitTest ("ComponentSearch") 27 | { 28 | } 29 | 30 | void runTest () override 31 | { 32 | struct Test 33 | { 34 | juce::String name; 35 | std::function entry; 36 | }; 37 | 38 | auto tests = { 39 | Test {"Finds component with component ID", [=] { findsComponentWithId (); }}, 40 | Test {"Finds component with test ID", [=] { findsComponentWithTestId (); }}, 41 | Test {"Finds nested components with slashes", 42 | [=] { findsNestedComponentsWithSlashes (); }}, 43 | }; 44 | 45 | for (auto && test : tests) 46 | { 47 | runOnMessageQueue ( 48 | [=] 49 | { 50 | beginTest (test.name); 51 | test.entry (); 52 | }); 53 | } 54 | } 55 | 56 | void findsComponentWithId () 57 | { 58 | constexpr auto componentAId = "component-a"; 59 | constexpr auto componentBId = "component-b"; 60 | constexpr auto componentCId = "component-c"; 61 | 62 | juce::TopLevelWindow window ("window", true); 63 | juce::Component componentA; 64 | juce::Component componentB; 65 | juce::Component componentC; 66 | 67 | componentA.setComponentID (componentAId); 68 | componentB.setComponentID (componentBId); 69 | componentC.setComponentID (componentCId); 70 | 71 | componentA.addAndMakeVisible (componentB); 72 | componentA.addAndMakeVisible (componentC); 73 | window.addAndMakeVisible (componentA); 74 | window.setVisible (true); 75 | 76 | expect (ComponentSearch::findWithId (componentAId) == &componentA); 77 | expect (ComponentSearch::findWithId (componentBId) == &componentB); 78 | expect (ComponentSearch::findWithId (componentCId) == &componentC); 79 | } 80 | 81 | void findsComponentWithTestId () 82 | { 83 | constexpr auto componentAId = "component-a"; 84 | constexpr auto componentBId = "component-b"; 85 | constexpr auto componentCId = "component-c"; 86 | 87 | juce::TopLevelWindow window ("window", true); 88 | juce::Component componentA; 89 | juce::Component componentB; 90 | juce::Component componentC; 91 | 92 | ComponentSearch::setTestId (componentA, componentAId); 93 | ComponentSearch::setTestId (componentB, componentBId); 94 | ComponentSearch::setTestId (componentC, componentCId); 95 | 96 | componentA.addAndMakeVisible (componentB); 97 | componentA.addAndMakeVisible (componentC); 98 | window.addAndMakeVisible (componentA); 99 | window.setVisible (true); 100 | 101 | expect (ComponentSearch::findWithId (componentAId) == &componentA); 102 | expect (ComponentSearch::findWithId (componentBId) == &componentB); 103 | expect (ComponentSearch::findWithId (componentCId) == &componentC); 104 | } 105 | 106 | void findsNestedComponentsWithSlashes () 107 | { 108 | constexpr auto componentAId = "component-a"; 109 | constexpr auto componentBId = "component-b"; 110 | constexpr auto componentCId = "component-c"; 111 | 112 | constexpr auto genericId = "generic"; 113 | 114 | juce::Component componentA; 115 | juce::Component componentB; 116 | juce::Component componentC; 117 | 118 | componentA.setComponentID (componentAId); 119 | componentB.setComponentID (componentBId); 120 | componentC.setComponentID (componentCId); 121 | 122 | componentA.addAndMakeVisible (componentB); 123 | componentA.addAndMakeVisible (componentC); 124 | 125 | juce::Component genericA; 126 | juce::Component genericB; 127 | juce::Component genericC; 128 | 129 | genericA.setComponentID (genericId); 130 | componentA.addAndMakeVisible (genericA); 131 | 132 | genericB.setComponentID (genericId); 133 | componentB.addAndMakeVisible (genericB); 134 | 135 | genericC.setComponentID (genericId); 136 | componentC.addAndMakeVisible (genericC); 137 | 138 | juce::TopLevelWindow window ("window", true); 139 | window.addAndMakeVisible (componentA); 140 | window.setVisible (true); 141 | 142 | expect (ComponentSearch::findWithId ("component-a/generic") == &genericA); 143 | expect (ComponentSearch::findWithId ("component-b/generic") == &genericB); 144 | expect (ComponentSearch::findWithId ("component-c/generic") == &genericC); 145 | expect (ComponentSearch::findWithId ("component-a/component-b/*") == &genericB); 146 | expect (ComponentSearch::findWithId ("component-a/component-c/*") == &genericC); 147 | expect (ComponentSearch::findWithId ("component-a/*/generic") == &genericB); 148 | } 149 | }; 150 | 151 | [[maybe_unused]] static ComponentSearchTests componentSearchTests; 152 | } 153 | -------------------------------------------------------------------------------- /source/cpp/tests/TestResponse.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | namespace focusrite::e2e 4 | { 5 | struct Fixture 6 | { 7 | juce::Uuid uuid; 8 | const Response okResponse = Response::ok () 9 | .withUuid (uuid) 10 | .withParameter ("string", "message") 11 | .withParameter ("number", 12345); 12 | const Response failResponse = 13 | Response::fail ("Error message").withUuid (uuid).withParameter ("double", 0.123456789); 14 | 15 | const juce::var parsedOk = juce::JSON::parse (okResponse.toJson ()); 16 | const juce::var parsedFail = juce::JSON::parse (failResponse.toJson ()); 17 | }; 18 | 19 | class ResponseTests final : public juce::UnitTest 20 | { 21 | public: 22 | ResponseTests () noexcept 23 | : juce::UnitTest ("Response") 24 | { 25 | } 26 | 27 | void initialise () override 28 | { 29 | _fixture = std::make_unique (); 30 | } 31 | 32 | void shutdown () override 33 | { 34 | _fixture.reset (); 35 | } 36 | 37 | void runTest () override 38 | { 39 | struct Test 40 | { 41 | juce::String name; 42 | std::function entry; 43 | }; 44 | 45 | auto tests = { 46 | Test {"UUID", [this] { containsUuid (); }}, 47 | Test {"Success", [this] { containsSuccess (); }}, 48 | Test {"Error message", [this] { containsErrorMessage (); }}, 49 | Test {"String parameter", [this] { stringParameter (); }}, 50 | Test {"Number parameter", [this] { numberParameter (); }}, 51 | Test {"Double parameter", [this] { doubleParameter (); }}, 52 | }; 53 | 54 | for (auto && test : tests) 55 | { 56 | beginTest (test.name); 57 | 58 | test.entry (); 59 | } 60 | } 61 | 62 | void containsUuid () 63 | { 64 | expect (juce::Uuid (_fixture->parsedOk.getProperty ("uuid", {})) == _fixture->uuid); 65 | } 66 | 67 | void containsSuccess () 68 | { 69 | expect (bool (_fixture->parsedOk.getProperty ("success", {}))); 70 | expect (! bool (_fixture->parsedFail.getProperty ("success", {}))); 71 | } 72 | 73 | void containsErrorMessage () 74 | { 75 | expectEquals (_fixture->parsedFail.getProperty ("error", {}).toString (), 76 | juce::String ("Error message")); 77 | } 78 | 79 | void stringParameter () 80 | { 81 | const auto data = _fixture->parsedOk.getProperty ("data", {}); 82 | expectEquals (data.getProperty ("string", {}).toString (), juce::String ("message")); 83 | } 84 | 85 | void numberParameter () 86 | { 87 | static constexpr auto value = 12345; 88 | const auto data = _fixture->parsedOk.getProperty ("data", {}); 89 | expectEquals (int (data.getProperty ("number", {})), value); 90 | } 91 | 92 | void doubleParameter () 93 | { 94 | static constexpr auto value = 0.123456789; 95 | static constexpr auto error = 1e-9; 96 | const auto data = _fixture->parsedFail.getProperty ("data", {}); 97 | expectWithinAbsoluteError (double (data.getProperty ("double", {})), value, error); 98 | } 99 | 100 | private: 101 | std::unique_ptr _fixture; 102 | }; 103 | 104 | [[maybe_unused]] static ResponseTests responseTests; 105 | 106 | } 107 | -------------------------------------------------------------------------------- /source/cpp/tests/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | class Application : public juce::JUCEApplicationBase 5 | { 6 | public: 7 | const juce::String getApplicationName () override // NOLINT 8 | { 9 | return {}; 10 | } 11 | 12 | const juce::String getApplicationVersion () override // NOLINT 13 | { 14 | return {}; 15 | } 16 | 17 | bool moreThanOneInstanceAllowed () override 18 | { 19 | return false; 20 | } 21 | 22 | void initialise ([[maybe_unused]] const juce::String & commandLineArguments) override 23 | { 24 | _testsResult = std::async (&Application::runTests); 25 | } 26 | 27 | void shutdown () override 28 | { 29 | juce::JUCEApplicationBase::setApplicationReturnValue ( 30 | _testsResult.get () == TestsResult::pass ? 0 : 1); 31 | } 32 | 33 | void anotherInstanceStarted ([[maybe_unused]] const juce::String & commandLineArgs) override 34 | { 35 | } 36 | 37 | void systemRequestedQuit () override 38 | { 39 | } 40 | 41 | void suspended () override 42 | { 43 | } 44 | 45 | void resumed () override 46 | { 47 | } 48 | 49 | void unhandledException (const std::exception * exception, 50 | const juce::String & sourceFile, 51 | int lineNumber) override 52 | { 53 | juce::Logger::writeToLog ("Unhandled exception in " + sourceFile + "(" + 54 | juce::String (lineNumber) + "): " + exception->what ()); 55 | juce::JUCEApplicationBase::setApplicationReturnValue (1); 56 | } 57 | 58 | private: 59 | enum class TestsResult 60 | { 61 | pass, 62 | fail, 63 | }; 64 | 65 | static TestsResult runTests () 66 | { 67 | juce::UnitTestRunner runner; 68 | runner.runAllTests (); 69 | 70 | auto testsResults = TestsResult::pass; 71 | 72 | for (int resultIndex = 0; resultIndex < runner.getNumResults (); ++resultIndex) 73 | if (const auto * result = runner.getResult (resultIndex)) 74 | if (result->failures > 0) 75 | testsResults = TestsResult::fail; 76 | 77 | juce::JUCEApplicationBase::quit (); 78 | return testsResults; 79 | } 80 | 81 | std::future _testsResult; 82 | }; 83 | 84 | START_JUCE_APPLICATION (Application) 85 | -------------------------------------------------------------------------------- /source/ts/app-connection.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import path from 'path'; 3 | import util from 'util'; 4 | import fs from 'fs'; 5 | import {Connection, EventMatchingFunction} from './connection'; 6 | import {Server} from './server'; 7 | import { 8 | ComponentVisibilityResponse, 9 | ComponentEnablementResponse, 10 | ComponentTextResponse, 11 | ScreenshotResponse, 12 | ComponentCountResponse, 13 | ResponseData, 14 | GetSliderValueResponse, 15 | GetItemIndexResponse, 16 | AccessibilityResponse, 17 | AccessibilityParentResponse, 18 | AccessibilityChildResponse, 19 | GetFocusedComponentResponse, 20 | GetComboBoxItemsResponse, 21 | } from './responses'; 22 | import {Command} from './commands'; 23 | import {minimatch} from 'minimatch'; 24 | import {waitForResult} from './poll'; 25 | import {AppProcess, EnvironmentVariables, launchApp} from './app-process'; 26 | import {ComponentHandle} from './component-handle'; 27 | 28 | const writeFile = util.promisify(fs.writeFile); 29 | 30 | let screenshotIndex = 0; 31 | 32 | interface AppConnectionOptions { 33 | appPath: string; 34 | logDirectory?: string; 35 | } 36 | 37 | export const DEFAULT_TIMEOUT = 5000; 38 | 39 | const existsAsFile = (path: string) => { 40 | try { 41 | return fs.statSync(path).isFile(); 42 | } catch (error) { 43 | return false; 44 | } 45 | }; 46 | 47 | export class AppConnection extends EventEmitter { 48 | appPath: string; 49 | process?: AppProcess; 50 | server: Server; 51 | connection?: Connection; 52 | logDirectory?: string; 53 | exitPromise?: Promise; 54 | 55 | constructor(options: AppConnectionOptions) { 56 | super(); 57 | 58 | if (!existsAsFile(options.appPath)) { 59 | throw new Error( 60 | `The specified app path (${options.appPath}) doesn't exist` 61 | ); 62 | } 63 | 64 | this.appPath = options.appPath; 65 | this.logDirectory = options.logDirectory; 66 | this.server = new Server(); 67 | 68 | this.server.on('error', () => { 69 | this.stopServer(); 70 | }); 71 | 72 | if (this.logDirectory) { 73 | fs.mkdirSync(this.logDirectory, { 74 | recursive: true, 75 | }); 76 | } 77 | } 78 | 79 | stopServer() { 80 | this.server.close(); 81 | this.connection?.kill(); 82 | } 83 | 84 | launchProcess(extraArgs: string[], env: EnvironmentVariables = {}) { 85 | this.process = launchApp({ 86 | path: this.appPath, 87 | logDirectory: this.logDirectory, 88 | extraArgs, 89 | env, 90 | }); 91 | 92 | this.process.on('error', (error) => { 93 | throw new Error(`Failed to spawn process: ${error.message}`); 94 | }); 95 | 96 | this.exitPromise = new Promise((resolve, reject) => { 97 | this.process?.on('exit', (code, signal) => { 98 | this.stopServer(); 99 | 100 | if (code) { 101 | reject(new Error(`App exited with error code: ${code}`)); 102 | } else if (signal) { 103 | reject(new Error(`App exited with signal: ${signal}`)); 104 | } else { 105 | resolve(); 106 | } 107 | }); 108 | }); 109 | } 110 | 111 | async launch(extraArgs: string[] = [], env: EnvironmentVariables = {}) { 112 | const port = await this.server.listen(); 113 | this.launchProcess(extraArgs.concat([`--e2e-test-port=${port}`]), env); 114 | const socket = await this.server.waitForConnection(); 115 | 116 | this.connection = new Connection(socket); 117 | this.connection.on('connect', () => this.emit('connect')); 118 | this.connection.on('disconnect', () => { 119 | this.server.close(); 120 | this.connection = undefined; 121 | this.emit('disconnect'); 122 | }); 123 | } 124 | 125 | kill() { 126 | this.connection?.kill(); 127 | this.server.close(); 128 | this.process?.kill(); 129 | } 130 | 131 | async sendCommand(command: Command): Promise { 132 | if (!this.connection) { 133 | throw new Error('Not connected to application'); 134 | } 135 | return await this.connection.send(command); 136 | } 137 | 138 | async waitForEvent( 139 | name: string, 140 | matchingFunction?: EventMatchingFunction, 141 | timeout?: number 142 | ): Promise { 143 | if (!this.connection) { 144 | throw new Error('Not connected to application'); 145 | } 146 | 147 | await this.connection.waitForEvent(name, matchingFunction, timeout); 148 | } 149 | 150 | async waitForExit(): Promise { 151 | if (!this.exitPromise) { 152 | throw new Error(`Process hasn't been launched`); 153 | } 154 | 155 | await this.exitPromise; 156 | } 157 | 158 | async quit(): Promise { 159 | const promise = this.waitForExit(); 160 | this.sendCommand({ 161 | type: 'quit', 162 | }); 163 | return promise; 164 | } 165 | 166 | async clickComponent(componentId: string, skip?: number): Promise { 167 | await this.sendCommand({ 168 | type: 'click-component', 169 | args: { 170 | 'component-id': componentId, 171 | 'num-clicks': 1, 172 | 'skip': skip || 0, 173 | }, 174 | }); 175 | } 176 | 177 | async doubleClickComponent( 178 | componentId: string, 179 | skip?: number 180 | ): Promise { 181 | await this.sendCommand({ 182 | type: 'click-component', 183 | args: { 184 | 'component-id': componentId, 185 | 'num-clicks': 2, 186 | 'skip': skip || 0, 187 | }, 188 | }); 189 | } 190 | 191 | async grabFocus(): Promise { 192 | await this.sendCommand({ 193 | type: 'grab-focus', 194 | }); 195 | } 196 | 197 | async keyPress( 198 | key: string, 199 | modifiers?: string, 200 | focusComponent?: string 201 | ): Promise { 202 | await this.sendCommand({ 203 | type: 'key-press', 204 | args: { 205 | 'key-code': key, 206 | 'modifiers': modifiers || '', 207 | 'focus-component': focusComponent || '', 208 | }, 209 | }); 210 | } 211 | 212 | async setSliderValue(sliderId: string, value: number): Promise { 213 | await this.sendCommand({ 214 | type: 'set-slider-value', 215 | args: { 216 | 'component-id': sliderId, 217 | value, 218 | }, 219 | }); 220 | } 221 | 222 | async getSliderValue(sliderId: string): Promise { 223 | const response = (await this.sendCommand({ 224 | type: 'get-slider-value', 225 | args: { 226 | 'component-id': sliderId, 227 | }, 228 | })) as GetSliderValueResponse; 229 | 230 | return response.value; 231 | } 232 | 233 | async getAccessibilityState( 234 | componentId: string 235 | ): Promise { 236 | return (await this.sendCommand({ 237 | type: 'get-accessibility-state', 238 | args: { 239 | 'component-id': componentId, 240 | }, 241 | })) as AccessibilityResponse; 242 | } 243 | 244 | async getAccessibilityParent(componentId: string): Promise { 245 | const parentResponse = (await this.sendCommand({ 246 | type: 'get-accessibility-parent', 247 | args: { 248 | 'component-id': componentId, 249 | }, 250 | })) as AccessibilityParentResponse; 251 | 252 | return parentResponse.parent; 253 | } 254 | 255 | async getAccessibilityChildren(componentId: string): Promise> { 256 | const childResponse = (await this.sendCommand({ 257 | type: 'get-accessibility-children', 258 | args: { 259 | 'component-id': componentId, 260 | }, 261 | })) as AccessibilityChildResponse; 262 | 263 | return childResponse.children.filter((str) => str !== ''); 264 | } 265 | 266 | async setTextEditorText(componentId: string, value: string): Promise { 267 | await this.sendCommand({ 268 | type: 'set-text-editor-text', 269 | args: { 270 | 'component-id': componentId, 271 | value, 272 | }, 273 | }); 274 | } 275 | 276 | async setComboBoxSelectedItemIndex( 277 | comboBoxId: string, 278 | value: number 279 | ): Promise { 280 | await this.sendCommand({ 281 | type: 'set-combo-box-selected-item-index', 282 | args: { 283 | 'component-id': comboBoxId, 284 | value, 285 | }, 286 | }); 287 | } 288 | 289 | async getComboBoxSelectedItemIndex(comboBoxId: string): Promise { 290 | const response = (await this.sendCommand({ 291 | type: 'get-combo-box-selected-item-index', 292 | args: { 293 | 'component-id': comboBoxId, 294 | }, 295 | })) as GetItemIndexResponse; 296 | 297 | return response.value; 298 | } 299 | 300 | async getComboBoxNumItems(comboBoxId: string): Promise { 301 | const response = (await this.sendCommand({ 302 | type: 'get-combo-box-num-items', 303 | args: { 304 | 'component-id': comboBoxId, 305 | }, 306 | })) as GetItemIndexResponse; 307 | 308 | return response.value; 309 | } 310 | 311 | async getComboBoxItems(comboBoxId: string): Promise { 312 | const response = (await this.sendCommand({ 313 | type: 'get-combo-box-items', 314 | args: { 315 | 'component-id': comboBoxId, 316 | }, 317 | })) as GetComboBoxItemsResponse; 318 | 319 | return response.items; 320 | } 321 | 322 | async invokeMenu(menu: string): Promise { 323 | await this.sendCommand({ 324 | type: 'invoke-menu', 325 | args: { 326 | title: menu, 327 | }, 328 | }); 329 | } 330 | 331 | async saveFailureScreenshot(): Promise { 332 | const dateString = new Date().toISOString().replace(/:/g, '-'); 333 | const filename = `${++screenshotIndex}-${dateString}.png`; 334 | 335 | await this.saveScreenshot('', filename); 336 | 337 | return filename; 338 | } 339 | 340 | async waitForComponentVisibilityToBe( 341 | componentName: string, 342 | visibility: boolean, 343 | timeoutInMilliseconds = DEFAULT_TIMEOUT 344 | ): Promise { 345 | try { 346 | await waitForResult( 347 | () => this.getComponentVisibility(componentName), 348 | visibility, 349 | timeoutInMilliseconds 350 | ); 351 | } catch (error) { 352 | const expectedVisibility = visibility ? 'visible' : 'hidden'; 353 | const screenshotFilename = await this.saveFailureScreenshot(); 354 | throw new Error( 355 | `Component '${componentName}' didn't become ${expectedVisibility} (see screenshot ${screenshotFilename})` 356 | ); 357 | } 358 | } 359 | 360 | async waitForComponentToBeVisible( 361 | componentName: string, 362 | timeoutInMilliseconds = DEFAULT_TIMEOUT 363 | ): Promise { 364 | await this.waitForComponentVisibilityToBe( 365 | componentName, 366 | true, 367 | timeoutInMilliseconds 368 | ); 369 | } 370 | 371 | async waitForComponentEnablementToBe( 372 | componentName: string, 373 | enablement: boolean, 374 | timeoutInMilliseconds = DEFAULT_TIMEOUT 375 | ): Promise { 376 | try { 377 | await waitForResult( 378 | () => this.getComponentEnablement(componentName), 379 | enablement, 380 | timeoutInMilliseconds 381 | ); 382 | 383 | return true; 384 | } catch (error) { 385 | const expectedEnablement = enablement ? 'enabled' : 'disabled'; 386 | const screenshotFilename = await this.saveFailureScreenshot(); 387 | throw new Error( 388 | `Component '${componentName}' didn't become ${expectedEnablement} (see screenshot ${screenshotFilename})` 389 | ); 390 | } 391 | } 392 | 393 | async waitForComponentToBeEnabled( 394 | componentName: string, 395 | timeoutInMilliseconds = DEFAULT_TIMEOUT 396 | ): Promise { 397 | return await this.waitForComponentEnablementToBe( 398 | componentName, 399 | true, 400 | timeoutInMilliseconds 401 | ); 402 | } 403 | 404 | async waitForComponentToBeDisabled( 405 | componentName: string, 406 | timeoutInMilliseconds = DEFAULT_TIMEOUT 407 | ): Promise { 408 | return await this.waitForComponentEnablementToBe( 409 | componentName, 410 | false, 411 | timeoutInMilliseconds 412 | ); 413 | } 414 | 415 | async countComponents(componentId: string, rootId: string): Promise { 416 | const result = (await this.sendCommand({ 417 | type: 'get-component-count', 418 | args: { 419 | 'component-id': componentId, 420 | 'root-id': rootId, 421 | }, 422 | })) as ComponentCountResponse; 423 | 424 | return result.count; 425 | } 426 | 427 | async saveScreenshot( 428 | componentId: string, 429 | outFileName: string 430 | ): Promise { 431 | if (!this.logDirectory) { 432 | console.error( 433 | 'Unable to save a screenshot of the app as a log directory has not been set' 434 | ); 435 | return; 436 | } 437 | 438 | const response = (await this.sendCommand({ 439 | type: 'get-screenshot', 440 | args: { 441 | 'component-id': componentId, 442 | }, 443 | })) as ScreenshotResponse; 444 | 445 | try { 446 | const outputFile = path.join(this.logDirectory, outFileName); 447 | await writeFile(outputFile, response.image, 'base64'); 448 | } catch (error) { 449 | console.error( 450 | `Error writing screenshot of ${componentId} to ${outFileName}` 451 | ); 452 | } 453 | } 454 | 455 | async getComponentVisibility(componentId: string): Promise { 456 | const response = (await this.sendCommand({ 457 | type: 'get-component-visibility', 458 | args: { 459 | 'component-id': componentId, 460 | }, 461 | })) as ComponentVisibilityResponse; 462 | 463 | return response.showing; 464 | } 465 | 466 | async getComponentEnablement(componentId: string): Promise { 467 | const response = (await this.sendCommand({ 468 | type: 'get-component-enablement', 469 | args: { 470 | 'component-id': componentId, 471 | }, 472 | })) as ComponentEnablementResponse; 473 | 474 | return response.enabled; 475 | } 476 | 477 | async getComponentText(componentId: string): Promise { 478 | const response = (await this.sendCommand({ 479 | type: 'get-component-text', 480 | args: { 481 | 'component-id': componentId, 482 | }, 483 | })) as ComponentTextResponse; 484 | 485 | return response.text; 486 | } 487 | 488 | async getFocusedComponent(): Promise { 489 | const response = (await this.sendCommand({ 490 | type: 'get-focus-component', 491 | args: {}, 492 | })) as GetFocusedComponentResponse; 493 | 494 | return response && response['component-id']; 495 | } 496 | 497 | async tabToComponent( 498 | componentPattern: string, 499 | maxNumComponents = 1024 500 | ): Promise { 501 | for (let count = 0; count < maxNumComponents; count++) { 502 | await this.keyPress('tab'); 503 | const focusedId = await this.getFocusedComponent(); 504 | if (focusedId && minimatch(focusedId, componentPattern)) { 505 | return true; 506 | } 507 | } 508 | 509 | return false; 510 | } 511 | 512 | getComponent(componentID: string) { 513 | return new ComponentHandle(componentID, this); 514 | } 515 | } 516 | -------------------------------------------------------------------------------- /source/ts/app-process.ts: -------------------------------------------------------------------------------- 1 | import {spawn} from 'child_process'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | export interface EnvironmentVariables { 6 | [key: string]: string; 7 | } 8 | 9 | const nextRunId = (() => { 10 | let runId = 1; 11 | 12 | return () => { 13 | return runId++; 14 | }; 15 | })(); 16 | 17 | const createLogFiles = (logDirectory: string) => { 18 | const runId = nextRunId(); 19 | 20 | const stdoutPath = path.join( 21 | logDirectory, 22 | `application-tests-stdout-${runId}.log` 23 | ); 24 | const stderrPath = path.join( 25 | logDirectory, 26 | `application-tests-stderr-${runId}.log` 27 | ); 28 | 29 | return { 30 | stdout: fs.createWriteStream(stdoutPath, {flags: 'a'}), 31 | stderr: fs.createWriteStream(stderrPath, {flags: 'a'}), 32 | }; 33 | }; 34 | 35 | interface Options { 36 | path: string; 37 | logDirectory?: string; 38 | extraArgs: string[]; 39 | env: EnvironmentVariables; 40 | } 41 | 42 | export const launchApp = ({path, extraArgs, env, logDirectory}: Options) => { 43 | const process = spawn(path, extraArgs, {env}); 44 | 45 | if (logDirectory) { 46 | const logs = createLogFiles(logDirectory); 47 | process.stdout.pipe(logs.stdout); 48 | process.stderr.pipe(logs.stderr); 49 | } 50 | 51 | return process; 52 | }; 53 | 54 | export type AppProcess = ReturnType; 55 | -------------------------------------------------------------------------------- /source/ts/binary-protocol.ts: -------------------------------------------------------------------------------- 1 | import {Response} from './responses'; 2 | 3 | const constants = { 4 | HEADER_SIZE: 8, 5 | DATA_OFFSET: 8, 6 | MAGIC_OFFSET: 0, 7 | SIZE_OFFSET: 4, 8 | MAGIC: 0x30061990, 9 | }; 10 | 11 | export function toBuffer(data: object) { 12 | const dataBuffer = Buffer.from(JSON.stringify(data), 'utf-8'); 13 | 14 | const buffer = Buffer.alloc(constants.HEADER_SIZE + dataBuffer.length, 0); 15 | buffer.writeUInt32LE(constants.MAGIC, constants.MAGIC_OFFSET); 16 | buffer.writeUInt32LE(dataBuffer.length, constants.SIZE_OFFSET); 17 | dataBuffer.copy(buffer, constants.DATA_OFFSET); 18 | 19 | return buffer; 20 | } 21 | 22 | export function isValid(buffer: Buffer): boolean { 23 | if (buffer.length < constants.HEADER_SIZE) { 24 | return true; 25 | } 26 | 27 | return buffer.readUInt32LE(constants.MAGIC_OFFSET) == constants.MAGIC; 28 | } 29 | 30 | interface NextResponse { 31 | response?: Response | Error; 32 | bytesConsumed: number; 33 | } 34 | 35 | export function getNextResponse(buffer: Buffer): NextResponse { 36 | if (buffer.length < constants.HEADER_SIZE) { 37 | return {bytesConsumed: 0}; 38 | } 39 | 40 | const dataSize = buffer.readUInt32LE(constants.SIZE_OFFSET); 41 | 42 | if (buffer.length < constants.HEADER_SIZE + dataSize) { 43 | return {bytesConsumed: 0}; 44 | } 45 | 46 | const rawResponse = buffer 47 | .subarray(constants.DATA_OFFSET, constants.DATA_OFFSET + dataSize) 48 | .toString(); 49 | 50 | let response: Response | Error; 51 | 52 | try { 53 | response = JSON.parse(rawResponse) as Response; 54 | } catch (error) { 55 | response = new Error(`Invalid JSON in response: ${error}`); 56 | } 57 | 58 | return { 59 | response, 60 | bytesConsumed: constants.HEADER_SIZE + dataSize, 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /source/ts/commands.ts: -------------------------------------------------------------------------------- 1 | export interface Command { 2 | type: string; 3 | args?: object; 4 | } 5 | 6 | export interface SentCommand extends Command { 7 | uuid: string; 8 | onReceived(response?: object): void; 9 | onError(error: Error): void; 10 | } 11 | -------------------------------------------------------------------------------- /source/ts/component-handle.ts: -------------------------------------------------------------------------------- 1 | import {AppConnection} from '.'; 2 | import {AccessibilityResponse} from './responses'; 3 | import {DEFAULT_TIMEOUT} from './app-connection'; 4 | 5 | export class ComponentHandle { 6 | appConnection: AppConnection; 7 | componentID: string; 8 | 9 | constructor(componentID: string, appConnection: AppConnection) { 10 | this.appConnection = appConnection; 11 | this.componentID = componentID; 12 | } 13 | 14 | async waitToBeVisible(timeoutInMilliseconds?: number) { 15 | await this.appConnection.waitForComponentToBeVisible( 16 | this.componentID, 17 | timeoutInMilliseconds 18 | ); 19 | } 20 | 21 | async waitForVisibilityToBe( 22 | visibility: boolean, 23 | timeoutInMilliseconds = DEFAULT_TIMEOUT 24 | ) { 25 | await this.appConnection.waitForComponentVisibilityToBe( 26 | this.componentID, 27 | visibility, 28 | timeoutInMilliseconds 29 | ); 30 | } 31 | 32 | async isVisible(): Promise { 33 | return this.appConnection.getComponentVisibility(this.componentID); 34 | } 35 | 36 | async waitToBeEnabled(timeoutInMilliseconds = DEFAULT_TIMEOUT) { 37 | await this.appConnection.waitForComponentToBeEnabled( 38 | this.componentID, 39 | timeoutInMilliseconds 40 | ); 41 | } 42 | 43 | async waitToBeDisabled(timeoutInMilliseconds = DEFAULT_TIMEOUT) { 44 | await this.appConnection.waitForComponentToBeDisabled( 45 | this.componentID, 46 | timeoutInMilliseconds 47 | ); 48 | } 49 | 50 | async waitForEnablementToBe( 51 | enablement: boolean, 52 | timeoutInMilliseconds = DEFAULT_TIMEOUT 53 | ) { 54 | await this.appConnection.waitForComponentEnablementToBe( 55 | this.componentID, 56 | enablement, 57 | timeoutInMilliseconds 58 | ); 59 | } 60 | 61 | async isEnabled(): Promise { 62 | return this.appConnection.getComponentEnablement(this.componentID); 63 | } 64 | 65 | async getText(): Promise { 66 | return this.appConnection.getComponentText(this.componentID); 67 | } 68 | 69 | async setTextEditorText(text: string) { 70 | await this.appConnection.setTextEditorText(this.componentID, text); 71 | } 72 | 73 | async getEnablement(): Promise { 74 | return this.appConnection.getComponentEnablement(this.componentID); 75 | } 76 | 77 | async click(skip?: number) { 78 | await this.appConnection.clickComponent(this.componentID, skip); 79 | } 80 | 81 | async doubleClick(skip?: number) { 82 | await this.appConnection.doubleClickComponent(this.componentID, skip); 83 | } 84 | 85 | async getSliderValue(): Promise { 86 | return this.appConnection.getSliderValue(this.componentID); 87 | } 88 | 89 | async setSliderValue(value: number) { 90 | await this.appConnection.setSliderValue(this.componentID, value); 91 | } 92 | 93 | async getComboBoxSelectedItemIndex(): Promise { 94 | return this.appConnection.getComboBoxSelectedItemIndex(this.componentID); 95 | } 96 | 97 | async setComboBoxSelectedItemIndex(index: number) { 98 | await this.appConnection.setComboBoxSelectedItemIndex( 99 | this.componentID, 100 | index 101 | ); 102 | } 103 | 104 | async getComboBoxItems(): Promise { 105 | return this.appConnection.getComboBoxItems(this.componentID); 106 | } 107 | 108 | async getComboBoxNumItems(): Promise { 109 | return this.appConnection.getComboBoxNumItems(this.componentID); 110 | } 111 | 112 | async getAccessibilityState(): Promise { 113 | return this.appConnection.getAccessibilityState(this.componentID); 114 | } 115 | 116 | async getAccessibilityParent(): Promise { 117 | return this.appConnection.getAccessibilityParent(this.componentID); 118 | } 119 | 120 | async getAccessibilityChildren(): Promise> { 121 | return this.appConnection.getAccessibilityChildren(this.componentID); 122 | } 123 | 124 | async keyPress(key: string, modifiers?: string) { 125 | await this.appConnection.keyPress(key, modifiers, this.componentID); 126 | } 127 | 128 | async isFocused(): Promise { 129 | return ( 130 | (await this.appConnection.getFocusedComponent()) === this.componentID 131 | ); 132 | } 133 | 134 | async tabTo(maxNumComponents = 1024): Promise { 135 | return this.appConnection.tabToComponent( 136 | this.componentID, 137 | maxNumComponents 138 | ); 139 | } 140 | 141 | async getNumChildrenWithID(childID: string): Promise { 142 | return this.appConnection.countComponents(childID, this.componentID); 143 | } 144 | 145 | async saveScreenshot(outFileName: string) { 146 | await this.appConnection.saveScreenshot(this.componentID, outFileName); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /source/ts/connection.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import {ResponseStream} from './response-stream'; 3 | 4 | import {v4 as uuidv4} from 'uuid'; 5 | import {strict as assert} from 'assert'; 6 | import {Socket} from 'net'; 7 | import {Command} from '.'; 8 | import {SentCommand} from './commands'; 9 | import {toBuffer} from './binary-protocol'; 10 | import {Event, EventResponse, Response, ResponseType} from './responses'; 11 | 12 | export type EventMatchingFunction = (event: Event) => boolean; 13 | 14 | interface WaitingEvent { 15 | name: string; 16 | matchingFunction?: EventMatchingFunction; 17 | onReceived(event: object): void; 18 | onError(): void; 19 | } 20 | 21 | export class Connection extends EventEmitter { 22 | responseStream: ResponseStream; 23 | sentCommands: SentCommand[]; 24 | receivedEvents: EventResponse[]; 25 | waitingEvents: WaitingEvent[]; 26 | socket: Socket; 27 | 28 | constructor(socket: Socket) { 29 | super(); 30 | this.responseStream = new ResponseStream(); 31 | this.sentCommands = []; 32 | this.receivedEvents = []; 33 | this.waitingEvents = []; 34 | this.socket = socket; 35 | 36 | this.socket.on('close', () => this.emit('disconnect')); 37 | this.socket.on('end', () => this.socket.destroy()); 38 | this.socket.on('connect', () => this.emit('connect')); 39 | this.socket.on('data', (data) => this.responseStream.push(data)); 40 | 41 | this.responseStream.on('response', (response: Response) => { 42 | if (response.type === ResponseType.response) { 43 | this.responseReceived(response); 44 | } else if (response.type === ResponseType.event) { 45 | this.eventReceived(response as EventResponse); 46 | } 47 | }); 48 | 49 | this.responseStream.on('error', (error) => { 50 | console.error(`Error response from app: ${error.message}`); 51 | this.socket.destroy(); 52 | }); 53 | 54 | this.socket.on('error', (error) => { 55 | if (error.message !== 'read ECONNRESET') { 56 | console.log(`Socket error from app: ${error.message}`); 57 | } 58 | 59 | this.socket.destroy(); 60 | }); 61 | } 62 | 63 | kill() { 64 | if (this.socket) this.socket.destroy(); 65 | } 66 | 67 | async send(command: Command): Promise { 68 | return new Promise((resolve, reject) => { 69 | assert(this.socket, 'Not connected'); 70 | 71 | const sentCommand = { 72 | uuid: uuidv4(), 73 | ...command, 74 | onReceived: (data: Buffer) => resolve(data), 75 | onError: (error: Error) => reject(error), 76 | }; 77 | const buffer = toBuffer(sentCommand); 78 | this.socket.write(buffer); 79 | this.sentCommands.push(sentCommand); 80 | }); 81 | } 82 | 83 | async waitForEvent( 84 | name: string, 85 | matchingFunction?: (event: object) => boolean, 86 | timeout?: number 87 | ) { 88 | return new Promise((resolve, reject) => { 89 | if (timeout) { 90 | setTimeout(reject, timeout); 91 | } 92 | this.waitingEvents.push({ 93 | name, 94 | matchingFunction, 95 | onReceived: resolve, 96 | onError: reject, 97 | }); 98 | this.notifyWaitingEvents(); 99 | }); 100 | } 101 | 102 | responseReceived(response: Response) { 103 | const command = this.sentCommands.find((element) => { 104 | return element.uuid === response.uuid; 105 | }); 106 | 107 | if (!command) { 108 | return; 109 | } 110 | 111 | if (command && response.success) { 112 | command.onReceived(response.data); 113 | } 114 | 115 | if (!response.success && command) { 116 | command.onError(new Error(response.error)); 117 | } 118 | 119 | this.sentCommands.splice(this.sentCommands.indexOf(command), 1); 120 | } 121 | 122 | eventReceived(event: EventResponse) { 123 | if (event.name) { 124 | this.receivedEvents.push(event); 125 | this.notifyWaitingEvents(); 126 | } 127 | } 128 | 129 | clearEvents() { 130 | this.receivedEvents = []; 131 | } 132 | 133 | notifyWaitingEvents() { 134 | this.waitingEvents = this.waitingEvents.filter((waitingEvent) => { 135 | let removeElement = false; 136 | 137 | this.receivedEvents.forEach((receivedEvent) => { 138 | if (waitingEvent.name !== receivedEvent.name) { 139 | return; 140 | } 141 | 142 | if ( 143 | waitingEvent.matchingFunction && 144 | !waitingEvent.matchingFunction(receivedEvent.data) 145 | ) { 146 | return; 147 | } 148 | 149 | if (!waitingEvent.onReceived) { 150 | return; 151 | } 152 | 153 | waitingEvent.onReceived(receivedEvent.data); 154 | removeElement = true; 155 | }); 156 | 157 | return !removeElement; 158 | }); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /source/ts/index.ts: -------------------------------------------------------------------------------- 1 | export {AppConnection} from './app-connection'; 2 | export {EnvironmentVariables} from './app-process'; 3 | export {Command} from './commands'; 4 | export {ComponentHandle} from './component-handle'; 5 | export {pollUntil, waitForResult} from './poll'; 6 | export {Response, Event} from './responses'; 7 | -------------------------------------------------------------------------------- /source/ts/poll.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_TIMEOUT = 10000; 2 | 3 | const wait = async (milliseconds: number) => 4 | new Promise((resolve) => setTimeout(resolve, milliseconds)); 5 | 6 | const invokeWithTimeout = async ( 7 | queryFunction: () => Promise, 8 | timeoutMs: number 9 | ) => { 10 | let timer: NodeJS.Timeout | undefined = undefined; 11 | const rejectingPromise = new Promise( 12 | (_, reject) => 13 | (timer = setTimeout(() => reject(new Error('Timed out.')), timeoutMs)) 14 | ); 15 | 16 | try { 17 | return await Promise.race([queryFunction(), rejectingPromise]); 18 | } catch { 19 | throw new Error('Timed out.'); 20 | } finally { 21 | clearTimeout(timer); 22 | } 23 | }; 24 | 25 | export async function pollUntil( 26 | matchingFunction: (data: T) => boolean, 27 | queryFunction: () => Promise, 28 | timeout = DEFAULT_TIMEOUT 29 | ): Promise { 30 | const end = Date.now() + timeout; 31 | 32 | while (Date.now() < end) { 33 | try { 34 | const data = await invokeWithTimeout(queryFunction, end - Date.now()); 35 | 36 | if (matchingFunction(data)) { 37 | return Promise.resolve(); 38 | } 39 | } catch (error) { 40 | return Promise.reject(new Error('Timed out.')); 41 | } 42 | 43 | const pollInterval = 5; 44 | await wait(pollInterval); 45 | } 46 | 47 | return Promise.reject(new Error('Timed out.')); 48 | } 49 | 50 | export async function waitForResult( 51 | queryFunction: () => Promise, 52 | expectedResult: T, 53 | timeoutMs = DEFAULT_TIMEOUT 54 | ): Promise { 55 | try { 56 | await pollUntil( 57 | (currentResult) => currentResult === expectedResult, 58 | queryFunction, 59 | timeoutMs 60 | ); 61 | } catch { 62 | throw new Error(`Result didn't become ${expectedResult}`); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /source/ts/response-stream.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import {getNextResponse, isValid} from './binary-protocol'; 3 | import {strict as assert} from 'assert'; 4 | 5 | export class ResponseStream extends EventEmitter { 6 | data: Buffer; 7 | 8 | constructor() { 9 | super(); 10 | this.data = Buffer.alloc(0); 11 | } 12 | 13 | #checkForData() { 14 | if (!isValid(this.data)) { 15 | this.emit('error', new Error('Invalid header')); 16 | return; 17 | } 18 | 19 | const nextResponse = getNextResponse(this.data); 20 | 21 | if (nextResponse.bytesConsumed === 0) { 22 | return; 23 | } 24 | 25 | this.data = this.data.subarray(nextResponse.bytesConsumed); 26 | 27 | assert(!!nextResponse.response); 28 | 29 | if (nextResponse.response instanceof Error) { 30 | this.emit('error', nextResponse.response); 31 | this.#checkForData(); 32 | return; 33 | } 34 | 35 | this.emit('response', nextResponse.response); 36 | this.#checkForData(); 37 | } 38 | 39 | push(data: Uint8Array) { 40 | this.data = Buffer.concat([this.data, data]); 41 | this.#checkForData(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /source/ts/responses.ts: -------------------------------------------------------------------------------- 1 | export interface ComponentCountResponse { 2 | count: number; 3 | } 4 | 5 | export interface ScreenshotResponse { 6 | image: string; 7 | } 8 | 9 | export interface ComponentVisibilityResponse { 10 | showing: boolean; 11 | exists: boolean; 12 | } 13 | 14 | export interface ComponentEnablementResponse { 15 | enabled: boolean; 16 | exists: boolean; 17 | } 18 | 19 | export interface ComponentTextResponse { 20 | text: string; 21 | } 22 | 23 | export interface GetSliderValueResponse { 24 | value: number; 25 | } 26 | 27 | export interface GetItemIndexResponse { 28 | value: number; 29 | } 30 | 31 | export interface GetComboBoxItemsResponse { 32 | items: string[]; 33 | } 34 | 35 | export interface AccessibilityResponse { 36 | title: string; 37 | description: string; 38 | help: string; 39 | accessible: boolean; 40 | handler: boolean; 41 | display: string; 42 | } 43 | 44 | export interface AccessibilityParentResponse { 45 | parent: string; 46 | } 47 | 48 | export interface AccessibilityChildResponse { 49 | children: string[]; 50 | } 51 | 52 | export interface GetFocusedComponentResponse { 53 | 'component-id': string; 54 | } 55 | 56 | export enum ResponseType { 57 | response = 'response', 58 | event = 'event', 59 | } 60 | 61 | export type ResponseData = object; 62 | 63 | export interface Response { 64 | uuid: string; 65 | type: ResponseType; 66 | success?: string; 67 | error?: string; 68 | data?: ResponseData; 69 | } 70 | 71 | export type Event = object; 72 | 73 | export interface EventResponse extends Response { 74 | name: string; 75 | data: ResponseData; 76 | } 77 | -------------------------------------------------------------------------------- /source/ts/server.ts: -------------------------------------------------------------------------------- 1 | import net, {Socket} from 'net'; 2 | import {EventEmitter} from 'events'; 3 | 4 | export class Server extends EventEmitter { 5 | listenSocket?: net.Server; 6 | 7 | constructor() { 8 | super(); 9 | } 10 | 11 | close() { 12 | if (this.listenSocket) { 13 | this.listenSocket.close(); 14 | } 15 | } 16 | 17 | async listen(): Promise { 18 | if (this.listenSocket) { 19 | throw new Error('Server already running'); 20 | } 21 | 22 | this.listenSocket = new net.Server(); 23 | 24 | this.listenSocket?.on('error', (error) => { 25 | this.listenSocket?.close(); 26 | this.emit('error', error); 27 | throw new Error(`Error on listen socket: ${error.message}`); 28 | }); 29 | 30 | this.listenSocket?.on('close', () => { 31 | this.listenSocket = undefined; 32 | }); 33 | 34 | return new Promise((resolve) => { 35 | this.listenSocket?.on('listening', () => { 36 | if (!this.listenSocket) { 37 | throw new Error('Listen socket has closed'); 38 | } 39 | 40 | const address = this.listenSocket.address() as net.AddressInfo; 41 | resolve(address.port); 42 | }); 43 | 44 | this.listenSocket?.listen(); 45 | }); 46 | } 47 | 48 | async waitForConnection(): Promise { 49 | if (!this.listenSocket) { 50 | throw new Error('Server not listening'); 51 | } 52 | 53 | return new Promise((resolve, reject) => { 54 | this.listenSocket?.on('close', () => 55 | reject(new Error('Listen socket closed')) 56 | ); 57 | 58 | this.listenSocket?.on('connection', resolve); 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/poll-until.spec.ts: -------------------------------------------------------------------------------- 1 | import {pollUntil} from '../source/ts/poll'; 2 | 3 | const wait = async (milliseconds: number) => 4 | new Promise((resolve) => setTimeout(resolve, milliseconds)); 5 | 6 | describe('using pollUntil to see if a function matches a value', () => { 7 | const functionThatReturns42 = () => Promise.resolve(42); 8 | const timeoutMs = 10; 9 | 10 | it('resolves if the expected value is returned within the timeout time', async () => { 11 | const didReturnExpectedValue = pollUntil( 12 | (currentResult) => currentResult === 42, 13 | functionThatReturns42, 14 | timeoutMs 15 | ); 16 | 17 | await expect(didReturnExpectedValue).resolves.not.toThrow(); 18 | }); 19 | 20 | it('rejects if the expected value is not returned within the timeout time', async () => { 21 | const didReturnExpectedValue = pollUntil( 22 | (currentResult) => currentResult === 21, 23 | functionThatReturns42, 24 | timeoutMs 25 | ); 26 | await expect(didReturnExpectedValue).rejects.toThrow(); 27 | }); 28 | 29 | it('rejects if the query function never returns anything within the timeout time', async () => { 30 | const longerThanTheTimeout = timeoutMs * 2; 31 | 32 | const didReturnExpectedValue = pollUntil( 33 | (currentResult) => currentResult === 42, 34 | async () => { 35 | await wait(longerThanTheTimeout); 36 | return await functionThatReturns42(); 37 | }, 38 | timeoutMs 39 | ); 40 | 41 | await expect(didReturnExpectedValue).rejects.toThrow(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/response-stream.spec.ts: -------------------------------------------------------------------------------- 1 | import {toBuffer} from '../source/ts/binary-protocol'; 2 | import {v4 as uuidv4} from 'uuid'; 3 | import {ResponseType} from '../source/ts/responses'; 4 | import {ResponseStream} from '../source/ts/response-stream'; 5 | 6 | describe('Response Stream', () => { 7 | let responseStream: ResponseStream; 8 | let onResponse: jest.Mock; 9 | let onError: jest.Mock; 10 | 11 | beforeEach(() => { 12 | responseStream = new ResponseStream(); 13 | onResponse = jest.fn(); 14 | onError = jest.fn(); 15 | responseStream.on('response', onResponse); 16 | responseStream.on('error', onError); 17 | }); 18 | 19 | it('parses valid response', () => { 20 | responseStream.push(toBuffer(exampleResponse)); 21 | expect(onResponse).toHaveBeenCalledWith(exampleResponse); 22 | }); 23 | 24 | it('parses valid response arriving byte by byte', () => { 25 | toBuffer(exampleResponse).forEach((byte) => 26 | responseStream.push(Buffer.from([byte])) 27 | ); 28 | expect(onResponse).toHaveBeenCalledWith(exampleResponse); 29 | }); 30 | 31 | it('rejects invalid data', () => { 32 | responseStream.push(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])); 33 | expect(onError).toHaveBeenCalled(); 34 | }); 35 | 36 | it('rejects invalid data with a valid header', () => { 37 | const buffer = toBuffer(exampleResponse); 38 | buffer[buffer.length - 1] = '∆'.charCodeAt(0); 39 | 40 | responseStream.push(buffer); 41 | expect(onError).toHaveBeenCalled(); 42 | }); 43 | 44 | it('retrieves valid responses after receiving an invalid response', () => { 45 | const invalidBuffer = toBuffer(exampleResponse); 46 | invalidBuffer[invalidBuffer.length - 1] = '∆'.charCodeAt(0); 47 | 48 | const validBuffer1 = toBuffer(exampleResponse); 49 | 50 | const exampleResponse2 = {hello: 'world'}; 51 | const validBuffer2 = toBuffer(exampleResponse2); 52 | 53 | responseStream.push( 54 | Buffer.concat([invalidBuffer, validBuffer1, validBuffer2]) 55 | ); 56 | 57 | expect(onError).toHaveBeenCalled(); 58 | expect(onResponse).toHaveBeenNthCalledWith(1, exampleResponse); 59 | expect(onResponse).toHaveBeenNthCalledWith(2, exampleResponse2); 60 | }); 61 | 62 | it('parses valid responses arriving together', () => { 63 | const response1 = {hello: 'world'}; 64 | const response2 = {foo: 'bar'}; 65 | 66 | const combinedBuffer = Buffer.concat([ 67 | toBuffer(response1), 68 | toBuffer(response2), 69 | ]); 70 | 71 | responseStream.push(combinedBuffer); 72 | 73 | expect(onResponse).toHaveBeenCalledTimes(2); 74 | expect(onResponse).toHaveBeenNthCalledWith(1, response1); 75 | expect(onResponse).toHaveBeenNthCalledWith(2, response2); 76 | }); 77 | 78 | it('parses valid responses arriving in irregular chunks', () => { 79 | const response1 = {hello: 'world'}; 80 | const response2 = {foo: 'bar'}; 81 | 82 | const combinedBuffer = Buffer.concat([ 83 | toBuffer(response1), 84 | toBuffer(response2), 85 | ]); 86 | 87 | const bufferSize = combinedBuffer.length; 88 | const chunkSize = Math.floor(bufferSize / 3); 89 | 90 | responseStream.push(combinedBuffer.subarray(0, chunkSize)); 91 | responseStream.push(combinedBuffer.subarray(chunkSize, 2 * chunkSize)); 92 | responseStream.push(combinedBuffer.subarray(2 * chunkSize)); 93 | 94 | expect(onResponse).toHaveBeenCalledTimes(2); 95 | expect(onResponse).toHaveBeenNthCalledWith(1, response1); 96 | expect(onResponse).toHaveBeenNthCalledWith(2, response2); 97 | }); 98 | }); 99 | 100 | const exampleResponse = { 101 | uuid: uuidv4(), 102 | type: ResponseType.response, 103 | foo: 'foo', 104 | bar: 'bar', 105 | }; 106 | -------------------------------------------------------------------------------- /tests/wait-for-result.spec.ts: -------------------------------------------------------------------------------- 1 | import {waitForResult} from '../source/ts/poll'; 2 | 3 | describe('waiting for a function to return a particular value', () => { 4 | const functionThatReturns42 = () => Promise.resolve(42); 5 | const timeoutMs = 10; 6 | 7 | it('becomes the expected value within the timeout time', async () => { 8 | const expectedValue = 42; 9 | 10 | await expect( 11 | waitForResult(functionThatReturns42, expectedValue, timeoutMs) 12 | ).resolves.not.toThrow(); 13 | }); 14 | 15 | it('throws when the function does not return the expected value within the timeout time', async () => { 16 | const expectedValue = 13; 17 | await expect( 18 | waitForResult(functionThatReturns42, expectedValue, timeoutMs) 19 | ).rejects.toThrow(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "lib": ["ES2020"], 9 | "module": "commonjs", 10 | "outDir": "./dist", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "ES2020" 16 | }, 17 | "exclude": ["node_modules"], 18 | "include": ["./source/ts/**/*.ts"] 19 | } 20 | --------------------------------------------------------------------------------