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,315 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
if (window.__raider && window.__raider.role) { return; }
|
|
4
|
+
|
|
5
|
+
// Keep in step with Raider::ROLES in lib/ruby_raider/runtime/by_role.rb.
|
|
6
|
+
var ROLES = ('alert alertdialog application article banner blockquote button caption cell checkbox code ' +
|
|
7
|
+
'columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed ' +
|
|
8
|
+
'figure form generic grid gridcell group heading img insertion link list listbox listitem log main marquee math ' +
|
|
9
|
+
'menu 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').split(' ');
|
|
13
|
+
|
|
14
|
+
var NAME_FROM_CONTENT = ('button link heading cell option tab menuitem treeitem row gridcell columnheader ' +
|
|
15
|
+
'rowheader switch checkbox radio tooltip menuitemcheckbox menuitemradio').split(' ');
|
|
16
|
+
|
|
17
|
+
// Implicit roles by tag; tags that need a decision are handled in implicitRole.
|
|
18
|
+
var TAG_ROLES = {
|
|
19
|
+
article: 'article', aside: 'complementary', button: 'button', datalist: 'listbox', details: 'group',
|
|
20
|
+
dialog: 'dialog', fieldset: 'group', figure: 'figure', form: 'form', h1: 'heading', h2: 'heading',
|
|
21
|
+
h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', hr: 'separator', main: 'main', math: 'math',
|
|
22
|
+
menu: 'list', ol: 'list', ul: 'list', meter: 'meter', nav: 'navigation', option: 'option', output: 'status',
|
|
23
|
+
progress: 'progressbar', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup',
|
|
24
|
+
td: 'cell', textarea: 'textbox', tr: 'row', div: 'generic', span: 'generic', p: 'paragraph',
|
|
25
|
+
blockquote: 'blockquote', code: 'code', em: 'emphasis', strong: 'strong', del: 'deletion', ins: 'insertion',
|
|
26
|
+
sub: 'subscript', sup: 'superscript', time: 'time', dfn: 'term', dt: 'term', dd: 'definition',
|
|
27
|
+
caption: 'caption', html: 'document', search: 'search', address: 'group', optgroup: 'group'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
var PLACEHOLDER_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];
|
|
31
|
+
|
|
32
|
+
var INPUT_ROLES = {
|
|
33
|
+
button: 'button', image: 'button', reset: 'button', submit: 'button', checkbox: 'checkbox', radio: 'radio',
|
|
34
|
+
range: 'slider', number: 'spinbutton', search: 'searchbox', email: 'textbox', tel: 'textbox',
|
|
35
|
+
text: 'textbox', url: 'textbox', password: 'textbox'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
var LIST_INPUTS = ['search', 'email', 'tel', 'text', 'url'];
|
|
39
|
+
|
|
40
|
+
// A label or aria-labelledby id resolves in the element's own tree, so shadow roots work.
|
|
41
|
+
function byId(el, id) {
|
|
42
|
+
var root = el.getRootNode();
|
|
43
|
+
return root && root.getElementById ? root.getElementById(id) : el.ownerDocument.getElementById(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function has(object, key) { return Object.prototype.hasOwnProperty.call(object, key); }
|
|
47
|
+
|
|
48
|
+
function normalize(text) { return String(text).replace(/\s+/g, ' ').trim(); }
|
|
49
|
+
|
|
50
|
+
function explicitRole(el) {
|
|
51
|
+
var tokens = (el.getAttribute('role') || '').toLowerCase().split(/\s+/);
|
|
52
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
53
|
+
if (ROLES.indexOf(tokens[i]) >= 0) { return tokens[i]; }
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function insideGrid(el) {
|
|
59
|
+
for (var n = el.parentElement; n; n = n.parentElement) {
|
|
60
|
+
var role = explicitRole(n);
|
|
61
|
+
if (role === 'grid' || role === 'treegrid') { return true; }
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function inputRole(el) {
|
|
67
|
+
var type = el.type;
|
|
68
|
+
if (type === 'hidden') { return null; }
|
|
69
|
+
if (el.hasAttribute('list') && LIST_INPUTS.indexOf(type) >= 0) { return 'combobox'; }
|
|
70
|
+
return has(INPUT_ROLES, type) ? INPUT_ROLES[type] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function headerRole(el, landmark) {
|
|
74
|
+
var parent = el.parentElement;
|
|
75
|
+
return parent && parent.closest('article, aside, main, nav, section') ? 'generic' : landmark;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasDataCell(row) {
|
|
79
|
+
for (var i = 0; i < row.cells.length; i++) {
|
|
80
|
+
if (row.cells[i].localName === 'td') { return true; }
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function thRole(el) {
|
|
86
|
+
var scope = (el.getAttribute('scope') || '').toLowerCase();
|
|
87
|
+
if (scope === 'row') { return 'rowheader'; }
|
|
88
|
+
if (scope === 'col') { return 'columnheader'; }
|
|
89
|
+
var row = el.parentElement;
|
|
90
|
+
var first = row && row.localName === 'tr' && el.cellIndex === 0;
|
|
91
|
+
return first && hasDataCell(row) && !el.closest('thead') ? 'rowheader' : 'columnheader';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function implicitRole(el) {
|
|
95
|
+
var tag = el.localName;
|
|
96
|
+
switch (tag) {
|
|
97
|
+
case 'a': return el.hasAttribute('href') ? 'link' : 'generic';
|
|
98
|
+
case 'area': return el.hasAttribute('href') ? 'link' : 'generic';
|
|
99
|
+
case 'header': return headerRole(el, 'banner');
|
|
100
|
+
case 'footer': return headerRole(el, 'contentinfo');
|
|
101
|
+
case 'img': return el.getAttribute('alt') === '' ? 'presentation' : 'img';
|
|
102
|
+
case 'input': return inputRole(el);
|
|
103
|
+
case 'li': return el.parentElement && /^(ul|ol|menu)$/.test(el.parentElement.localName) ? 'listitem' : 'generic';
|
|
104
|
+
case 'section': return isLabelled(el) ? 'region' : 'generic';
|
|
105
|
+
case 'select': return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
|
|
106
|
+
case 'td': return insideGrid(el) ? 'gridcell' : 'cell';
|
|
107
|
+
case 'th': return thRole(el);
|
|
108
|
+
case 'summary': return null;
|
|
109
|
+
default: return has(TAG_ROLES, tag) ? TAG_ROLES[tag] : null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Cheap and non-recursive: never computes a name, so getRole cannot re-enter nameOf.
|
|
114
|
+
function isLabelled(el) {
|
|
115
|
+
var label = el.getAttribute('aria-label');
|
|
116
|
+
if (label && normalize(label)) { return true; }
|
|
117
|
+
var ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/);
|
|
118
|
+
for (var i = 0; i < ids.length; i++) {
|
|
119
|
+
var ref = ids[i] ? byId(el, ids[i]) : null;
|
|
120
|
+
if (ref && (normalize(ref.textContent) || normalize(ref.getAttribute('aria-label') || ''))) { return true; }
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getRole(el) { return explicitRole(el) || implicitRole(el); }
|
|
126
|
+
|
|
127
|
+
function hiddenSelf(el) {
|
|
128
|
+
if (el.getAttribute('aria-hidden') === 'true') { return true; }
|
|
129
|
+
if (el.localName === 'input' && el.type === 'hidden') { return true; }
|
|
130
|
+
var style = window.getComputedStyle(el);
|
|
131
|
+
return (style.display === 'none' && el.localName !== 'area') || style.visibility === 'hidden' || style.visibility === 'collapse';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The element whose layout box decides visibility: options of a closed select and display: contents
|
|
135
|
+
// elements have no box of their own.
|
|
136
|
+
function boxOwner(el) {
|
|
137
|
+
var n = el.localName === 'option' ? el.closest('select') || el : el;
|
|
138
|
+
while (n.parentElement && window.getComputedStyle(n).display === 'contents') { n = n.parentElement; }
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isHidden(el) {
|
|
143
|
+
if (el.closest('[aria-hidden="true"]')) { return true; }
|
|
144
|
+
var owner = boxOwner(el);
|
|
145
|
+
if (el.localName !== 'area' && owner.checkVisibility && !owner.checkVisibility()) { return true; }
|
|
146
|
+
for (var n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
147
|
+
if (n.localName !== 'area' && window.getComputedStyle(n).display === 'none') { return true; }
|
|
148
|
+
}
|
|
149
|
+
return hiddenSelf(el);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function textOfIds(el, attribute) {
|
|
153
|
+
var ids = (el.getAttribute(attribute) || '').split(/\s+/);
|
|
154
|
+
var parts = [];
|
|
155
|
+
for (var i = 0; i < ids.length; i++) {
|
|
156
|
+
var ref = ids[i] ? byId(el, ids[i]) : null;
|
|
157
|
+
if (ref) { parts.push(nameOf(ref, { inRef: true, content: true, ref: true })); }
|
|
158
|
+
}
|
|
159
|
+
return parts.join(' ');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ariaName(el) {
|
|
163
|
+
if (el.hasAttribute('aria-labelledby')) {
|
|
164
|
+
var referenced = textOfIds(el, 'aria-labelledby');
|
|
165
|
+
if (normalize(referenced)) { return referenced; }
|
|
166
|
+
}
|
|
167
|
+
var label = el.getAttribute('aria-label');
|
|
168
|
+
return label && normalize(label) ? label : '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function selectedText(select) {
|
|
172
|
+
var out = [];
|
|
173
|
+
for (var i = 0; i < select.selectedOptions.length; i++) { out.push(select.selectedOptions[i].textContent); }
|
|
174
|
+
return out.join(' ');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function embeddedValue(el, role) {
|
|
178
|
+
if (role === 'textbox') { return 'value' in el ? el.value : el.textContent; }
|
|
179
|
+
if (role === 'combobox' || role === 'listbox') {
|
|
180
|
+
if (el.localName === 'select') { return selectedText(el); }
|
|
181
|
+
return 'value' in el ? el.value : '';
|
|
182
|
+
}
|
|
183
|
+
if (role === 'slider' || role === 'spinbutton') {
|
|
184
|
+
return el.getAttribute('aria-valuetext') || el.getAttribute('aria-valuenow') || el.value || '';
|
|
185
|
+
}
|
|
186
|
+
return '';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function inlineDisplay(el) {
|
|
190
|
+
var display = window.getComputedStyle(el).display;
|
|
191
|
+
return display === 'contents' || display.indexOf('inline') === 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function childrenText(el, o) {
|
|
195
|
+
var out = '';
|
|
196
|
+
for (var n = el.firstChild; n; n = n.nextSibling) {
|
|
197
|
+
if (n.nodeType === 3) {
|
|
198
|
+
out += n.data;
|
|
199
|
+
} else if (n.nodeType === 1) {
|
|
200
|
+
var text = nameOf(n, { inRef: o.inRef, content: true, skip: o.skip, ref: false });
|
|
201
|
+
out += inlineDisplay(n) ? text : ' ' + text + ' ';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function labelsText(el, o) {
|
|
208
|
+
var out = [];
|
|
209
|
+
var labels = el.labels || [];
|
|
210
|
+
for (var i = 0; i < labels.length; i++) {
|
|
211
|
+
out.push(childrenText(labels[i], { inRef: o.inRef, skip: el }));
|
|
212
|
+
}
|
|
213
|
+
return out.join(' ');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function childElement(el, tag) {
|
|
217
|
+
for (var n = el.firstElementChild; n; n = n.nextElementSibling) {
|
|
218
|
+
if (n.localName === tag) { return n; }
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function nativeName(el, o) {
|
|
224
|
+
var tag = el.localName;
|
|
225
|
+
var type = tag === 'input' ? el.type : '';
|
|
226
|
+
var text = '';
|
|
227
|
+
if (type === 'button' || type === 'submit' || type === 'reset') { return el.value || ''; }
|
|
228
|
+
if (tag === 'img' || tag === 'area' || type === 'image') {
|
|
229
|
+
text = el.getAttribute('alt') || '';
|
|
230
|
+
} else if (tag === 'table') {
|
|
231
|
+
text = el.caption ? childrenText(el.caption, { inRef: o.inRef }) : '';
|
|
232
|
+
} else if (tag === 'fieldset' || tag === 'figure') {
|
|
233
|
+
var holder = childElement(el, tag === 'fieldset' ? 'legend' : 'figcaption');
|
|
234
|
+
text = holder ? childrenText(holder, { inRef: o.inRef }) : '';
|
|
235
|
+
} else if (el.labels) {
|
|
236
|
+
text = labelsText(el, o);
|
|
237
|
+
}
|
|
238
|
+
return text;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// o.top: the element asked about; o.ref: named by aria-labelledby (hidden state ignored);
|
|
242
|
+
// o.inRef: inside a labelledby walk (no second labelledby); o.content: walking a name-from-content subtree.
|
|
243
|
+
var computing = [];
|
|
244
|
+
|
|
245
|
+
function nameOf(el, o) {
|
|
246
|
+
if (el === o.skip || computing.indexOf(el) >= 0) { return ''; }
|
|
247
|
+
computing.push(el);
|
|
248
|
+
try {
|
|
249
|
+
return computeName(el, o);
|
|
250
|
+
} finally {
|
|
251
|
+
computing.pop();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function computeName(el, o) {
|
|
256
|
+
if (!o.ref && (o.top ? isHidden(el) : hiddenSelf(el))) { return ''; }
|
|
257
|
+
var role = getRole(el);
|
|
258
|
+
var text = o.inRef ? '' : ariaName(el);
|
|
259
|
+
if (!o.inRef && normalize(text)) { return text; }
|
|
260
|
+
var label = o.inRef ? el.getAttribute('aria-label') : '';
|
|
261
|
+
if (label && normalize(label)) { return label; }
|
|
262
|
+
if (!o.top && normalize(embeddedValue(el, role))) { return embeddedValue(el, role); }
|
|
263
|
+
text = nativeName(el, o);
|
|
264
|
+
if (normalize(text)) { return text; }
|
|
265
|
+
if (o.content || o.top && NAME_FROM_CONTENT.indexOf(role) >= 0) {
|
|
266
|
+
text = childrenText(el, o);
|
|
267
|
+
if (normalize(text)) { return text; }
|
|
268
|
+
}
|
|
269
|
+
var title = el.getAttribute('title');
|
|
270
|
+
return title && normalize(title) ? title : placeholderOf(el);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function placeholderOf(el) {
|
|
274
|
+
var typed = el.localName === 'input' && PLACEHOLDER_TYPES.indexOf(el.type) >= 0;
|
|
275
|
+
return typed || el.localName === 'textarea' ? el.getAttribute('placeholder') || '' : '';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function nameWithin(el, includeHidden) {
|
|
279
|
+
return normalize(nameOf(el, { top: true, ref: includeHidden }));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function accessibleName(el) { return nameWithin(el, false); }
|
|
283
|
+
|
|
284
|
+
function headingLevel(el) {
|
|
285
|
+
var explicit = parseInt(el.getAttribute('aria-level'), 10);
|
|
286
|
+
if (explicit >= 1) { return explicit; }
|
|
287
|
+
var tagged = /^h([1-6])$/.exec(el.localName);
|
|
288
|
+
return tagged ? Number(tagged[1]) : 2;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function role(scope, wanted, options) {
|
|
292
|
+
var root = scope || document;
|
|
293
|
+
var opts = options || {};
|
|
294
|
+
var named = opts.name !== null && opts.name !== undefined;
|
|
295
|
+
var needle = named ? normalize(opts.name) : '';
|
|
296
|
+
var all = root.querySelectorAll('*');
|
|
297
|
+
var found = [];
|
|
298
|
+
for (var i = 0; i < all.length; i++) {
|
|
299
|
+
var el = all[i];
|
|
300
|
+
if (getRole(el) !== wanted) { continue; }
|
|
301
|
+
if (opts.level !== null && opts.level !== undefined && headingLevel(el) !== opts.level) { continue; }
|
|
302
|
+
if (!opts.includeHidden && isHidden(el)) { continue; }
|
|
303
|
+
if (named) {
|
|
304
|
+
var name = nameWithin(el, opts.includeHidden === true);
|
|
305
|
+
var ok = opts.exact ? name === needle : name.toLowerCase().indexOf(needle.toLowerCase()) >= 0;
|
|
306
|
+
if (!ok) { continue; }
|
|
307
|
+
}
|
|
308
|
+
found.push(el);
|
|
309
|
+
}
|
|
310
|
+
return found;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
window.__raider = window.__raider || {};
|
|
314
|
+
Object.assign(window.__raider, { role: role, getRole: getRole, accessibleName: accessibleName, isHidden: isHidden });
|
|
315
|
+
})();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
require_relative 'selectors'
|
|
6
|
+
|
|
7
|
+
module Raider
|
|
8
|
+
# Wraps a bridge's locator converter: translates the Raider wire names into
|
|
9
|
+
# XPath or CSS through Raider::Selectors and hands every other locator,
|
|
10
|
+
# Appium's included, to the converter it wraps untouched.
|
|
11
|
+
class LocatorConverter
|
|
12
|
+
XPATH = {
|
|
13
|
+
'raider label' => :label,
|
|
14
|
+
'raider text' => :text
|
|
15
|
+
}.freeze
|
|
16
|
+
ATTRIBUTES = {
|
|
17
|
+
'raider placeholder' => 'placeholder',
|
|
18
|
+
'raider alt' => 'alt',
|
|
19
|
+
'raider title' => 'title'
|
|
20
|
+
}.freeze
|
|
21
|
+
TEST_ID = 'raider test id'
|
|
22
|
+
ROLE = 'raider role'
|
|
23
|
+
|
|
24
|
+
attr_reader :inner
|
|
25
|
+
|
|
26
|
+
def initialize(inner)
|
|
27
|
+
@inner = inner
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def convert(how, what)
|
|
31
|
+
key = how.to_s
|
|
32
|
+
return inner.convert(how, what) unless owns?(key)
|
|
33
|
+
raise NotSupported, 'internal error: the role finder did not intercept this role locator' if key == ROLE
|
|
34
|
+
|
|
35
|
+
value, exact = split(key, what)
|
|
36
|
+
raise ArgumentError, "#{label_for(key)}: needs a non-empty value" if value.to_s.strip.empty?
|
|
37
|
+
|
|
38
|
+
translate(key, value, exact)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def owns?(key)
|
|
44
|
+
XPATH.key?(key) || ATTRIBUTES.key?(key) || [TEST_ID, ROLE].include?(key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def translate(key, value, exact)
|
|
48
|
+
return ['xpath', Selectors.public_send(XPATH.fetch(key), value, exact:)] if XPATH.key?(key)
|
|
49
|
+
return ['css selector', Selectors.attribute_css(ATTRIBUTES.fetch(key), value, exact:)] if ATTRIBUTES.key?(key)
|
|
50
|
+
|
|
51
|
+
['css selector', Selectors.test_id(value)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def split(key, what)
|
|
55
|
+
return [what, false] unless what.is_a?(Array)
|
|
56
|
+
|
|
57
|
+
value, options = what
|
|
58
|
+
raise ArgumentError, "#{label_for(key)}: expected a value or [value, { exact: }]" unless valid_pair?(what)
|
|
59
|
+
|
|
60
|
+
[value, options&.fetch(:exact, false) ? true : false]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def valid_pair?(pair)
|
|
64
|
+
value, options = pair
|
|
65
|
+
(value.is_a?(String) || value.is_a?(Symbol)) && (options.nil? || options.is_a?(Hash)) && pair.size <= 2
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def label_for(key)
|
|
69
|
+
key.delete_prefix('raider ').tr(' ', '_')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rspec/expectations'
|
|
4
|
+
require 'selenium-webdriver'
|
|
5
|
+
require_relative '../config'
|
|
6
|
+
require_relative '../errors'
|
|
7
|
+
require_relative '../wait'
|
|
8
|
+
|
|
9
|
+
module Raider
|
|
10
|
+
module Matchers
|
|
11
|
+
# Shared polling for the web-first matchers. Subclasses implement `read(subject)`,
|
|
12
|
+
# `pass?(value)` and `verb`, and may override `absent_passes?`.
|
|
13
|
+
class Base
|
|
14
|
+
include RSpec::Matchers::Composable
|
|
15
|
+
|
|
16
|
+
# `and` / `or` that build the sequential compounds, so every link of a chain
|
|
17
|
+
# stays sequential.
|
|
18
|
+
module Chaining
|
|
19
|
+
def and(other)
|
|
20
|
+
Base::And.new(self, other)
|
|
21
|
+
end
|
|
22
|
+
alias & and
|
|
23
|
+
|
|
24
|
+
def or(other)
|
|
25
|
+
Base::Or.new(self, other)
|
|
26
|
+
end
|
|
27
|
+
alias | or
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
include Chaining
|
|
31
|
+
|
|
32
|
+
# Compound evaluation that hands each matcher the actual as is, so a block
|
|
33
|
+
# actual is resolved by each matcher itself rather than nested.
|
|
34
|
+
module Sequential
|
|
35
|
+
include Chaining
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def evaluate(actual)
|
|
40
|
+
@evaluator = RSpec::Matchers::BuiltIn::Compound::SequentialEvaluator.new(actual, matcher_1, matcher_2)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `a.and(b)` evaluated sequentially.
|
|
45
|
+
class And < RSpec::Matchers::BuiltIn::Compound::And
|
|
46
|
+
include Sequential
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def match(_expected, actual)
|
|
51
|
+
evaluate(actual)
|
|
52
|
+
matcher_1_matches? && matcher_2_matches?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# `a.or(b)` evaluated sequentially.
|
|
57
|
+
class Or < RSpec::Matchers::BuiltIn::Compound::Or
|
|
58
|
+
include Sequential
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def match(_expected, actual)
|
|
63
|
+
evaluate(actual)
|
|
64
|
+
matcher_1_matches? || matcher_2_matches?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
NOT_FOUND = Object.new.freeze
|
|
69
|
+
STALE_HINT = 'the element is stale; use the block form (expect { page.element }) so it is found again'
|
|
70
|
+
|
|
71
|
+
# Raised inside the poll to end it at once: a raw element went stale.
|
|
72
|
+
class StaleActual < StandardError; end
|
|
73
|
+
|
|
74
|
+
# What the last poll saw, for the failure message.
|
|
75
|
+
Poll = Struct.new(:actual, :subject, :value, :waited, :elapsed, :stale)
|
|
76
|
+
|
|
77
|
+
attr_reader :expected
|
|
78
|
+
|
|
79
|
+
def initialize(expected, wait: nil, **options)
|
|
80
|
+
@expected = expected
|
|
81
|
+
@wait = wait
|
|
82
|
+
@options = options
|
|
83
|
+
@poll = Poll.new(nil, nil, NOT_FOUND, nil, 0, false)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def matches?(actual)
|
|
87
|
+
poll(actual, negated: false)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def does_not_match?(actual)
|
|
91
|
+
poll(actual, negated: true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def failure_message
|
|
95
|
+
message('to')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def failure_message_when_negated
|
|
99
|
+
message('not to')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def supports_block_expectations?
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def description
|
|
107
|
+
"#{verb} #{expected.inspect}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def absent_passes?
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolve(actual)
|
|
115
|
+
actual.is_a?(Proc) ? actual.call : actual
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_with_zero_wait(subject, method, *args)
|
|
119
|
+
return subject.public_send(method, *args) unless accepts_timeout?(subject, method)
|
|
120
|
+
|
|
121
|
+
subject.public_send(method, *args, timeout: 0)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def option(key)
|
|
127
|
+
@options[key]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def polled_actual
|
|
131
|
+
@poll.actual
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def wait_for(_actual)
|
|
135
|
+
@wait || Raider.timeout
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def poll(actual, negated:)
|
|
139
|
+
@poll = Poll.new(actual, nil, NOT_FOUND, wait_for(actual), 0, false)
|
|
140
|
+
started = clock
|
|
141
|
+
begin
|
|
142
|
+
Raider::Wait.until(timeout: @poll.waited) { settled?(negated) }
|
|
143
|
+
true
|
|
144
|
+
rescue Raider::TimeoutError, StaleActual
|
|
145
|
+
false
|
|
146
|
+
ensure
|
|
147
|
+
@poll.elapsed = clock - started
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def clock
|
|
152
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def settled?(negated)
|
|
156
|
+
Raider::Wait.immediately do
|
|
157
|
+
@poll.subject = resolve(@poll.actual)
|
|
158
|
+
@poll.value = read(@poll.subject)
|
|
159
|
+
end
|
|
160
|
+
pass?(@poll.value) != negated || throw(:not_ready)
|
|
161
|
+
rescue *retried_errors => e
|
|
162
|
+
@poll.value = NOT_FOUND
|
|
163
|
+
gone(e, negated)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The read found nothing: pass where absence counts, fail at once for a stale raw
|
|
167
|
+
# element, and otherwise try again.
|
|
168
|
+
def gone(error, negated)
|
|
169
|
+
return true if negated ? negated_absent_passes? : absent_passes?
|
|
170
|
+
|
|
171
|
+
raise_stale if error.is_a?(Selenium::WebDriver::Error::StaleElementReferenceError) && raw_element?
|
|
172
|
+
throw :not_ready
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def negated_absent_passes?
|
|
176
|
+
false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def raw_element?
|
|
180
|
+
@poll.actual.is_a?(Selenium::WebDriver::Element)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def raise_stale
|
|
184
|
+
@poll.stale = true
|
|
185
|
+
raise StaleActual
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def retried_errors
|
|
189
|
+
[Selenium::WebDriver::Error::NoSuchElementError,
|
|
190
|
+
Selenium::WebDriver::Error::StaleElementReferenceError,
|
|
191
|
+
Selenium::WebDriver::Error::TimeoutError, Raider::AmbiguousMatch]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def accepts_timeout?(subject, method)
|
|
195
|
+
subject.method(method).parameters.any? { |_kind, name| name == :timeout }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def message(polarity)
|
|
199
|
+
head = "expected #{target} #{polarity} #{description}"
|
|
200
|
+
return "#{head}, but #{STALE_HINT}" if @poll.stale
|
|
201
|
+
|
|
202
|
+
"#{head}, #{last_seen} after #{Raider::Describe.seconds(@poll.elapsed)}s"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def target
|
|
206
|
+
return 'the block' if @poll.actual.is_a?(Proc)
|
|
207
|
+
|
|
208
|
+
shown = @poll.subject || @poll.actual
|
|
209
|
+
shown.respond_to?(:tag_name) ? Raider::Describe.element(shown) : shown.inspect
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def last_seen
|
|
213
|
+
value = @poll.value
|
|
214
|
+
value.equal?(NOT_FOUND) ? 'last seen: not found' : "last seen #{show(value)}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def show(value)
|
|
218
|
+
value.inspect
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|