page_ez 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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