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 }