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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.standard.yml +3 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/Gemfile +5 -0
  6. data/Gemfile.lock +148 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +575 -0
  9. data/Rakefile +10 -0
  10. data/lib/page_ez/configuration.rb +33 -0
  11. data/lib/page_ez/delegates_to.rb +25 -0
  12. data/lib/page_ez/errors.rb +17 -0
  13. data/lib/page_ez/has_many_result.rb +35 -0
  14. data/lib/page_ez/has_one_result.rb +14 -0
  15. data/lib/page_ez/method_generators/define_has_many_result_methods.rb +48 -0
  16. data/lib/page_ez/method_generators/define_has_one_predicate_methods.rb +42 -0
  17. data/lib/page_ez/method_generators/define_has_one_result_methods.rb +35 -0
  18. data/lib/page_ez/method_generators/has_many_dynamic_selector.rb +39 -0
  19. data/lib/page_ez/method_generators/has_many_ordered_dynamic_selector.rb +39 -0
  20. data/lib/page_ez/method_generators/has_many_ordered_selector.rb +47 -0
  21. data/lib/page_ez/method_generators/has_many_static_selector.rb +41 -0
  22. data/lib/page_ez/method_generators/has_one_composed_class.rb +40 -0
  23. data/lib/page_ez/method_generators/has_one_dynamic_selector.rb +39 -0
  24. data/lib/page_ez/method_generators/has_one_static_selector.rb +25 -0
  25. data/lib/page_ez/method_generators/identity_processor.rb +11 -0
  26. data/lib/page_ez/null_logger.rb +12 -0
  27. data/lib/page_ez/options.rb +37 -0
  28. data/lib/page_ez/page.rb +162 -0
  29. data/lib/page_ez/page_visitor.rb +72 -0
  30. data/lib/page_ez/parameters.rb +54 -0
  31. data/lib/page_ez/pluralization.rb +25 -0
  32. data/lib/page_ez/selector_evaluator.rb +76 -0
  33. data/lib/page_ez/version.rb +5 -0
  34. data/lib/page_ez/visitors/debug_visitor.rb +59 -0
  35. data/lib/page_ez/visitors/depth_visitor.rb +44 -0
  36. data/lib/page_ez/visitors/macro_pluralization_visitor.rb +70 -0
  37. data/lib/page_ez/visitors/matcher_collision_visitor.rb +75 -0
  38. data/lib/page_ez/visitors/registered_name_visitor.rb +100 -0
  39. data/lib/page_ez.rb +41 -0
  40. data/page_ez.gemspec +43 -0
  41. metadata +238 -0
data/README.md ADDED
@@ -0,0 +1,575 @@
1
+ # PageEz
2
+
3
+ [![Coverage Status](https://coveralls.io/repos/github/joshuaclayton/page_ez/badge.svg?branch=main)](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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -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