ruby_raider 3.13.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/runtime.yml +38 -0
- data/.github/workflows/system_tests.yml +1 -23
- data/.reek.yml +1 -0
- data/.rspec +1 -0
- data/Gemfile +2 -0
- data/README.md +16 -49
- data/RELEASE.md +22 -0
- data/bin/raider +5 -0
- data/lib/commands/scaffolding_commands.rb +2 -25
- data/lib/commands/utility_commands.rb +23 -3
- data/lib/generators/automation/automation_generator.rb +0 -5
- data/lib/generators/automation/templates/component.tt +1 -0
- data/lib/generators/automation/templates/home.tt +2 -2
- data/lib/generators/automation/templates/login.tt +6 -26
- data/lib/generators/automation/templates/page.tt +23 -18
- data/lib/generators/automation/templates/partials/home_page_selector.tt +1 -2
- data/lib/generators/automation/templates/partials/initialize_selector.tt +0 -8
- data/lib/generators/automation/templates/partials/pdp_page_selector.tt +1 -2
- data/lib/generators/automation/templates/partials/url_methods.tt +8 -2
- data/lib/generators/automation/templates/partials/visit_method.tt +0 -5
- data/lib/generators/automation/templates/pdp.tt +1 -9
- data/lib/generators/common_generator.rb +0 -2
- data/lib/generators/generator.rb +12 -19
- data/lib/generators/helper_generator.rb +21 -19
- data/lib/generators/infrastructure/templates/azure_pipelines.tt +1 -1
- data/lib/generators/infrastructure/templates/github.tt +2 -3
- data/lib/generators/infrastructure/templates/github_appium.tt +0 -8
- data/lib/generators/invoke_generators.rb +4 -23
- data/lib/generators/menu_generator.rb +1 -9
- data/lib/generators/rspec/templates/accessibility_spec.tt +1 -9
- data/lib/generators/rspec/templates/spec.tt +8 -24
- data/lib/generators/template_renderer/partial_cache.rb +3 -1
- data/lib/generators/template_renderer.rb +1 -1
- data/lib/generators/templates/common/gemfile.tt +3 -10
- data/lib/generators/templates/common/git_ignore.tt +0 -5
- data/lib/generators/templates/common/partials/mobile_config.tt +3 -1
- data/lib/generators/templates/common/partials/web_config.tt +10 -1
- data/lib/generators/templates/common/rakefile.tt +0 -15
- data/lib/generators/templates/common/read_me.tt +357 -250
- data/lib/generators/templates/common/rubocop.tt +0 -2
- data/lib/generators/templates/helpers/driver_helper.tt +8 -0
- data/lib/generators/templates/helpers/gesture_helper.tt +78 -0
- data/lib/generators/templates/helpers/network_helper.tt +521 -0
- data/lib/generators/templates/helpers/partials/quit_driver.tt +0 -2
- data/lib/generators/templates/helpers/partials/screenshot.tt +0 -2
- data/lib/generators/templates/helpers/partials/selenium_driver.tt +5 -2
- data/lib/generators/templates/helpers/screenshot_helper.tt +490 -0
- data/lib/generators/templates/helpers/spec_helper.tt +39 -29
- data/lib/generators/templates/helpers/trace_helper.tt +240 -95
- data/lib/generators/templates/helpers/video_helper.tt +3 -8
- data/lib/ruby_raider/runtime/actionability.rb +129 -0
- data/lib/ruby_raider/runtime/actionable.rb +49 -0
- data/lib/ruby_raider/runtime/by_role.rb +55 -0
- data/lib/ruby_raider/runtime/config.rb +79 -0
- data/lib/ruby_raider/runtime/context.rb +38 -0
- data/lib/ruby_raider/runtime/errors.rb +69 -0
- data/lib/ruby_raider/runtime/finders.rb +73 -0
- data/lib/ruby_raider/runtime/js/actionability.js +134 -0
- data/lib/ruby_raider/runtime/js/role_engine.js +315 -0
- data/lib/ruby_raider/runtime/locator_converter.rb +72 -0
- data/lib/ruby_raider/runtime/matchers/base.rb +222 -0
- data/lib/ruby_raider/runtime/matchers/element.rb +183 -0
- data/lib/ruby_raider/runtime/matchers/page.rb +71 -0
- data/lib/ruby_raider/runtime/matchers/presence.rb +41 -0
- data/lib/ruby_raider/runtime/matchers/retrying_block.rb +104 -0
- data/lib/ruby_raider/runtime/observer.rb +170 -0
- data/lib/ruby_raider/runtime/role_finder.rb +83 -0
- data/lib/ruby_raider/runtime/rspec.rb +78 -0
- data/lib/ruby_raider/runtime/selectors.rb +60 -0
- data/lib/ruby_raider/runtime/strict.rb +142 -0
- data/lib/ruby_raider/runtime/wait.rb +84 -0
- data/lib/ruby_raider/runtime.rb +20 -0
- data/lib/scaffolding/name_normalizer.rb +1 -1
- data/lib/scaffolding/project_detector.rb +2 -28
- data/lib/scaffolding/scaffolding.rb +0 -20
- data/lib/scaffolding/templates/component.tt +0 -14
- data/lib/scaffolding/templates/page_object.tt +2 -14
- data/lib/scaffolding/templates/spec.tt +1 -9
- data/lib/utilities/utilities.rb +10 -8
- data/lib/version +1 -1
- data/ruby_raider.gemspec +2 -1
- data/sig/commands/scaffolding_commands.rbs +0 -2
- data/sig/commands/utility_commands.rbs +6 -0
- data/sig/generators/generator.rbs +0 -4
- data/sig/generators/helper_generator.rbs +0 -1
- data/sig/generators/menu_generator.rbs +0 -1
- data/sig/ruby_raider/runtime/actionability.rbs +25 -0
- data/sig/ruby_raider/runtime/actionable.rbs +13 -0
- data/sig/ruby_raider/runtime/by_role.rbs +15 -0
- data/sig/ruby_raider/runtime/config.rbs +26 -0
- data/sig/ruby_raider/runtime/context.rbs +12 -0
- data/sig/ruby_raider/runtime/errors.rbs +36 -0
- data/sig/ruby_raider/runtime/finders.rbs +26 -0
- data/sig/ruby_raider/runtime/locator_converter.rbs +21 -0
- data/sig/ruby_raider/runtime/matchers/base.rbs +64 -0
- data/sig/ruby_raider/runtime/matchers/element.rbs +78 -0
- data/sig/ruby_raider/runtime/matchers/page.rbs +38 -0
- data/sig/ruby_raider/runtime/matchers/presence.rbs +21 -0
- data/sig/ruby_raider/runtime/matchers/retrying_block.rbs +27 -0
- data/sig/ruby_raider/runtime/observer.rbs +46 -0
- data/sig/ruby_raider/runtime/role_finder.rbs +18 -0
- data/sig/ruby_raider/runtime/selectors.rbs +21 -0
- data/sig/ruby_raider/runtime/strict.rbs +35 -0
- data/sig/ruby_raider/runtime/wait.rbs +17 -0
- data/sig/ruby_raider/runtime.rbs +2 -0
- data/sig/scaffolding/project_detector.rbs +0 -4
- data/sig/scaffolding/scaffolding.rbs +0 -5
- data/sig/utilities/utilities.rbs +2 -1
- data/sig/vendor/selenium_webdriver.rbs +71 -0
- data/spec/commands/raider_commands_spec.rb +38 -3
- data/spec/commands/scaffolding_commands_spec.rb +27 -0
- data/spec/commands/utility_commands_spec.rb +53 -0
- data/spec/generators/gesture_helper_spec.rb +123 -0
- data/spec/generators/network_helper_spec.rb +634 -0
- data/spec/generators/screenshot_helper_spec.rb +776 -0
- data/spec/generators/template_renderer_spec.rb +0 -4
- data/spec/generators/trace_helper_spec.rb +489 -97
- data/spec/generators/video_helper_spec.rb +0 -9
- data/spec/integration/commands/browser_update_after_creation_spec.rb +0 -8
- data/spec/integration/commands/scaffolding_commands_spec.rb +12 -70
- data/spec/integration/commands/utility_commands_spec.rb +19 -41
- data/spec/integration/content/azure_content_spec.rb +2 -6
- data/spec/integration/content/ci_content_spec.rb +1 -55
- data/spec/integration/content/common_content_spec.rb +1 -118
- data/spec/integration/content/config_content_spec.rb +34 -31
- data/spec/integration/content/gemfile_content_spec.rb +7 -72
- data/spec/integration/content/helper_content_spec.rb +105 -99
- data/spec/integration/content/page_content_spec.rb +85 -87
- data/spec/integration/content/readme_content_spec.rb +134 -75
- data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
- data/spec/integration/content/rubocop_content_spec.rb +1 -1
- data/spec/integration/content/syntax_validation_spec.rb +2 -2
- data/spec/integration/content/test_content_spec.rb +23 -147
- data/spec/integration/end_to_end_features_spec.rb +1 -43
- data/spec/integration/end_to_end_spec.rb +319 -326
- data/spec/integration/generators/automation_generator_spec.rb +3 -37
- data/spec/integration/generators/axe_addon_spec.rb +2 -35
- data/spec/integration/generators/azure_generator_spec.rb +1 -1
- data/spec/integration/generators/common_generator_spec.rb +1 -40
- data/spec/integration/generators/config_features_spec.rb +1 -24
- data/spec/integration/generators/github_generator_spec.rb +0 -12
- data/spec/integration/generators/helpers_generator_spec.rb +74 -80
- data/spec/integration/generators/invoke_generators_spec.rb +54 -45
- data/spec/integration/generators/rspec_generator_spec.rb +0 -6
- data/spec/integration/scaffolding_e2e_spec.rb +17 -179
- data/spec/integration/spec_helper.rb +4 -6
- data/spec/integration/trace_format_checker.rb +11 -2
- data/spec/integration/trace_page_server.rb +72 -5
- data/spec/menus/menu_generator_spec.rb +14 -39
- data/spec/runtime/actionability_spec.rb +207 -0
- data/spec/runtime/actionable_spec.rb +125 -0
- data/spec/runtime/browser/actionability_script_browser_spec.rb +150 -0
- data/spec/runtime/browser/actionable_browser_spec.rb +98 -0
- data/spec/runtime/browser/finders_browser_spec.rb +148 -0
- data/spec/runtime/browser/matchers_browser_spec.rb +67 -0
- data/spec/runtime/browser/retrying_block_browser_spec.rb +37 -0
- data/spec/runtime/browser/role_engine_browser_spec.rb +184 -0
- data/spec/runtime/browser/strict_browser_spec.rb +38 -0
- data/spec/runtime/by_role_spec.rb +52 -0
- data/spec/runtime/config_spec.rb +79 -0
- data/spec/runtime/context_spec.rb +73 -0
- data/spec/runtime/errors_spec.rb +89 -0
- data/spec/runtime/finders_spec.rb +165 -0
- data/spec/runtime/fixtures/actionability.html +70 -0
- data/spec/runtime/fixtures/locators.html +46 -0
- data/spec/runtime/fixtures/matchers.html +33 -0
- data/spec/runtime/fixtures/roles.html +162 -0
- data/spec/runtime/matchers/base_spec.rb +165 -0
- data/spec/runtime/matchers/element_spec.rb +277 -0
- data/spec/runtime/matchers/page_spec.rb +102 -0
- data/spec/runtime/matchers/presence_spec.rb +59 -0
- data/spec/runtime/matchers/retrying_block_spec.rb +165 -0
- data/spec/runtime/observer_spec.rb +341 -0
- data/spec/runtime/role_finder_spec.rb +156 -0
- data/spec/runtime/role_query_spec.rb +75 -0
- data/spec/runtime/selectors_spec.rb +135 -0
- data/spec/runtime/strict_spec.rb +257 -0
- data/spec/runtime/support/browser.rb +48 -0
- data/spec/runtime/support/fake_bridge.rb +117 -0
- data/spec/runtime/wait_spec.rb +71 -0
- data/spec/scaffolding/name_normalizer_spec.rb +3 -2
- data/spec/scaffolding/scaffold_project_detector_spec.rb +4 -43
- data/spec/system/selenium_spec.rb +1 -5
- data/spec/system/support/system_test_helper.rb +2 -5
- data/spec/utilities/headless_config_spec.rb +0 -7
- data/spec/utilities/utilities_spec.rb +24 -0
- metadata +113 -48
- data/lib/generators/automation/templates/header.tt +0 -9
- data/lib/generators/cucumber/cucumber_generator.rb +0 -58
- data/lib/generators/cucumber/templates/accessibility_feature.tt +0 -5
- data/lib/generators/cucumber/templates/accessibility_steps.tt +0 -17
- data/lib/generators/cucumber/templates/cucumber.tt +0 -1
- data/lib/generators/cucumber/templates/env.tt +0 -7
- data/lib/generators/cucumber/templates/feature.tt +0 -21
- data/lib/generators/cucumber/templates/partials/appium_env.tt +0 -26
- data/lib/generators/cucumber/templates/partials/driver_world.tt +0 -18
- data/lib/generators/cucumber/templates/partials/mobile_steps.tt +0 -20
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +0 -120
- data/lib/generators/cucumber/templates/partials/watir_env.tt +0 -48
- data/lib/generators/cucumber/templates/partials/watir_world.tt +0 -4
- data/lib/generators/cucumber/templates/partials/web_steps.tt +0 -28
- data/lib/generators/cucumber/templates/steps.tt +0 -5
- data/lib/generators/cucumber/templates/user_factory.tt +0 -15
- data/lib/generators/cucumber/templates/world.tt +0 -7
- data/lib/generators/templates/helpers/browser_helper.tt +0 -31
- data/lib/generators/templates/helpers/raider_element.tt +0 -159
- data/lib/generators/templates/helpers/wait_helper.tt +0 -611
- data/lib/scaffolding/templates/feature.tt +0 -6
- data/lib/scaffolding/templates/steps.tt +0 -19
- data/sig/generators/cucumber/cucumber_generator.rbs +0 -12
- data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +0 -175
- data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +0 -24
- data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +0 -37
- data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +0 -33
- data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +0 -174
- data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +0 -37
- data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +0 -33
- data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +0 -49
- data/spec/integration/content/cucumber_reporter_content_spec.rb +0 -161
- data/spec/integration/generators/cucumber_generator_spec.rb +0 -100
- data/spec/integration/helpers/wait_helper_mobile_spec.rb +0 -281
- data/spec/integration/helpers/wait_helper_spec.rb +0 -679
- data/spec/integration/helpers/wait_helper_support.rb +0 -183
- data/spec/system/watir_spec.rb +0 -38
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
require_relative 'config'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'wait'
|
|
7
|
+
require_relative 'context'
|
|
8
|
+
|
|
9
|
+
module Raider
|
|
10
|
+
# Waits for an element to be actionable before an action runs. On web contexts
|
|
11
|
+
# the checks run in the browser (js/actionability.js); on native ones the gate
|
|
12
|
+
# polls displayed?/enabled?. This module owns the check tables and the poll loops.
|
|
13
|
+
# :reek:TooManyConstants { max_constants: 6 } -- one table per check set, the script and the notice
|
|
14
|
+
module Actionability
|
|
15
|
+
CHECKS = {
|
|
16
|
+
click: %w[visible enabled inert stable events],
|
|
17
|
+
send_keys: %w[visible enabled editable inert],
|
|
18
|
+
clear: %w[visible enabled editable inert],
|
|
19
|
+
submit: %w[visible enabled inert]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
NATIVE_CHECKS = {
|
|
23
|
+
click: %w[visible enabled],
|
|
24
|
+
send_keys: %w[visible enabled],
|
|
25
|
+
clear: %w[visible enabled],
|
|
26
|
+
submit: []
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
PHRASES = {
|
|
30
|
+
'visible' => 'be visible',
|
|
31
|
+
'enabled' => 'be enabled',
|
|
32
|
+
'editable' => 'be editable',
|
|
33
|
+
'inert' => 'not be inert',
|
|
34
|
+
'stable' => 'be stable',
|
|
35
|
+
'events' => 'receive events'
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
SCRIPT = File.read(File.join(__dir__, 'js', 'actionability.js')).freeze
|
|
39
|
+
FIRST_POLL = { 'check' => 'stable', 'first' => true }.freeze
|
|
40
|
+
NOTICE = '[raider] actionability on (Raider.actionability = false or force: true to skip)'
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Returns the number of not-ready polls (0 when skipped or ready at once).
|
|
44
|
+
def ensure(element, action, force:)
|
|
45
|
+
return 0 unless Raider.actionability
|
|
46
|
+
|
|
47
|
+
announce
|
|
48
|
+
web = Context.web_for?(element)
|
|
49
|
+
return attached(element, web) if force
|
|
50
|
+
|
|
51
|
+
poll(element) { web ? polled(element, action) : native_failure(element, action) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check(element, action)
|
|
55
|
+
element.instance_variable_get(:@bridge).execute_script(SCRIPT, element, CHECKS.fetch(action))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset_notice
|
|
59
|
+
@announced = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# force skips every check but attached: a stale element raises here.
|
|
65
|
+
def attached(element, web)
|
|
66
|
+
web ? element.tag_name : element.displayed?
|
|
67
|
+
0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Counts the polls whose block returns a failing check; the last one names the timeout.
|
|
71
|
+
def poll(element)
|
|
72
|
+
last = nil
|
|
73
|
+
retries = 0
|
|
74
|
+
started = clock
|
|
75
|
+
Wait.until(timeout: Raider.timeout) do
|
|
76
|
+
last = yield
|
|
77
|
+
next true unless last
|
|
78
|
+
|
|
79
|
+
retries += 1
|
|
80
|
+
throw :not_ready, last['check']
|
|
81
|
+
end
|
|
82
|
+
retries
|
|
83
|
+
rescue TimeoutError
|
|
84
|
+
raise not_actionable(element, last, clock - started, retries)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Plain Appium commands, no script: the first check that fails, else nil.
|
|
88
|
+
def native_failure(element, action)
|
|
89
|
+
failing = NATIVE_CHECKS.fetch(action).find { |name| !native_ready?(element, name) }
|
|
90
|
+
failing && { 'check' => failing }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The first poll only stores a rect for stable to compare with, so a short pause
|
|
94
|
+
# (inside the block; Wait.until's interval is fixed) replaces the full poll interval.
|
|
95
|
+
def polled(element, action)
|
|
96
|
+
result = check(element, action)
|
|
97
|
+
return result unless result == FIRST_POLL
|
|
98
|
+
|
|
99
|
+
sleep 0.02
|
|
100
|
+
check(element, action)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def native_ready?(element, name)
|
|
104
|
+
name == 'visible' ? element.displayed? : element.enabled?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def clock
|
|
108
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def not_actionable(element, last, waited, retries)
|
|
112
|
+
check = last.fetch('check')
|
|
113
|
+
NotActionable.new(Describe.element(element), PHRASES.fetch(check, check), covering: covering(last), waited: waited, retries: retries)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def covering(last)
|
|
117
|
+
found = last['covering']
|
|
118
|
+
found&.transform_keys(&:to_sym)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def announce
|
|
122
|
+
return if @announced || ENV['RAIDER_QUIET'] == '1'
|
|
123
|
+
|
|
124
|
+
@announced = true
|
|
125
|
+
$stderr.puts NOTICE # rubocop:disable Style/StderrPuts -- the notice must not be silenced by -W0
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
require_relative 'observer'
|
|
5
|
+
require_relative 'actionability'
|
|
6
|
+
|
|
7
|
+
module Raider
|
|
8
|
+
# Waits for actionability before each Element action. force: true skips the
|
|
9
|
+
# checks except that the element must still be attached.
|
|
10
|
+
module Actionable
|
|
11
|
+
def click(force: false)
|
|
12
|
+
Observer.emit_action(self, :click) do |run|
|
|
13
|
+
raider_gate(run, :click, force)
|
|
14
|
+
super()
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def send_keys(*keys, force: false)
|
|
19
|
+
Observer.emit_action(self, :send_keys, keys) do |run|
|
|
20
|
+
raider_gate(run, :send_keys, force)
|
|
21
|
+
super(*keys)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
alias send_key send_keys
|
|
25
|
+
|
|
26
|
+
def clear(force: false)
|
|
27
|
+
Observer.emit_action(self, :clear) do |run|
|
|
28
|
+
raider_gate(run, :clear, force)
|
|
29
|
+
super()
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def submit(force: false)
|
|
34
|
+
Observer.emit_action(self, :submit) do |run|
|
|
35
|
+
raider_gate(run, :submit, force)
|
|
36
|
+
super()
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def raider_gate(run, action, force)
|
|
43
|
+
retries = Actionability.ensure(self, action, force: force)
|
|
44
|
+
run&.ready(retries)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Selenium::WebDriver::Element.prepend(Raider::Actionable)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raider
|
|
4
|
+
# The WAI-ARIA 1.2 concrete roles, plus generic.
|
|
5
|
+
ROLES = %w[
|
|
6
|
+
alert alertdialog application article banner blockquote button caption cell checkbox code columnheader
|
|
7
|
+
combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form
|
|
8
|
+
generic grid gridcell group heading img insertion link list listbox listitem log main marquee math menu
|
|
9
|
+
menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation
|
|
10
|
+
progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider
|
|
11
|
+
spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer
|
|
12
|
+
toolbar tooltip tree treegrid treeitem
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Builds the role locator: a Hash the finders turn into a raider role query.
|
|
16
|
+
module RoleLocator
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def build(role, name, exact, include_hidden, level = nil)
|
|
20
|
+
token = validated_role(role)
|
|
21
|
+
raise ArgumentError, "name must be a String, got #{name.class}" unless name.nil? || name.is_a?(String)
|
|
22
|
+
|
|
23
|
+
validated_level(token, level)
|
|
24
|
+
options = { name: name, exact: (true if exact), include_hidden: (true if include_hidden), level: level }.compact
|
|
25
|
+
{ role: options.empty? ? [token] : [token, options] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# level: filters headings by h1-h6 or aria-level; nil means any level.
|
|
29
|
+
def validated_level(token, level)
|
|
30
|
+
return if level.nil?
|
|
31
|
+
raise ArgumentError, "level: only applies to the heading role, not '#{token}'" unless token == 'heading'
|
|
32
|
+
return if level.is_a?(Integer) && level.between?(1, 6)
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "level: must be an Integer from 1 to 6, got #{level.inspect}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validated_role(role)
|
|
38
|
+
token = role.to_s
|
|
39
|
+
return token if ROLES.include?(token)
|
|
40
|
+
|
|
41
|
+
raise ArgumentError, "unknown role '#{token}'; roles are the ARIA role names such as button, link, textbox"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.by_role(role, name: nil, exact: false, include_hidden: false, level: nil)
|
|
46
|
+
RoleLocator.build(role, name, exact, include_hidden, level)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Locator helpers for page objects and wait helpers.
|
|
50
|
+
module Helpers
|
|
51
|
+
def by_role(role, **options)
|
|
52
|
+
Raider.by_role(role, **options)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
# Runtime configuration. Precedence per key: explicit setter, then env,
|
|
6
|
+
# then config/config.yml, then the default.
|
|
7
|
+
module Raider
|
|
8
|
+
CONFIG_FILE = 'config/config.yml'
|
|
9
|
+
FALSY = %w[off false 0 no].freeze
|
|
10
|
+
|
|
11
|
+
# Load-order and configuration mistakes.
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def strict
|
|
16
|
+
resolve(:strict, 'RAIDER_STRICT', true) { |value| truthy?(value) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def actionability
|
|
20
|
+
resolve(:actionability, 'RAIDER_ACTIONABILITY', true) { |value| truthy?(value) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def timeout
|
|
24
|
+
resolve(:timeout, 'RAIDER_TIMEOUT', 10) { |value| number(value) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_id_attribute
|
|
28
|
+
resolve(:test_id_attribute, 'RAIDER_TEST_ID_ATTRIBUTE', 'data-testid', &:to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_writer :strict, :actionability, :timeout, :test_id_attribute
|
|
32
|
+
|
|
33
|
+
# Any object (or nil) receiving action events; see Raider::Observer.
|
|
34
|
+
attr_accessor :observer
|
|
35
|
+
|
|
36
|
+
def reset
|
|
37
|
+
@strict = @actionability = @timeout = @test_id_attribute = nil
|
|
38
|
+
@observer = nil
|
|
39
|
+
@file_config = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def resolve(key, env_name, default)
|
|
45
|
+
explicit = instance_variable_get("@#{key}")
|
|
46
|
+
return explicit unless explicit.nil?
|
|
47
|
+
|
|
48
|
+
raw = ENV.fetch(env_name, nil)
|
|
49
|
+
raw = file_config[key.to_s] if raw.nil? || raw.empty?
|
|
50
|
+
raw.nil? ? default : yield(raw)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def file_config
|
|
54
|
+
@file_config ||= load_file
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_file
|
|
58
|
+
return {} unless File.exist?(CONFIG_FILE)
|
|
59
|
+
|
|
60
|
+
data = YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol])
|
|
61
|
+
data.is_a?(Hash) ? data : {}
|
|
62
|
+
rescue Psych::Exception => e
|
|
63
|
+
raise Error, "cannot read #{CONFIG_FILE}: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def truthy?(value)
|
|
67
|
+
return value if [true, false].include?(value)
|
|
68
|
+
|
|
69
|
+
!FALSY.include?(value.to_s.strip.downcase)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def number(value)
|
|
73
|
+
return value if value.is_a?(Numeric)
|
|
74
|
+
|
|
75
|
+
float = Float(value)
|
|
76
|
+
float == float.to_i ? float.to_i : float
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
|
|
5
|
+
module Raider
|
|
6
|
+
# Decides whether strictness and actionability apply: on for Selenium drivers
|
|
7
|
+
# and Appium webviews, off on an Appium NATIVE_APP context.
|
|
8
|
+
module Context
|
|
9
|
+
NATIVE = 'NATIVE_APP'
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def web?(bridge)
|
|
13
|
+
return true unless contextual?(bridge)
|
|
14
|
+
|
|
15
|
+
bridge.current_context != NATIVE
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def web_for?(search_context)
|
|
19
|
+
bridge = search_context.instance_variable_get(:@bridge)
|
|
20
|
+
bridge.nil? || web?(bridge)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Appium bridges answer current_context; an event-firing wrapper delegates it.
|
|
26
|
+
def contextual?(bridge)
|
|
27
|
+
# selenium 4.30's event-firing wrapper lacks respond_to_missing?, so look at its delegate.
|
|
28
|
+
bridge = bridge.instance_variable_get(:@delegate) if event_firing?(bridge)
|
|
29
|
+
bridge.respond_to?(:current_context)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def event_firing?(bridge)
|
|
33
|
+
defined?(Selenium::WebDriver::Support::EventFiringBridge) &&
|
|
34
|
+
bridge.is_a?(Selenium::WebDriver::Support::EventFiringBridge)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
require_relative 'config'
|
|
5
|
+
|
|
6
|
+
module Raider
|
|
7
|
+
# Formats elements and durations for error messages.
|
|
8
|
+
module Describe
|
|
9
|
+
TEXT_LIMIT = 30
|
|
10
|
+
CLASS_LIMIT = 2
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def seconds(value)
|
|
15
|
+
rounded = value.round(2)
|
|
16
|
+
rounded == rounded.to_i ? rounded.to_i : rounded
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def element(element)
|
|
20
|
+
tag = element.tag_name
|
|
21
|
+
id = element.attribute('id').to_s
|
|
22
|
+
classes = element.attribute('class').to_s.split.first(CLASS_LIMIT).join(' ')
|
|
23
|
+
text = element.text.to_s.strip[0, TEXT_LIMIT]
|
|
24
|
+
open = "<#{tag}#{attribute_part('id', id)}#{attribute_part('class', classes)}>"
|
|
25
|
+
text.empty? ? open : "#{open}#{text}</#{tag}>"
|
|
26
|
+
rescue Selenium::WebDriver::Error::WebDriverError
|
|
27
|
+
'<element>'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attribute_part(name, value)
|
|
31
|
+
value.empty? ? '' : %( #{name}="#{value}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A strict finder matched more than one element.
|
|
36
|
+
class AmbiguousMatch < Selenium::WebDriver::Error::WebDriverError
|
|
37
|
+
SHOWN = 3
|
|
38
|
+
|
|
39
|
+
attr_reader :elements
|
|
40
|
+
|
|
41
|
+
def initialize(locator_description, elements)
|
|
42
|
+
@elements = elements
|
|
43
|
+
listed = elements.first(SHOWN).map { |element| "`#{Describe.element(element)}`" }.join(', ')
|
|
44
|
+
more = [elements.size - SHOWN, 0].max
|
|
45
|
+
super("`#{locator_description}` matched #{elements.size} elements: #{listed} (+#{more} more); " \
|
|
46
|
+
'use find_elements, or narrow the locator')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# An element did not become actionable within the timeout.
|
|
51
|
+
class NotActionable < Selenium::WebDriver::Error::ElementNotInteractableError
|
|
52
|
+
attr_reader :check, :covering, :retries
|
|
53
|
+
|
|
54
|
+
def initialize(element_description, check, covering: nil, waited: Raider.timeout, retries: 0)
|
|
55
|
+
@check = check
|
|
56
|
+
@retries = retries
|
|
57
|
+
@covering = covering
|
|
58
|
+
message = "waited #{Describe.seconds(waited)}s for `#{element_description}` to #{check}"
|
|
59
|
+
message += "; `#{covering[:element]}` covers it at (#{covering[:x]}, #{covering[:y]})" if covering
|
|
60
|
+
super(message)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The requested operation is not supported.
|
|
65
|
+
class NotSupported < Selenium::WebDriver::Error::UnsupportedOperationError; end
|
|
66
|
+
|
|
67
|
+
# A runtime wait timed out.
|
|
68
|
+
class TimeoutError < Selenium::WebDriver::Error::TimeoutError; end
|
|
69
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
require_relative 'locator_converter'
|
|
6
|
+
|
|
7
|
+
module Raider
|
|
8
|
+
FINDERS = {
|
|
9
|
+
label: 'raider label',
|
|
10
|
+
placeholder: 'raider placeholder',
|
|
11
|
+
alt: 'raider alt',
|
|
12
|
+
title: 'raider title',
|
|
13
|
+
text: 'raider text',
|
|
14
|
+
test_id: 'raider test id',
|
|
15
|
+
role: 'raider role'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Registers the Raider locator keys and converter on Selenium and Appium.
|
|
19
|
+
module Finders
|
|
20
|
+
BRIDGE = 'Selenium::WebDriver::Remote::Bridge'
|
|
21
|
+
SEARCH_CONTEXT = 'Selenium::WebDriver::SearchContext'
|
|
22
|
+
SELENIUM_BRIDGES = %w[Selenium::WebDriver::Remote::Bridge Selenium::WebDriver::Remote::BiDiBridge].freeze
|
|
23
|
+
APPIUM_BRIDGES = %w[Appium::Core::Base::Bridge Appium::Core::Base::BiDiBridge].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def register
|
|
28
|
+
context = Selenium::WebDriver::SearchContext
|
|
29
|
+
context.extra_finders = (context.extra_finders || {}).merge(FINDERS)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Covers a late `require 'appium_lib'`, which overwrites the finder table
|
|
33
|
+
# and assigns its own bridge converter. Bridges created before the wrap keep
|
|
34
|
+
# the converter they copied; require the runtime before creating a driver.
|
|
35
|
+
def ensure_registered
|
|
36
|
+
register unless Selenium::WebDriver::SearchContext.finders.key?(:label)
|
|
37
|
+
install_appium_converters
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def install_converter(bridge_class)
|
|
41
|
+
return if bridge_class.locator_converter.is_a?(LocatorConverter)
|
|
42
|
+
|
|
43
|
+
bridge_class.locator_converter = LocatorConverter.new(bridge_class.locator_converter)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def install
|
|
47
|
+
register
|
|
48
|
+
SELENIUM_BRIDGES.each { |name| install_converter(Object.const_get(name)) }
|
|
49
|
+
install_appium_converters
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Each bridge class keeps its own converter, so the BiDi subclasses need
|
|
53
|
+
# their own wrap. Appium classes are only touched when Appium is loaded.
|
|
54
|
+
def install_appium_converters
|
|
55
|
+
APPIUM_BRIDGES.each do |name|
|
|
56
|
+
install_converter(Object.const_get(name)) if Object.const_defined?(name)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_api
|
|
61
|
+
bridge = Selenium::WebDriver::Remote::Bridge
|
|
62
|
+
context = Selenium::WebDriver::SearchContext
|
|
63
|
+
raise Error, "#{BRIDGE} must respond to locator_converter=" unless bridge.respond_to?(:locator_converter=)
|
|
64
|
+
raise Error, "#{SEARCH_CONTEXT} must respond to extra_finders=" unless context.respond_to?(:extra_finders=)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def guard
|
|
68
|
+
return unless ObjectSpace.each_object(Selenium::WebDriver::Remote::Bridge).any?
|
|
69
|
+
|
|
70
|
+
raise Error, "require 'ruby_raider/runtime' before creating the driver"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Actionability checks for Raider::Actionability. Runs as an execute_script body:
|
|
2
|
+
// arguments[0] is the element, arguments[1] the Array of check names to run.
|
|
3
|
+
// Returns null when every check passes, else { check, covering? } for the first failure.
|
|
4
|
+
// Read-only apart from scrolling: it never sets styles, focuses, or dispatches events.
|
|
5
|
+
const el = arguments[0];
|
|
6
|
+
const names = arguments[1];
|
|
7
|
+
const isOption = el.tagName === 'OPTION';
|
|
8
|
+
const typing = names.includes('editable');
|
|
9
|
+
|
|
10
|
+
const describe = (node) => {
|
|
11
|
+
let text = node.tagName.toLowerCase();
|
|
12
|
+
if (node.id) text += '#' + node.id;
|
|
13
|
+
const classes = Array.from(node.classList || []).slice(0, 2);
|
|
14
|
+
classes.forEach((name) => { text += '.' + name; });
|
|
15
|
+
return text;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// One step up the composed tree: the parent, or the host when leaving a shadow root.
|
|
19
|
+
const up = (node) => node.parentNode || node.host || null;
|
|
20
|
+
|
|
21
|
+
// First element on the composed path from node to the root (node included) that passes test.
|
|
22
|
+
const composedClosest = (node, test) => {
|
|
23
|
+
for (let current = node; current; current = up(current)) {
|
|
24
|
+
if (current.nodeType === 1 && test(current)) return current;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const composedContains = (ancestor, node) => composedClosest(node, (current) => current === ancestor) !== null;
|
|
30
|
+
|
|
31
|
+
// Not display: none on the node or any ancestor (a file input may have an empty rect).
|
|
32
|
+
const displayed = (node) => {
|
|
33
|
+
if (node.checkVisibility) return node.checkVisibility();
|
|
34
|
+
return !composedClosest(node, (current) => window.getComputedStyle(current).display === 'none');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const visible = () => {
|
|
38
|
+
const target = isOption ? (el.closest('select') || el) : el;
|
|
39
|
+
const style = window.getComputedStyle(target);
|
|
40
|
+
if (target.tagName === 'INPUT' && target.type === 'file') {
|
|
41
|
+
// File inputs are routinely hidden behind a styled label, so typing a path skips visible.
|
|
42
|
+
return typing || displayed(target);
|
|
43
|
+
}
|
|
44
|
+
const rect = target.getBoundingClientRect();
|
|
45
|
+
if (!(rect.width > 0 && rect.height > 0)) return false;
|
|
46
|
+
if (target.checkVisibility) return target.checkVisibility({ visibilityProperty: true });
|
|
47
|
+
return style.visibility !== 'hidden';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const enabled = () => {
|
|
51
|
+
if (el.disabled) return false;
|
|
52
|
+
const fieldset = composedClosest(el, (node) => node.tagName === 'FIELDSET' && node.hasAttribute('disabled'));
|
|
53
|
+
if (!fieldset) return true;
|
|
54
|
+
const legend = fieldset.querySelector(':scope > legend');
|
|
55
|
+
return !!legend && composedContains(legend, el);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Keys can go to any element (body for Escape, a button for Enter); only a readonly field refuses them.
|
|
59
|
+
const editable = () => !((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.readOnly);
|
|
60
|
+
|
|
61
|
+
const modalDialog = () => {
|
|
62
|
+
try {
|
|
63
|
+
const modal = document.querySelector('dialog:modal');
|
|
64
|
+
if (modal) return modal;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// :modal is not supported; fall through to the open-dialog scan.
|
|
67
|
+
}
|
|
68
|
+
return Array.from(document.querySelectorAll('dialog[open]')).find((dialog) => {
|
|
69
|
+
try { return dialog.matches(':modal'); } catch (error) { return false; }
|
|
70
|
+
}) || null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const notInert = () => {
|
|
74
|
+
if (composedClosest(el, (node) => node.hasAttribute('inert'))) return false;
|
|
75
|
+
const dialog = modalDialog();
|
|
76
|
+
return !dialog || composedContains(dialog, el);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const stable = () => {
|
|
80
|
+
if (isOption) return true;
|
|
81
|
+
window.__raiderRects = window.__raiderRects || new WeakMap();
|
|
82
|
+
const box = el.getBoundingClientRect();
|
|
83
|
+
const now = [box.x + window.scrollX, box.y + window.scrollY, box.width, box.height].map(Math.round).join(',');
|
|
84
|
+
const before = window.__raiderRects.get(el);
|
|
85
|
+
window.__raiderRects.set(el, now);
|
|
86
|
+
return before === undefined ? 'first' : before === now;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const clamp = (value, max) => Math.max(0, Math.min(Math.round(value), Math.max(max - 1, 0)));
|
|
90
|
+
|
|
91
|
+
// Returns null when the centre point reaches the element, else a failure with covering.
|
|
92
|
+
const events = () => {
|
|
93
|
+
if (isOption) return null;
|
|
94
|
+
const box = el.getBoundingClientRect();
|
|
95
|
+
const x = clamp(box.x + box.width / 2, window.innerWidth);
|
|
96
|
+
const y = clamp(box.y + box.height / 2, window.innerHeight);
|
|
97
|
+
const root = el.getRootNode();
|
|
98
|
+
const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
|
|
99
|
+
if (hit && el.contains(hit)) return null;
|
|
100
|
+
return hit ? { check: 'events', covering: { element: describe(hit), x: x, y: y } } : { check: 'events' };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const within = (box, outer) => (
|
|
104
|
+
box.top >= outer.top && box.left >= outer.left && box.bottom <= outer.bottom && box.right <= outer.right
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Inside the viewport (without scrollbars) and inside every clipping ancestor (overflow not visible).
|
|
108
|
+
const unclipped = () => {
|
|
109
|
+
const box = el.getBoundingClientRect();
|
|
110
|
+
const root = document.documentElement;
|
|
111
|
+
if (!within(box, { top: 0, left: 0, bottom: root.clientHeight, right: root.clientWidth })) return false;
|
|
112
|
+
return !composedClosest(up(el) || el, (node) => {
|
|
113
|
+
if (node === root || node === document.body) return false;
|
|
114
|
+
const style = window.getComputedStyle(node);
|
|
115
|
+
if (style.overflowX === 'visible' && style.overflowY === 'visible') return false;
|
|
116
|
+
return !within(box, node.getBoundingClientRect());
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (!isOption && !unclipped()) el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
121
|
+
|
|
122
|
+
const simple = { visible: visible, enabled: enabled, editable: editable, inert: notInert, stable: stable };
|
|
123
|
+
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
if (name === 'events') {
|
|
126
|
+
const failure = events();
|
|
127
|
+
if (failure) return failure;
|
|
128
|
+
} else {
|
|
129
|
+
const result = simple[name]();
|
|
130
|
+
if (result === 'first') return { check: name, first: true };
|
|
131
|
+
if (!result) return { check: name };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|