page_ez 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +148 -0
- data/LICENSE.txt +21 -0
- data/README.md +575 -0
- data/Rakefile +10 -0
- data/lib/page_ez/configuration.rb +33 -0
- data/lib/page_ez/delegates_to.rb +25 -0
- data/lib/page_ez/errors.rb +17 -0
- data/lib/page_ez/has_many_result.rb +35 -0
- data/lib/page_ez/has_one_result.rb +14 -0
- data/lib/page_ez/method_generators/define_has_many_result_methods.rb +48 -0
- data/lib/page_ez/method_generators/define_has_one_predicate_methods.rb +42 -0
- data/lib/page_ez/method_generators/define_has_one_result_methods.rb +35 -0
- data/lib/page_ez/method_generators/has_many_dynamic_selector.rb +39 -0
- data/lib/page_ez/method_generators/has_many_ordered_dynamic_selector.rb +39 -0
- data/lib/page_ez/method_generators/has_many_ordered_selector.rb +47 -0
- data/lib/page_ez/method_generators/has_many_static_selector.rb +41 -0
- data/lib/page_ez/method_generators/has_one_composed_class.rb +40 -0
- data/lib/page_ez/method_generators/has_one_dynamic_selector.rb +39 -0
- data/lib/page_ez/method_generators/has_one_static_selector.rb +25 -0
- data/lib/page_ez/method_generators/identity_processor.rb +11 -0
- data/lib/page_ez/null_logger.rb +12 -0
- data/lib/page_ez/options.rb +37 -0
- data/lib/page_ez/page.rb +162 -0
- data/lib/page_ez/page_visitor.rb +72 -0
- data/lib/page_ez/parameters.rb +54 -0
- data/lib/page_ez/pluralization.rb +25 -0
- data/lib/page_ez/selector_evaluator.rb +76 -0
- data/lib/page_ez/version.rb +5 -0
- data/lib/page_ez/visitors/debug_visitor.rb +59 -0
- data/lib/page_ez/visitors/depth_visitor.rb +44 -0
- data/lib/page_ez/visitors/macro_pluralization_visitor.rb +70 -0
- data/lib/page_ez/visitors/matcher_collision_visitor.rb +75 -0
- data/lib/page_ez/visitors/registered_name_visitor.rb +100 -0
- data/lib/page_ez.rb +41 -0
- data/page_ez.gemspec +43 -0
- metadata +238 -0
data/README.md
ADDED
@@ -0,0 +1,575 @@
|
|
1
|
+
# PageEz
|
2
|
+
|
3
|
+
[](https://coveralls.io/github/joshuaclayton/page_ez?branch=main)
|
4
|
+
|
5
|
+
PageEz is a tool to define page objects with [Capybara].
|
6
|
+
|
7
|
+
[Capybara]: https://github.com/teamcapybara/capybara
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add the gem to your `Gemfile`:
|
12
|
+
|
13
|
+
```
|
14
|
+
gem "page_ez"
|
15
|
+
```
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
Define a page object:
|
20
|
+
|
21
|
+
```rb
|
22
|
+
class TodosIndex < PageEz::Page
|
23
|
+
has_one :active_list, "section.active ul" do
|
24
|
+
has_many :items do
|
25
|
+
has_one :name, "span[data-role=todo-name]"
|
26
|
+
has_one :checkbox, "input[type=checkbox]"
|
27
|
+
|
28
|
+
def mark_complete
|
29
|
+
checkbox.click
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def active_todo_names
|
35
|
+
items.map { _1.name.text }
|
36
|
+
end
|
37
|
+
|
38
|
+
has_one :completed_list, "section.complete ul" do
|
39
|
+
has_many :items do
|
40
|
+
has_one :name, "span[data-role=todo-name]"
|
41
|
+
has_one :checkbox, "input[type=checkbox]"
|
42
|
+
|
43
|
+
def mark_incomplete
|
44
|
+
checkbox.click
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
```
|
50
|
+
|
51
|
+
Use your page object:
|
52
|
+
|
53
|
+
```rb
|
54
|
+
it "manages todos state when completing" do
|
55
|
+
user = create(:user)
|
56
|
+
create(:todo, name: "Buy milk", user:)
|
57
|
+
create(:todo, name: "Buy eggs", user:)
|
58
|
+
|
59
|
+
sign_in_as user
|
60
|
+
|
61
|
+
todos_index = TodosIndex.new
|
62
|
+
|
63
|
+
expect(todos_index.active_todo_names).to eq(["Buy milk", "Buy eggs"])
|
64
|
+
todos_index.active_list.item_matching(text: "Buy milk").mark_complete
|
65
|
+
expect(todos_index.active_todo_names).to eq(["Buy eggs"])
|
66
|
+
todos_index.active_list.item_matching(text: "Buy eggs").mark_complete
|
67
|
+
|
68
|
+
expect(todos_index.active_todo_names).to be_empty
|
69
|
+
|
70
|
+
todos_index.completed_list.item_matching(text: "Buy milk").mark_incomplete
|
71
|
+
expect(todos_index.active_todo_names).to eq(["Buy milk"])
|
72
|
+
todos_index.completed_list.item_matching(text: "Buy eggs").mark_incomplete
|
73
|
+
expect(todos_index.active_todo_names).to eq(["Buy milk", "Buy eggs"])
|
74
|
+
end
|
75
|
+
```
|
76
|
+
|
77
|
+
### `has_one`
|
78
|
+
|
79
|
+
You can define accessors to individual elements (matched with Capybara's `find`):
|
80
|
+
|
81
|
+
```rb
|
82
|
+
class BlogPost < PageEz::Page
|
83
|
+
has_one :post_title, "header h2"
|
84
|
+
has_one :body, "section[data-role=post-body]"
|
85
|
+
has_one :published_date, "time[data-role=published-date]"
|
86
|
+
end
|
87
|
+
|
88
|
+
# generates the following methods:
|
89
|
+
|
90
|
+
blog_post = BlogPost.new
|
91
|
+
|
92
|
+
blog_post.post_title # => find("header h2")
|
93
|
+
blog_post.has_post_title? # => has_css?("header h2")
|
94
|
+
blog_post.has_no_post_title? # => has_no_css?("header h2")
|
95
|
+
|
96
|
+
blog_post.body # => find("section[data-role=post-body]")
|
97
|
+
blog_post.has_body? # => has_css?("section[data-role=post-body]")
|
98
|
+
blog_post.has_no_body? # => has_no_css?("section[data-role=post-body]")
|
99
|
+
|
100
|
+
blog_post.published_date # => find("time[data-role=published-date]")
|
101
|
+
blog_post.has_published_date? # => has_css?("time[data-role=published-date]")
|
102
|
+
blog_post.has_no_published_date? # => has_no_css?("time[data-role=published-date]")
|
103
|
+
|
104
|
+
blog_post.post_title(text: "Writing Ruby is Fun!") # => find("header h2", text: "Writing Ruby is Fun!")
|
105
|
+
blog_post.has_post_title?(text: "Writing Ruby is Fun!") # => has_css?("header h2", text: "Writing Ruby is Fun!")
|
106
|
+
blog_post.has_no_post_title?(text: "Writing Ruby is Boring") # => has_no_css?("header h2", text: "Writing Ruby is Boring")
|
107
|
+
```
|
108
|
+
|
109
|
+
The methods defined by PageEz can be passed additional options from Capybara. Refer to documentation for the following methods:
|
110
|
+
|
111
|
+
* [`Capybara::Node::Finders#find`]
|
112
|
+
* [`Capybara::Node::Matchers#has_css?`]
|
113
|
+
|
114
|
+
### `has_many`
|
115
|
+
|
116
|
+
You can define accessors to multiple elements (matched with Capybara's `all`):
|
117
|
+
|
118
|
+
```rb
|
119
|
+
class TodosIndex < PageEz::Page
|
120
|
+
has_many :todos, "ul li span[data-role=todo-name]"
|
121
|
+
end
|
122
|
+
|
123
|
+
# generates the following methods:
|
124
|
+
|
125
|
+
todos_index = TodosIndex.new
|
126
|
+
|
127
|
+
todos_index.todos # => all("ul li span[data-role=todo-name]")
|
128
|
+
todos_index.has_todos? # => has_css?("ul li span[data-role=todo-name]")
|
129
|
+
todos_index.has_no_todos? # => has_no_css?("ul li span[data-role=todo-name]")
|
130
|
+
|
131
|
+
todos_index.todo_matching(text: "Buy milk") # => find("ul li span[data-role=todo-name]", text: "Buy milk")
|
132
|
+
todos_index.has_todo_matching?(text: "Buy milk") # => has_css?("ul li span[data-role=todo-name]", text: "Buy milk")
|
133
|
+
todos_index.has_no_todo_matching?(text: "Buy milk") # => has_no_css?("ul li span[data-role=todo-name]", text: "Buy milk")
|
134
|
+
|
135
|
+
todos_index.todos.has_count_of?(number) # => has_css?("ul li span[data-role=todo-name]", count: number)
|
136
|
+
todos_index.has_todos_count?(number) # => has_css?("ul li span[data-role=todo-name]", count: number)
|
137
|
+
|
138
|
+
todos_index.todos.has_any_elements? # => has_css?("ul li span[data-role=todo-name]")
|
139
|
+
todos_index.todos.has_no_elements? # => has_no_css?("ul li span[data-role=todo-name]")
|
140
|
+
```
|
141
|
+
|
142
|
+
The methods defined by PageEz can be passed additional options from Capybara. Refer to documentation for the following methods:
|
143
|
+
|
144
|
+
* [`Capybara::Node::Finders#all`]
|
145
|
+
* [`Capybara::Node::Matchers#has_css?`]
|
146
|
+
|
147
|
+
### `has_many_ordered`
|
148
|
+
|
149
|
+
This mirrors the `has_many` macro but adds additional methods for accessing
|
150
|
+
elements at a specific index.
|
151
|
+
|
152
|
+
```rb
|
153
|
+
class TodosIndex < PageEz::Page
|
154
|
+
has_many_ordered :todos, "ul[data-role=todo-list] li"
|
155
|
+
end
|
156
|
+
|
157
|
+
# generates the base has_many methods (see above)
|
158
|
+
|
159
|
+
# in addition, it generates the ability to access at an index. The index passed
|
160
|
+
# to Ruby will be translated to the appropriate `:nth-of-child` (which is a
|
161
|
+
# 1-based index rather than 0-based)
|
162
|
+
|
163
|
+
todos_index.todo_at(0) # => find("ul[data-role=todo-list] li:nth-of-type(1)")
|
164
|
+
todos_index.has_todo_at?(0) # => has_css?("ul[data-role=todo-list] li:nth-of-type(1)")
|
165
|
+
todos_index.has_no_todo_at?(0) # => has_no_css?("ul[data-role=todo-list] li:nth-of-type(1)")
|
166
|
+
|
167
|
+
todos_index.todo_at(0, text: "Buy milk") # => find("ul[data-role=todo-list] li:nth-of-type(1)", text: "Buy milk")
|
168
|
+
```
|
169
|
+
|
170
|
+
The methods defined by PageEz can be passed additional options from Capybara. Refer to documentation for the following methods:
|
171
|
+
|
172
|
+
* [`Capybara::Node::Finders#find`]
|
173
|
+
* [`Capybara::Node::Matchers#has_css?`]
|
174
|
+
|
175
|
+
### `contains`
|
176
|
+
|
177
|
+
This provides a shorthand for delegating methods from one page object to
|
178
|
+
another, flattening the hierarchy of page objects and making it easier to
|
179
|
+
interact with application-level componentry.
|
180
|
+
|
181
|
+
```rb
|
182
|
+
class SidebarModal < PageEz::Page
|
183
|
+
base_selector "div[data-role=sidebar]"
|
184
|
+
|
185
|
+
has_one :sidebar_heading, "h2"
|
186
|
+
has_one :sidebar_contents, "section[data-role=contents]"
|
187
|
+
end
|
188
|
+
|
189
|
+
class PeopleIndex < PageEz::Page
|
190
|
+
contains SidebarModal
|
191
|
+
|
192
|
+
has_many :people_rows, "ul[data-role=people-list] li" do
|
193
|
+
has_one :name, "span[data-role=person-name]"
|
194
|
+
has_one :edit_link, "a", text: "Edit"
|
195
|
+
end
|
196
|
+
|
197
|
+
def change_person_name(from:, to:)
|
198
|
+
people_row_matching(text: from).edit_link.click
|
199
|
+
|
200
|
+
within sidebar_contents do
|
201
|
+
fill_in "Name", with: to
|
202
|
+
click_on "Save Person"
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
```
|
207
|
+
|
208
|
+
By default, this delegates all methods to an instance of the page object. If
|
209
|
+
you prefer to delegate a subset of the methods, you can do so with the `only`
|
210
|
+
option:
|
211
|
+
|
212
|
+
```rb
|
213
|
+
class SidebarModal < PageEz::Page
|
214
|
+
base_selector "div[data-role=sidebar]"
|
215
|
+
|
216
|
+
has_one :sidebar_heading, "h2"
|
217
|
+
has_one :sidebar_contents, "section[data-role=contents]"
|
218
|
+
end
|
219
|
+
|
220
|
+
class PeopleIndex < PageEz::Page
|
221
|
+
contains SidebarModal, only: %i[sidebar_contents]
|
222
|
+
end
|
223
|
+
```
|
224
|
+
|
225
|
+
The equivalent functionality could be achieved with:
|
226
|
+
|
227
|
+
```rb
|
228
|
+
class SidebarModal < PageEz::Page
|
229
|
+
base_selector "div[data-role=sidebar]"
|
230
|
+
|
231
|
+
has_one :sidebar_heading, "h2"
|
232
|
+
has_one :sidebar_contents, "section[data-role=contents]"
|
233
|
+
end
|
234
|
+
|
235
|
+
class PeopleIndex < PageEz::Page
|
236
|
+
has_one :sidebar_modal, SidebarModal
|
237
|
+
delegate :sidebar_contents, to: :sidebar_modal
|
238
|
+
end
|
239
|
+
```
|
240
|
+
|
241
|
+
### Using Methods as Dynamic Selectors
|
242
|
+
|
243
|
+
In the examples above, the CSS selectors are static.
|
244
|
+
|
245
|
+
However, there are a few different ways to define `has_one`, `has_many`, and
|
246
|
+
`has_many_ordered` elements as dynamic.
|
247
|
+
|
248
|
+
```rb
|
249
|
+
class TodosIndex < PageEz::Page
|
250
|
+
has_one :todo_by_id
|
251
|
+
|
252
|
+
def todo_by_id(id:)
|
253
|
+
"[data-model=todo][data-model-id=#{id}]"
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
# generates the same methods as has_one (see above) but with a required `id:` keyword argument
|
258
|
+
|
259
|
+
todos_index = TodosIndex.new
|
260
|
+
todos_index.todo_by_id(id: 5) # => find("[data-model=todo][data-model-id=5]")
|
261
|
+
todos_index.has_todo_by_id?(id: 5) # => has_css?("[data-model=todo][data-model-id=5]")
|
262
|
+
todos_index.has_no_todo_by_id?(id: 5) # => has_no_css?("[data-model=todo][data-model-id=5]")
|
263
|
+
```
|
264
|
+
|
265
|
+
The first mechanism declares the `has_one :todo_by_id` at the top of the file,
|
266
|
+
and the definition for the selector later on. This allows for grouping multiple
|
267
|
+
`has_one`s together for readability.
|
268
|
+
|
269
|
+
The second approach syntactically mirrors Ruby's `private_class_method`:
|
270
|
+
|
271
|
+
```rb
|
272
|
+
class TodosIndex < PageEz::Page
|
273
|
+
has_one def todo_by_id(id:)
|
274
|
+
"[data-model=todo][data-model-id=#{id}]"
|
275
|
+
end
|
276
|
+
|
277
|
+
# or
|
278
|
+
|
279
|
+
def todo_by_id(id:)
|
280
|
+
"[data-model=todo][data-model-id=#{id}]"
|
281
|
+
end
|
282
|
+
has_one :todo_by_id
|
283
|
+
end
|
284
|
+
```
|
285
|
+
|
286
|
+
In either case, the method needs to return a CSS string. PageEz will generate
|
287
|
+
the corresponding predicate methods as expected, as well (in the example above,
|
288
|
+
`#has_todo_by_id?(id:)` and `#has_no_todo_by_id?(id:)`
|
289
|
+
|
290
|
+
For the additional methods generated with the `has_many_ordered` macro (e.g.
|
291
|
+
for `has_many_ordered :items`, the methods `#item_at` and `#has_item_at?`), the
|
292
|
+
first argument is the index of the element, and all other args will be passed
|
293
|
+
through.
|
294
|
+
|
295
|
+
```rb
|
296
|
+
class TodosList < PageEz::Page
|
297
|
+
has_many_ordered :items do
|
298
|
+
has_one :name, "[data-role='title']"
|
299
|
+
has_one :checkbox, "input[type='checkbox']"
|
300
|
+
end
|
301
|
+
|
302
|
+
def items(state:)
|
303
|
+
"li[data-state='#{state}']"
|
304
|
+
end
|
305
|
+
end
|
306
|
+
```
|
307
|
+
|
308
|
+
This would enable usage as follows:
|
309
|
+
|
310
|
+
```rb
|
311
|
+
todos = TodosList.new
|
312
|
+
|
313
|
+
expect(todos.items(state: "complete")).to have_count_of(1)
|
314
|
+
expect(todos.items(state: "incomplete")).to have_count_of(2)
|
315
|
+
|
316
|
+
expect(todos).to have_item_at(0, state: "complete")
|
317
|
+
expect(todos).not_to have_item_at(1, state: "complete")
|
318
|
+
expect(todos).to have_item_at(0, state: "incomplete")
|
319
|
+
expect(todos).to have_item_at(1, state: "incomplete")
|
320
|
+
expect(todos).not_to have_item_at(2, state: "incomplete")
|
321
|
+
```
|
322
|
+
|
323
|
+
One key aspect of PageEz is that page hierarchy can be codified and scoped for interaction.
|
324
|
+
|
325
|
+
```rb
|
326
|
+
class TodosList
|
327
|
+
has_many_ordered :items, "li" do
|
328
|
+
has_one :name, "span[data-role=name]"
|
329
|
+
has_one :complete_button, "input[type=checkbox][data-action=toggle-complete]"
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
# generates the following method chains
|
334
|
+
|
335
|
+
todos_list = TodosList.new
|
336
|
+
|
337
|
+
todos_list.items.first.name # => all("li").first.find("span[data-role=name]")
|
338
|
+
todos_list.items.first.has_name? # => all("li").first.has_css?("span[data-role=name]")
|
339
|
+
todos_list.items.first.has_no_name?(text: "Buy yogurt") # => all("li").first.has_no_css?("span[data-role=name]", text: "Buy yogurt")
|
340
|
+
todos_list.items.first.complete_button.click # => all("li").first.find("input[type=checkbox][data-action=toggle-complete]").click
|
341
|
+
|
342
|
+
# and, because we're using has_many_ordered:
|
343
|
+
|
344
|
+
todos_list.item_at(0).name # => find("li:nth-of-type(1)").find("span[data-role=name]")
|
345
|
+
todos_list.item_at(0).has_name? # => find("li:nth-of-type(1)").has_css?("span[data-role=name]")
|
346
|
+
todos_list.item_at(0).has_no_name?(text: "Buy yogurt") # => find("li:nth-of-type(1)").has_no_css?("span[data-role=name]", text: "Buy yogurt")
|
347
|
+
todos_list.item_at(0).complete_button.click # => find("li:nth-of-type(1)").find("input[type=checkbox][data-action=toggle-complete]").click
|
348
|
+
```
|
349
|
+
|
350
|
+
[`Capybara::Node::Finders#all`]: https://rubydoc.info/github/teamcapybara/capybara/Capybara/Node/Finders#all-instance_method
|
351
|
+
[`Capybara::Node::Finders#find`]: https://rubydoc.info/github/teamcapybara/capybara/Capybara/Node/Finders#find-instance_method
|
352
|
+
[`Capybara::Node::Matchers#has_css?`]: https://rubydoc.info/github/teamcapybara/capybara/Capybara/Node/Matchers#has_css%3F-instance_method
|
353
|
+
|
354
|
+
## Base Selectors
|
355
|
+
|
356
|
+
Certain components may exist across multiple pages but have a base selector
|
357
|
+
from which all interactions should be scoped.
|
358
|
+
|
359
|
+
This can be configured on a per-object basis:
|
360
|
+
|
361
|
+
```rb
|
362
|
+
class ApplicationHeader < PageEz::Page
|
363
|
+
base_selector "header[data-role=primary]"
|
364
|
+
|
365
|
+
has_one :application_title, "h1"
|
366
|
+
end
|
367
|
+
```
|
368
|
+
|
369
|
+
## Page Object Composition
|
370
|
+
|
371
|
+
Because page objects can encompass as much or as little of the DOM as desired,
|
372
|
+
it's possible to compose multiple page objects.
|
373
|
+
|
374
|
+
### Composition via DSL
|
375
|
+
|
376
|
+
```rb
|
377
|
+
class Card < PageEz::Page
|
378
|
+
has_one :header, "h3"
|
379
|
+
end
|
380
|
+
|
381
|
+
class PrimaryNav < PageEz::Page
|
382
|
+
has_one :home_link, "a[data-role='home-link']"
|
383
|
+
end
|
384
|
+
|
385
|
+
class Dashboard < PageEz::Page
|
386
|
+
has_many_ordered :metrics, "ul.metrics li" do
|
387
|
+
has_one :card, Card
|
388
|
+
end
|
389
|
+
|
390
|
+
has_one :primary_nav, PrimaryNav, base_selector: "nav.primary"
|
391
|
+
end
|
392
|
+
```
|
393
|
+
|
394
|
+
### Manual Composition
|
395
|
+
|
396
|
+
```rb
|
397
|
+
class Card < PageEz::Page
|
398
|
+
has_one :header, "h3"
|
399
|
+
end
|
400
|
+
|
401
|
+
class PrimaryNav < PageEz::Page
|
402
|
+
has_one :home_link, "a[data-role='home-link']"
|
403
|
+
end
|
404
|
+
|
405
|
+
class Dashboard < PageEz::Page
|
406
|
+
has_many_ordered :metrics, "ul.metrics li" do
|
407
|
+
def card
|
408
|
+
# passing `self` is required to scope the query for the specific card
|
409
|
+
# within the metric when nested inside `has_one`, `has_many`, and
|
410
|
+
# `has_many_ordered`
|
411
|
+
Card.new(self)
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
def primary_nav
|
416
|
+
# pass the element `Capybara::Node::Element` to scope page interaction when
|
417
|
+
# composing at the top-level PageEz::Page class
|
418
|
+
PrimaryNav.new(find("nav.primary"))
|
419
|
+
end
|
420
|
+
end
|
421
|
+
```
|
422
|
+
|
423
|
+
With the following markup:
|
424
|
+
|
425
|
+
```html
|
426
|
+
<nav class="primary">
|
427
|
+
<ul>
|
428
|
+
<li><a data-role="home-link" href="/">Home</a></li>
|
429
|
+
</ul>
|
430
|
+
</nav>
|
431
|
+
|
432
|
+
<ul class="metrics">
|
433
|
+
<li><h3>Metric 0</h3></li>
|
434
|
+
<li><h3>Metric 1</h3></li>
|
435
|
+
<li><h3>Metric 2</h3></li>
|
436
|
+
</ul>
|
437
|
+
|
438
|
+
<ul class="stats">
|
439
|
+
<li><h3>Stat 1</h3></li>
|
440
|
+
<li><h3>Stat 2</h3></li>
|
441
|
+
<li><h3>Stat 3</h3></li>
|
442
|
+
</ul>
|
443
|
+
```
|
444
|
+
|
445
|
+
One could then interact with the card as such:
|
446
|
+
|
447
|
+
```rb
|
448
|
+
# within a spec file
|
449
|
+
|
450
|
+
visit "/"
|
451
|
+
|
452
|
+
dashboard = Dashboard.new
|
453
|
+
|
454
|
+
expect(dashboard.primary_nav).to have_home_link
|
455
|
+
expect(dashboard.metric_at(0).card.header).to have_text("Metric 0")
|
456
|
+
```
|
457
|
+
|
458
|
+
Review page object composition within the [composition specs].
|
459
|
+
|
460
|
+
[composition specs]: ./spec/features/composition_spec.rb
|
461
|
+
|
462
|
+
## Configuration
|
463
|
+
|
464
|
+
### Logger
|
465
|
+
|
466
|
+
Configure PageEz's logger to capture debugging information about
|
467
|
+
which page objects and methods are defined.
|
468
|
+
|
469
|
+
|
470
|
+
```rb
|
471
|
+
PageEz.configure do |config|
|
472
|
+
config.logger = Logger.new($stdout)
|
473
|
+
end
|
474
|
+
```
|
475
|
+
|
476
|
+
### Pluralization Warnings
|
477
|
+
|
478
|
+
Use of the different macros imply singular or plural values, e.g.
|
479
|
+
|
480
|
+
* `has_one :todos_list, "ul"`
|
481
|
+
* `has_many :cards, "li[data-role=card]"`
|
482
|
+
|
483
|
+
By default, PageEz allows for any pluralization usage regardless of macro. You
|
484
|
+
can configure PageEz to either warn (via its logger) or raise an exception if
|
485
|
+
pluralization doesn't look to align. Behind the scenes, PageEz uses
|
486
|
+
ActiveSupport's pluralization mechanisms.
|
487
|
+
|
488
|
+
```rb
|
489
|
+
PageEz.configure do |config|
|
490
|
+
config.on_pluralization_mismatch = :warn # or :raise, nil is the default
|
491
|
+
end
|
492
|
+
```
|
493
|
+
|
494
|
+
### Collisions with Capybara's RSpec Matchers
|
495
|
+
|
496
|
+
Capybara ships with a set of RSpec matchers, including:
|
497
|
+
|
498
|
+
* `have_title`
|
499
|
+
* `have_link`
|
500
|
+
* `have_button`
|
501
|
+
* `have_field`
|
502
|
+
* `have_select`
|
503
|
+
* `have_table`
|
504
|
+
* `have_text`
|
505
|
+
|
506
|
+
By default, if any elements are declared in PageEz that would overlap
|
507
|
+
with these matchers (e.g. `has_one :title, "h3"`), PageEz will raise an
|
508
|
+
exception in order to prevent confusing errors when asserting via predicate
|
509
|
+
matchers (since PageEz will define corresponding `has_title?` and
|
510
|
+
`has_no_title?` methods).
|
511
|
+
|
512
|
+
You can configure the behavior to warn (or do nothing):
|
513
|
+
|
514
|
+
```rb
|
515
|
+
PageEz.configure do |config|
|
516
|
+
config.on_matcher_collision = :warn # or nil, :raise is the default
|
517
|
+
end
|
518
|
+
```
|
519
|
+
|
520
|
+
## Development
|
521
|
+
|
522
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
523
|
+
`rake spec` to run the tests. You can also run `bin/console` for an interactive
|
524
|
+
prompt that will allow you to experiment.
|
525
|
+
|
526
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To
|
527
|
+
release a new version, update the version number in `version.rb`, and then run
|
528
|
+
`bundle exec rake release`, which will create a git tag for the version, push
|
529
|
+
git commits and the created tag, and push the `.gem` file to
|
530
|
+
[rubygems.org](https://rubygems.org).
|
531
|
+
|
532
|
+
## Feature Tests
|
533
|
+
|
534
|
+
This uses a test harness for Rack app generation called `AppGenerator`, which
|
535
|
+
handles mounting HTML responses to endpoints accessible via `GET`. In tests,
|
536
|
+
call `build_page` with the markup you'd like and it will mount that response to
|
537
|
+
the root of the application.
|
538
|
+
|
539
|
+
```ruby
|
540
|
+
page = build_page(<<-HTML)
|
541
|
+
<form>
|
542
|
+
<input name="name" type="text" />
|
543
|
+
<input name="email" type="text" />
|
544
|
+
</form>
|
545
|
+
HTML
|
546
|
+
```
|
547
|
+
|
548
|
+
To drive interactions with a headless browser, add the RSpec metadata `:js` to
|
549
|
+
either individual `it`s or `describe`s.
|
550
|
+
|
551
|
+
## Roadmap
|
552
|
+
|
553
|
+
* [x] Verify page object interactions work within `within`
|
554
|
+
* [ ] Define `form` syntax
|
555
|
+
* [ ] Define `define` syntax (FactoryBot style)
|
556
|
+
* [x] Nested/reference-able page objects (from `define` syntax, by symbol, or by class name)
|
557
|
+
|
558
|
+
## Contributing
|
559
|
+
|
560
|
+
Bug reports and pull requests are welcome on GitHub at
|
561
|
+
https://github.com/joshuaclayton/page_ez. This project is intended to be a
|
562
|
+
safe, welcoming space for collaboration, and contributors are expected to
|
563
|
+
adhere to the [code of
|
564
|
+
conduct](https://github.com/joshuaclayton/page_ez/blob/main/CODE_OF_CONDUCT.md).
|
565
|
+
|
566
|
+
## License
|
567
|
+
|
568
|
+
The gem is available as open source under the terms of the [MIT
|
569
|
+
License](https://opensource.org/licenses/MIT).
|
570
|
+
|
571
|
+
## Code of Conduct
|
572
|
+
|
573
|
+
Everyone interacting in the PageEz project's codebases, issue trackers, chat
|
574
|
+
rooms and mailing lists is expected to follow the [code of
|
575
|
+
conduct](https://github.com/joshuaclayton/page_ez/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
module PageEz
|
2
|
+
class Configuration
|
3
|
+
VALID_MISMATCH_BEHAVIORS = [:warn, :raise, nil].freeze
|
4
|
+
attr_accessor :logger
|
5
|
+
attr_reader :on_pluralization_mismatch, :on_matcher_collision
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
reset
|
9
|
+
end
|
10
|
+
|
11
|
+
def on_pluralization_mismatch=(value)
|
12
|
+
if !VALID_MISMATCH_BEHAVIORS.include?(value)
|
13
|
+
raise ArgumentError, "#{value.inspect} must be one of #{VALID_MISMATCH_BEHAVIORS}"
|
14
|
+
end
|
15
|
+
|
16
|
+
@on_pluralization_mismatch = value
|
17
|
+
end
|
18
|
+
|
19
|
+
def on_matcher_collision=(value)
|
20
|
+
if !VALID_MISMATCH_BEHAVIORS.include?(value)
|
21
|
+
raise ArgumentError, "#{value.inspect} must be one of #{VALID_MISMATCH_BEHAVIORS}"
|
22
|
+
end
|
23
|
+
|
24
|
+
@on_matcher_collision = value
|
25
|
+
end
|
26
|
+
|
27
|
+
def reset
|
28
|
+
self.logger = NullLogger.new
|
29
|
+
self.on_pluralization_mismatch = nil
|
30
|
+
self.on_matcher_collision = :raise
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module PageEz
|
2
|
+
module DelegatesTo
|
3
|
+
def self.[](name)
|
4
|
+
Module.new do
|
5
|
+
define_singleton_method(:included) do |base|
|
6
|
+
base.include(Module.new.tap do |mod|
|
7
|
+
mod.class_eval %{
|
8
|
+
def method_missing(*args, **kwargs, &block)
|
9
|
+
if #{name}.respond_to?(args[0])
|
10
|
+
#{name}.send(*args, **kwargs, &block)
|
11
|
+
else
|
12
|
+
super(*args, **kwargs, &block)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def respond_to_missing?(method_name, include_private = false)
|
17
|
+
#{name}.respond_to?(method_name, include_private) || super(method_name, include_private)
|
18
|
+
end
|
19
|
+
}, __FILE__, __LINE__ - 12
|
20
|
+
end)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module PageEz
|
2
|
+
class Error < StandardError; end
|
3
|
+
|
4
|
+
class PluralizationMismatchError < StandardError; end
|
5
|
+
|
6
|
+
class MatcherCollisionError < StandardError; end
|
7
|
+
|
8
|
+
class DuplicateElementDeclarationError < StandardError; end
|
9
|
+
|
10
|
+
class InvalidSelectorError < StandardError; end
|
11
|
+
|
12
|
+
def self.reraise_selector_error(selector)
|
13
|
+
yield
|
14
|
+
rescue Nokogiri::CSS::SyntaxError => e
|
15
|
+
raise InvalidSelectorError, "Invalid selector '#{selector}':\n#{e.message}"
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module PageEz
|
2
|
+
class HasManyResult
|
3
|
+
include DelegatesTo[:@result]
|
4
|
+
|
5
|
+
def initialize(container:, selector:, options:, constructor:)
|
6
|
+
@container = container
|
7
|
+
@selector = selector
|
8
|
+
@options = options
|
9
|
+
@result = container.all(
|
10
|
+
selector,
|
11
|
+
**options
|
12
|
+
).map do |element|
|
13
|
+
constructor.call(element)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def has_count_of?(count)
|
18
|
+
@container.has_css?(
|
19
|
+
@selector,
|
20
|
+
**@options.merge(count: count)
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
def has_any_elements?
|
25
|
+
@container.has_css?(
|
26
|
+
@selector,
|
27
|
+
**@options
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
def has_no_elements?
|
32
|
+
@container.has_no_css?(@selector, **@options)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|