/ tests / e2e / src / locators.rs
locators.rs
  1  use playwright_rs::{AriaRole, FilterOptions, GetByRoleOptions, Page};
  2  
  3  pub enum Locator {
  4      Css(String),
  5      Text { text: String, exact: bool },
  6      Role(AriaRole, GetByRoleOptions),
  7      Title { text: String, exact: bool },
  8      Placeholder { text: String, exact: bool },
  9      Label { text: String, exact: bool },
 10      Scoped(Box<Locator>, Box<Locator>),
 11      First(Box<Locator>),
 12      Last(Box<Locator>),
 13      Nth(Box<Locator>, i32),
 14      Filtered {
 15          base: Box<Locator>,
 16          has_text: Option<String>,
 17          has_not_text: Option<String>,
 18      },
 19  }
 20  
 21  pub struct RoleBuilder {
 22      role: AriaRole,
 23      opts: GetByRoleOptions,
 24  }
 25  
 26  impl RoleBuilder {
 27      pub fn name(mut self, name: &str) -> Self {
 28          self.opts.name = Some(name.into());
 29          self
 30      }
 31  
 32      pub fn level(mut self, level: u32) -> Self {
 33          self.opts.level = Some(level);
 34          self
 35      }
 36  
 37      pub fn checked(mut self, checked: bool) -> Self {
 38          self.opts.checked = Some(checked);
 39          self
 40      }
 41  
 42      pub fn disabled(mut self, disabled: bool) -> Self {
 43          self.opts.disabled = Some(disabled);
 44          self
 45      }
 46  
 47      pub fn expanded(mut self, expanded: bool) -> Self {
 48          self.opts.expanded = Some(expanded);
 49          self
 50      }
 51  
 52      pub fn pressed(mut self, pressed: bool) -> Self {
 53          self.opts.pressed = Some(pressed);
 54          self
 55      }
 56  
 57      pub fn exact(mut self) -> Self {
 58          self.opts.exact = Some(true);
 59          self
 60      }
 61  
 62      pub fn include_hidden(mut self) -> Self {
 63          self.opts.include_hidden = Some(true);
 64          self
 65      }
 66  
 67      pub fn build(self) -> Locator {
 68          Locator::Role(self.role, self.opts)
 69      }
 70  }
 71  
 72  impl From<RoleBuilder> for Locator {
 73      fn from(rb: RoleBuilder) -> Self {
 74          rb.build()
 75      }
 76  }
 77  
 78  fn role_opts_to_pw(opts: &GetByRoleOptions) -> Option<GetByRoleOptions> {
 79      let is_default = opts.name.is_none()
 80          && opts.level.is_none()
 81          && opts.checked.is_none()
 82          && opts.disabled.is_none()
 83          && opts.selected.is_none()
 84          && opts.expanded.is_none()
 85          && opts.include_hidden.is_none()
 86          && opts.exact.is_none()
 87          && opts.pressed.is_none();
 88  
 89      if is_default { None } else { Some(opts.clone()) }
 90  }
 91  
 92  impl Locator {
 93      pub fn role(role: AriaRole) -> RoleBuilder {
 94          RoleBuilder {
 95              role,
 96              opts: GetByRoleOptions::default(),
 97          }
 98      }
 99  
100      pub fn text(t: &str) -> Self {
101          Self::Text { text: t.into(), exact: false }
102      }
103  
104      pub fn exact_text(t: &str) -> Self {
105          Self::Text { text: t.into(), exact: true }
106      }
107  
108      pub fn title(t: &str) -> Self {
109          Self::Title { text: t.into(), exact: true }
110      }
111  
112      pub fn placeholder(t: &str) -> Self {
113          Self::Placeholder { text: t.into(), exact: false }
114      }
115  
116      pub fn label(t: &str) -> Self {
117          Self::Label { text: t.into(), exact: false }
118      }
119  
120      pub fn exact_label(t: &str) -> Self {
121          Self::Label { text: t.into(), exact: true }
122      }
123  
124      pub fn scoped(parent: Locator, child: Locator) -> Self {
125          Self::Scoped(Box::new(parent), Box::new(child))
126      }
127  
128      pub fn first(self) -> Self {
129          Self::First(Box::new(self))
130      }
131  
132      pub fn last(self) -> Self {
133          Self::Last(Box::new(self))
134      }
135  
136      pub fn nth(self, n: i32) -> Self {
137          Self::Nth(Box::new(self), n)
138      }
139  
140      pub fn filter_has_text(self, text: &str) -> Self {
141          Self::Filtered {
142              base: Box::new(self),
143              has_text: Some(text.into()),
144              has_not_text: None,
145          }
146      }
147  
148      pub fn filter_has_not_text(self, text: &str) -> Self {
149          Self::Filtered {
150              base: Box::new(self),
151              has_text: None,
152              has_not_text: Some(text.into()),
153          }
154      }
155  
156      pub fn resolve<'a>(
157          &'a self,
158          page: &'a Page,
159      ) -> std::pin::Pin<Box<dyn std::future::Future<Output = playwright_rs::Locator> + 'a>> {
160          Box::pin(async move {
161              match self {
162                  Self::Scoped(parent, child) => {
163                      let p = parent.resolve(page).await;
164                      child.resolve_in(&p)
165                  }
166                  Self::First(inner) => inner.resolve(page).await.first(),
167                  Self::Last(inner) => inner.resolve(page).await.last(),
168                  Self::Nth(inner, n) => inner.resolve(page).await.nth(*n),
169                  Self::Filtered { base, has_text, has_not_text } => {
170                      base.resolve(page).await.filter(FilterOptions {
171                          has_text: has_text.clone(),
172                          has_not_text: has_not_text.clone(),
173                          has: None,
174                          has_not: None,
175                      })
176                  }
177                  _ => self.resolve_leaf(page).await,
178              }
179          })
180      }
181  
182      async fn resolve_leaf(&self, page: &Page) -> playwright_rs::Locator {
183          match self {
184              Self::Css(s) => page.locator(s).await,
185              Self::Text { text, exact } => page.get_by_text(text, *exact).await,
186              Self::Role(role, opts) => page.get_by_role(*role, role_opts_to_pw(opts)).await,
187              Self::Title { text, exact } => page.get_by_title(text, *exact).await,
188              Self::Placeholder { text, exact } => page.get_by_placeholder(text, *exact).await,
189              Self::Label { text, exact } => page.get_by_label(text, *exact).await,
190              _ => unreachable!(),
191          }
192      }
193  
194      fn resolve_in(&self, parent: &playwright_rs::Locator) -> playwright_rs::Locator {
195          match self {
196              Self::Css(s) => parent.locator(s),
197              Self::Text { text, exact } => parent.get_by_text(text, *exact),
198              Self::Role(role, opts) => parent.get_by_role(*role, role_opts_to_pw(opts)),
199              Self::Title { text, exact } => parent.get_by_title(text, *exact),
200              Self::Placeholder { text, exact } => parent.get_by_placeholder(text, *exact),
201              Self::Label { text, exact } => parent.get_by_label(text, *exact),
202              Self::Scoped(p, c) => {
203                  let mid = p.resolve_in(parent);
204                  c.resolve_in(&mid)
205              }
206              Self::First(inner) => inner.resolve_in(parent).first(),
207              Self::Last(inner) => inner.resolve_in(parent).last(),
208              Self::Nth(inner, n) => inner.resolve_in(parent).nth(*n),
209              Self::Filtered { base, has_text, has_not_text } => {
210                  base.resolve_in(parent).filter(FilterOptions {
211                      has_text: has_text.clone(),
212                      has_not_text: has_not_text.clone(),
213                      has: None,
214                      has_not: None,
215                  })
216              }
217          }
218      }
219  }
220  
221  // ===== LAYOUT: NAVBAR =====
222  
223  pub mod navbar {
224      use super::{AriaRole, Locator};
225  
226      pub fn root() -> Locator {
227          Locator::Css("header".into())
228      }
229  
230      pub fn logo() -> Locator {
231          Locator::scoped(root(), Locator::role(AriaRole::Link).build())
232      }
233  
234      pub fn search_button() -> Locator {
235          Locator::role(AriaRole::Button).name("Search...").build()
236      }
237  
238      pub fn wifi_button() -> Locator {
239          Locator::Css("button[aria-label='Network settings']".into())
240      }
241  
242      pub fn skeleton_badges() -> Locator {
243          Locator::scoped(root(), Locator::Css(".animate-pulse".into())).first()
244      }
245  
246      pub fn chip_badge() -> Locator {
247          Locator::scoped(root(), Locator::Text { text: "ESP32".into(), exact: false })
248      }
249  
250      pub fn memory_badge() -> Locator {
251          Locator::scoped(root(), Locator::Css("span.font-mono".into())).last()
252      }
253  }
254  
255  // ===== LAYOUT: FOOTER =====
256  
257  pub mod footer {
258      use super::Locator;
259  
260      pub fn root() -> Locator {
261          Locator::Css("footer".into())
262      }
263  
264      pub fn company_name() -> Locator {
265          Locator::scoped(root(), Locator::text("Apidae Systems"))
266      }
267  
268      pub fn website_link() -> Locator {
269          Locator::scoped(root(), Locator::title("Website"))
270      }
271  
272      pub fn linkedin_link() -> Locator {
273          Locator::scoped(root(), Locator::title("LinkedIn"))
274      }
275  
276      pub fn github_link() -> Locator {
277          Locator::scoped(root(), Locator::title("GitHub"))
278      }
279  }
280  
281  // ===== HOME: URL BAR =====
282  
283  pub mod url_bar {
284      use super::{AriaRole, Locator};
285  
286      pub fn protocol_toggle() -> Locator {
287          Locator::role(AriaRole::Button).name("http").build()
288      }
289  
290      pub fn hostname_input() -> Locator {
291          Locator::Css("input[aria-label='Device URL']".into())
292      }
293  
294      pub fn connection_status() -> Locator {
295          Locator::Css("div[aria-label='POLLING'], div[aria-label='LIVE'], div[aria-label*='.']".into())
296      }
297  
298      pub fn live_status() -> Locator {
299          Locator::Css("div[aria-label='LIVE'], div[aria-label='POLLING'], div[aria-label*='.']".into())
300      }
301  }
302  
303  // ===== HOME: MEASUREMENT =====
304  
305  pub mod measurement {
306      use super::{AriaRole, Locator};
307  
308      pub fn root() -> Locator {
309          Locator::Css("#cloudevents-section".into())
310      }
311  
312      pub fn tab(label: &str) -> Locator {
313          Locator::scoped(root(), Locator::role(AriaRole::Tab).name(label).build())
314      }
315  
316      pub fn temp_humidity_tab() -> Locator {
317          Locator::role(AriaRole::Tab).name("Temp/Humidity").build()
318      }
319  
320      pub fn voltage_tab() -> Locator {
321          Locator::role(AriaRole::Tab).name("Voltage").build()
322      }
323  
324      pub fn co2_tab() -> Locator {
325          Locator::role(AriaRole::Tab).name("CO\u{2082}").build()
326      }
327  
328      pub fn empty_state() -> Locator {
329          Locator::scoped(root(), Locator::text("No readings yet"))
330      }
331  
332      pub fn sample_button() -> Locator {
333          Locator::role(AriaRole::Button).name("Sample").build()
334      }
335  
336      pub fn csv_button() -> Locator {
337          Locator::role(AriaRole::Button).name("CSV").exact().build()
338      }
339  }
340  
341  // ===== HOME: SLEEP =====
342  
343  pub mod sleep {
344      use super::{AriaRole, Locator};
345  
346      pub fn root() -> Locator {
347          Locator::Css("#sleep-panel".into())
348      }
349  
350      pub fn heading() -> Locator {
351          Locator::scoped(root(), Locator::text("Deep Sleep"))
352      }
353  
354      pub fn wake_cause_badge() -> Locator {
355          Locator::scoped(root(), Locator::text("power_on"))
356      }
357  
358      pub fn preset_button(label: &str) -> Locator {
359          Locator::scoped(
360              root(),
361              Locator::role(AriaRole::Button).name(label).exact().build(),
362          )
363      }
364  
365      pub fn custom_button() -> Locator {
366          preset_button("custom")
367      }
368  
369      pub fn hours_input() -> Locator {
370          Locator::scoped(root(), Locator::label("Hours"))
371      }
372  
373      pub fn minutes_input() -> Locator {
374          Locator::scoped(root(), Locator::label("Minutes"))
375      }
376  
377      pub fn seconds_input() -> Locator {
378          Locator::scoped(root(), Locator::label("Seconds"))
379      }
380  
381      pub fn toggle() -> Locator {
382          Locator::scoped(root(), Locator::role(AriaRole::Switch).build())
383      }
384  }
385  
386  // ===== HOME: FILESYSTEM =====
387  
388  pub mod filesystem {
389      use super::{AriaRole, Locator};
390  
391      pub fn root() -> Locator {
392          Locator::Css("#filesystem-section".into())
393      }
394  
395      pub fn heading() -> Locator {
396          Locator::scoped(root(), Locator::text("Filesystem"))
397      }
398  
399      pub fn sd_section() -> Locator {
400          Locator::scoped(root(), Locator::text("SD"))
401      }
402  
403      pub fn littlefs_section() -> Locator {
404          Locator::scoped(root(), Locator::text("LittleFS"))
405      }
406  
407      pub fn file_row(filename: &str) -> Locator {
408          Locator::scoped(root(), Locator::text(filename))
409      }
410  
411      pub fn add_file_label() -> Locator {
412          Locator::scoped(root(), Locator::text("Add file...")).first()
413      }
414  
415      pub fn rename_button(filename: &str) -> Locator {
416          Locator::scoped(root(), Locator::Css(format!("button[aria-label='Rename {filename}']")))
417      }
418  
419      pub fn delete_button(filename: &str) -> Locator {
420          Locator::scoped(root(), Locator::Css(format!("button[aria-label='Delete {filename}']")))
421      }
422  
423      pub fn delete_dialog() -> Locator {
424          Locator::role(AriaRole::Alertdialog).build()
425      }
426  
427      pub fn rename_dialog() -> Locator {
428          Locator::role(AriaRole::Alertdialog).build()
429      }
430  
431      pub fn dialog_cancel() -> Locator {
432          Locator::role(AriaRole::Button).name("Cancel").build()
433      }
434  
435      pub fn dialog_delete() -> Locator {
436          Locator::role(AriaRole::Button).name("Delete").exact().build()
437      }
438  
439      pub fn dialog_rename() -> Locator {
440          Locator::role(AriaRole::Button).name("Rename").exact().build()
441      }
442  
443      pub fn rename_input() -> Locator {
444          Locator::label("New filename")
445      }
446  
447      pub fn csv_preview_dialog() -> Locator {
448          Locator::role(AriaRole::Dialog).build()
449      }
450  
451      pub fn preview_close() -> Locator {
452          Locator::role(AriaRole::Button).name("Close").build()
453      }
454  }
455  
456  // ===== HOME: TERMINAL =====
457  
458  pub mod terminal {
459      use super::Locator;
460  
461      pub fn root() -> Locator {
462          Locator::Css("#terminal-container".into())
463      }
464  }
465  
466  // ===== HOME: FLASH =====
467  
468  pub mod flash {
469      use super::{AriaRole, Locator};
470  
471      pub fn root() -> Locator {
472          Locator::Css("#flash-panel".into())
473      }
474  
475      pub fn heading() -> Locator {
476          Locator::scoped(root(), Locator::text("Firmware Update"))
477      }
478  
479      pub fn connect_button() -> Locator {
480          Locator::scoped(root(), Locator::role(AriaRole::Button).name("Connect").build())
481      }
482  
483      pub fn disconnect_button() -> Locator {
484          Locator::scoped(root(), Locator::role(AriaRole::Button).name("Disconnect").build())
485      }
486  
487      pub fn flash_button() -> Locator {
488          Locator::scoped(root(), Locator::role(AriaRole::Button).name("Flash Firmware").build())
489      }
490  
491      pub fn monitor_button() -> Locator {
492          Locator::scoped(root(), Locator::role(AriaRole::Button).name("Monitor").build())
493      }
494  
495      pub fn firmware_section() -> Locator {
496          Locator::scoped(root(), Locator::text("Select a firmware"))
497      }
498  }
499  
500  // ===== COMMAND PALETTE =====
501  
502  pub mod command_palette {
503      use super::{AriaRole, Locator};
504  
505      pub fn root() -> Locator {
506          Locator::role(AriaRole::Dialog).build()
507      }
508  
509      pub fn search_input() -> Locator {
510          Locator::Css("input[aria-label='Search commands']".into())
511      }
512  
513      pub fn command(label: &str) -> Locator {
514          Locator::role(AriaRole::Button).name(label).build()
515      }
516  }
517  
518  // ===== NETWORK SHEET =====
519  
520  pub mod network_sheet {
521      use super::{AriaRole, Locator};
522  
523      pub fn root() -> Locator {
524          Locator::Css("div[role='dialog'][aria-label='Network settings']".into())
525      }
526  
527      pub fn scan_button() -> Locator {
528          Locator::scoped(root(), Locator::role(AriaRole::Button).name("Scan").build())
529      }
530  
531      pub fn ssid_input() -> Locator {
532          Locator::Css("#network-ssid-input".into())
533      }
534  
535      pub fn password_input() -> Locator {
536          Locator::Css("#network-password-input".into())
537      }
538  
539      pub fn connect_button() -> Locator {
540          Locator::scoped(root(), Locator::role(AriaRole::Button).name("Connect").build())
541      }
542  
543      pub fn close_button() -> Locator {
544          Locator::scoped(root(), Locator::Css("button[aria-label='Close']".into()))
545      }
546  
547      pub fn results_region() -> Locator {
548          Locator::scoped(root(), Locator::text("RSSI"))
549      }
550  }
551  
552  // ===== 404 =====
553  
554  pub mod not_found {
555      use super::{AriaRole, Locator};
556  
557      pub fn heading() -> Locator {
558          Locator::role(AriaRole::Heading).level(1).build()
559      }
560  
561      pub fn subheading() -> Locator {
562          Locator::role(AriaRole::Heading).level(2).build()
563      }
564  
565      pub fn back_home_button() -> Locator {
566          Locator::text("Back to Home")
567      }
568  }
569  
570  // ===== DOCS =====
571  
572  pub mod docs {
573      use super::{AriaRole, Locator};
574  
575      pub fn sidebar() -> Locator {
576          Locator::role(AriaRole::Navigation).build()
577      }
578  
579      pub fn content() -> Locator {
580          Locator::Css("article".into())
581      }
582  }