1use super::*;
8
9impl BrowserSession {
10 pub async fn observe_delta(&self) -> BrowserResult<ObservationDelta> {
14 let previous = self
15 .observation_cache
16 .lock()
17 .await
18 .as_ref()
19 .map(|cached| cached.context.clone())
20 .ok_or("observe_delta requires a prior compact observation")?;
21 let current = self.observe_fresh().await?;
22 if previous.page.target_id != current.page.target_id
23 || previous.page.frame_id != current.page.frame_id
24 {
25 return Err("observe_delta cannot compare different routes".into());
26 }
27
28 let old_by_backend: HashMap<_, _> = previous
29 .accessibility
30 .interactive
31 .iter()
32 .map(|control| (control.backend_dom_node_id, control))
33 .collect();
34 let new_by_backend: HashMap<_, _> = current
35 .accessibility
36 .interactive
37 .iter()
38 .map(|control| (control.backend_dom_node_id, control))
39 .collect();
40 let control = |value: &CompactInteractiveElement| DeltaControl {
41 reference: value.reference.clone(),
42 role: value.role.clone(),
43 name: value.name.clone(),
44 };
45 let mut added = Vec::new();
46 let mut removed = Vec::new();
47 let mut changed = Vec::new();
48 for value in ¤t.accessibility.interactive {
49 match old_by_backend.get(&value.backend_dom_node_id) {
50 None if added.len() < 8 => added.push(control(value)),
51 Some(old)
52 if (old.role != value.role || old.name != value.name) && changed.len() < 8 =>
53 {
54 changed.push(control(value));
55 }
56 _ => {}
57 }
58 }
59 for value in &previous.accessibility.interactive {
60 if !new_by_backend.contains_key(&value.backend_dom_node_id) && removed.len() < 8 {
61 removed.push(control(value));
62 }
63 }
64 let revision_delta = current
65 .accessibility
66 .revision
67 .saturating_sub(previous.accessibility.revision);
68 Ok(ObservationDelta {
69 from_revision: previous.accessibility.revision,
70 to_revision: current.accessibility.revision,
71 mutation_summary: MutationSummary {
72 url_changed: previous.page.url != current.page.url,
73 title_changed: previous.page.title != current.page.title,
74 revision_delta,
75 soft_navigation_suspected: revision_delta > 0
76 && previous.page.url == current.page.url
77 && previous.page.title == current.page.title,
78 },
79 added,
80 removed,
81 changed,
82 prior_incomplete: previous.incomplete,
83 current_incomplete: current.incomplete,
84 })
85 }
86
87 pub async fn reconcile_references(
91 &self,
92 from_revision: u64,
93 refs: &[String],
94 ) -> BrowserResult<ReconciliationOutcome> {
95 self.reconcile_references_with_options(
96 from_revision,
97 refs,
98 &ReconciliationOptions::default(),
99 )
100 .await
101 }
102
103 pub async fn reconcile_references_with_options(
107 &self,
108 from_revision: u64,
109 refs: &[String],
110 options: &ReconciliationOptions,
111 ) -> BrowserResult<ReconciliationOutcome> {
112 if refs.len() > MAX_RECONCILE_REFS {
113 return Err(format!(
114 "too many refs to reconcile: {} (max {})",
115 refs.len(),
116 MAX_RECONCILE_REFS
117 )
118 .into());
119 }
120 if options.hints.len() > MAX_RECONCILE_HINTS {
121 return Err(format!(
122 "too many reconciliation hints: {} (max {})",
123 options.hints.len(),
124 MAX_RECONCILE_HINTS
125 )
126 .into());
127 }
128
129 let current_revision = self.page_revision.load(Ordering::Relaxed);
130 let prior_page = self
131 .observation_cache
132 .lock()
133 .await
134 .as_ref()
135 .filter(|cached| cached.revision == from_revision)
136 .map(|cached| cached.context.page.clone());
137 let prior = {
138 let cache = self.observation_cache.lock().await;
139 cache
140 .as_ref()
141 .filter(|cached| cached.revision == from_revision)
142 .map(|cached| {
143 (
144 cached.context.page.target_id.clone(),
145 cached.context.page.frame_id.clone(),
146 cached
147 .context
148 .accessibility
149 .interactive
150 .iter()
151 .map(|control| {
152 (
153 control.reference.clone(),
154 control.role.clone(),
155 control.name.clone(),
156 control.backend_dom_node_id,
157 control.ancestor_path.clone(),
158 )
159 })
160 .collect::<Vec<_>>(),
161 )
162 })
163 };
164 if current_revision == from_revision {
165 if prior.is_none() {
166 return bounded_reconciliation_outcome(ReconciliationOutcome {
167 status: ReconciliationStatus::Complete,
168 to_revision: current_revision,
169 preserved: 0,
170 relocated: 0,
171 lost: refs.len(),
172 mappings: refs
173 .iter()
174 .map(|old| ReferenceMapping::Lost {
175 old: old.clone(),
176 reason: ReferenceLostReason::StaleBoundary,
177 })
178 .collect(),
179 mutation_summary: MutationSummary::default(),
180 incomplete: vec![ObservationIncompleteReason::BoundaryScan],
181 });
182 }
183 let known_refs: HashSet<&str> = prior
187 .as_ref()
188 .map(|(_, _, controls)| {
189 controls
190 .iter()
191 .map(|(reference, _, _, _, _)| reference.as_str())
192 .collect()
193 })
194 .unwrap_or_default();
195 let scope_label = options.scope_ref.as_ref().and_then(|scope_ref| {
196 parse_revisioned_reference(scope_ref)
197 .ok()
198 .flatten()
199 .filter(|scope| scope.revision == from_revision)
200 .and_then(|scope| {
201 prior.as_ref().and_then(|(_, _, controls)| {
202 controls
203 .iter()
204 .find(|(_, _, _, backend, _)| *backend == scope.backend_dom_node_id)
205 .map(|(_, role, name, _, _)| format!("{}:{}", role, name))
206 })
207 })
208 });
209 let scope_invalid = options.scope_ref.is_some() && scope_label.is_none();
210 let mappings: Vec<_> = refs
211 .iter()
212 .map(|old| {
213 let valid = matches!(
214 parse_revisioned_reference(old),
215 Ok(Some(reference))
216 if reference.revision == from_revision
217 && known_refs.contains(old.as_str())
218 );
219 if !valid {
220 return ReferenceMapping::Lost {
221 old: old.clone(),
222 reason: ReferenceLostReason::StaleBoundary,
223 };
224 }
225 if scope_invalid
226 || scope_label.as_deref().is_some_and(|scope| {
227 !prior.as_ref().is_some_and(|(_, _, controls)| {
228 controls.iter().any(|(reference, _, _, _, ancestors)| {
229 reference == old
230 && ancestors.iter().any(|ancestor| ancestor == scope)
231 })
232 })
233 })
234 {
235 return ReferenceMapping::Lost {
236 old: old.clone(),
237 reason: ReferenceLostReason::OutOfScope,
238 };
239 }
240 ReferenceMapping::Preserved {
241 old: old.clone(),
242 new: old.clone(),
243 }
244 })
245 .collect();
246 let preserved = mappings
247 .iter()
248 .filter(|mapping| matches!(mapping, ReferenceMapping::Preserved { .. }))
249 .count();
250 let lost = mappings.len() - preserved;
251 return bounded_reconciliation_outcome(ReconciliationOutcome {
252 status: ReconciliationStatus::Complete,
253 to_revision: current_revision,
254 preserved,
255 relocated: 0,
256 lost,
257 mappings,
258 mutation_summary: MutationSummary::default(),
259 incomplete: Vec::new(),
260 });
261 }
262
263 let context = self.observe_fresh().await?;
265 let to_revision = context.accessibility.revision;
266
267 let mut current_by_backend: HashMap<i64, String> = HashMap::new();
269 let mut current_controls: Vec<(&str, &str, i64, &str, &[String])> = Vec::new();
270 let ax = &context.accessibility;
271 for c in &ax.interactive {
272 current_by_backend.insert(c.backend_dom_node_id, c.reference.clone());
273 current_controls.push((
274 &c.role,
275 &c.name,
276 c.backend_dom_node_id,
277 &c.reference,
278 &c.ancestor_path,
279 ));
280 }
281
282 let route_changed = prior.as_ref().is_none_or(|(old_target, old_frame, _)| {
286 context.page.target_id != *old_target || context.page.frame_id != *old_frame
287 });
288
289 let scope_label = options.scope_ref.as_ref().and_then(|scope_ref| {
290 parse_revisioned_reference(scope_ref)
291 .ok()
292 .flatten()
293 .filter(|scope| scope.revision == from_revision)
294 .and_then(|scope| {
295 current_controls
296 .iter()
297 .find(|(_, _, backend, _, _)| *backend == scope.backend_dom_node_id)
298 .map(|(role, name, _, _, _)| format!("{}:{}", role, name))
299 })
300 });
301 let scope_invalid = options.scope_ref.is_some() && scope_label.is_none();
302
303 let mut mappings = Vec::with_capacity(refs.len());
304 let mut preserved = 0usize;
305 let mut relocated = 0usize;
306 let mut lost = 0usize;
307
308 for old_ref in refs {
309 let Ok(Some(reference)) = parse_revisioned_reference(old_ref) else {
311 mappings.push(ReferenceMapping::Lost {
312 old: old_ref.clone(),
313 reason: ReferenceLostReason::StaleBoundary,
314 });
315 lost += 1;
316 continue;
317 };
318 if reference.revision != from_revision || route_changed {
319 mappings.push(ReferenceMapping::Lost {
320 old: old_ref.clone(),
321 reason: ReferenceLostReason::StaleBoundary,
322 });
323 lost += 1;
324 continue;
325 }
326 let backend_id = reference.backend_dom_node_id;
327
328 if scope_invalid {
329 mappings.push(ReferenceMapping::Lost {
330 old: old_ref.clone(),
331 reason: ReferenceLostReason::OutOfScope,
332 });
333 lost += 1;
334 continue;
335 }
336
337 if let Some(new_ref) = current_by_backend.get(&backend_id) {
339 if let Some(scope) = scope_label.as_deref()
340 && !current_controls
341 .iter()
342 .find(|control| control.3 == new_ref)
343 .is_some_and(|control| control.4.iter().any(|ancestor| ancestor == scope))
344 {
345 mappings.push(ReferenceMapping::Lost {
346 old: old_ref.clone(),
347 reason: ReferenceLostReason::OutOfScope,
348 });
349 lost += 1;
350 continue;
351 }
352 mappings.push(ReferenceMapping::Preserved {
353 old: old_ref.clone(),
354 new: new_ref.clone(),
355 });
356 preserved += 1;
357 continue;
358 }
359
360 let prior_control = prior.as_ref().and_then(|(_, _, controls)| {
362 controls
363 .iter()
364 .find(|(reference, _, _, _, _)| reference == old_ref)
365 });
366 let mut match_kind = ReferenceMatch::RoleAndName;
367 let mut matches: Vec<_> = prior_control
368 .map(|(_, role, name, _, _)| {
369 current_controls
370 .iter()
371 .filter(|(current_role, current_name, _, _, _)| {
372 current_role.eq_ignore_ascii_case(role)
373 && current_name.eq_ignore_ascii_case(name)
374 })
375 .collect()
376 })
377 .unwrap_or_default();
378
379 if matches.is_empty()
383 && let Some(hint) = options
384 .hints
385 .get(refs.iter().position(|r| r == old_ref).unwrap_or(usize::MAX))
386 {
387 match_kind = match hint {
388 Locator::AccessibleName(_) => ReferenceMatch::AccessibleName,
389 _ => ReferenceMatch::Hint,
390 };
391 matches = current_controls
392 .iter()
393 .filter(|control| locator_matches_compact(hint, control))
394 .collect();
395 }
396
397 if let Some(scope) = scope_label.as_deref() {
398 matches.retain(|(_, _, _, _, ancestors)| {
399 ancestors.iter().any(|ancestor| ancestor == scope)
400 });
401 if matches.is_empty() && prior_control.is_some() {
402 mappings.push(ReferenceMapping::Lost {
403 old: old_ref.clone(),
404 reason: ReferenceLostReason::OutOfScope,
405 });
406 lost += 1;
407 continue;
408 }
409 if !matches.is_empty() {
410 match_kind = ReferenceMatch::ScopedHint;
411 }
412 }
413 if matches.len() == 1 {
414 mappings.push(ReferenceMapping::Relocated {
415 old: old_ref.clone(),
416 new: matches[0].3.to_string(),
417 matched_by: match_kind,
418 });
419 relocated += 1;
420 continue;
421 }
422 if matches.len() > 1 {
423 mappings.push(ReferenceMapping::Lost {
424 old: old_ref.clone(),
425 reason: ReferenceLostReason::Ambiguous {
426 candidates: matches
427 .iter()
428 .take(AMBIGUOUS_CANDIDATE_LIMIT)
429 .map(|(role, name, _, reference, _)| CandidateSummary {
430 label: bounded_candidate_label(&format!("{} {}", role, name)),
431 reference: Some((*reference).to_string()),
432 })
433 .collect(),
434 },
435 });
436 lost += 1;
437 continue;
438 }
439 mappings.push(ReferenceMapping::Lost {
440 old: old_ref.clone(),
441 reason: ReferenceLostReason::NotFound,
442 });
443 lost += 1;
444 }
445
446 let mutation_summary = prior_page
447 .as_ref()
448 .map(|prior| MutationSummary {
449 url_changed: prior.url != context.page.url,
450 title_changed: prior.title != context.page.title,
451 revision_delta: to_revision.saturating_sub(from_revision),
452 soft_navigation_suspected: to_revision > from_revision
453 && prior.url == context.page.url
454 && prior.title == context.page.title,
455 })
456 .unwrap_or_default();
457 bounded_reconciliation_outcome(ReconciliationOutcome {
458 status: if route_changed {
459 ReconciliationStatus::RouteChanged
460 } else {
461 ReconciliationStatus::Complete
462 },
463 to_revision,
464 mappings,
465 preserved,
466 relocated,
467 lost,
468 mutation_summary,
469 incomplete: context.incomplete,
470 })
471 }
472
473 pub async fn export_checkpoint(&self) -> BrowserResult<CheckpointV1> {
476 let page = self.page_info().await?;
477 let revision = self.page_revision.load(Ordering::Relaxed);
478
479 let last_refs: Vec<String> = self
480 .observation_cache
481 .lock()
482 .await
483 .as_ref()
484 .map(|cached| {
485 cached
486 .context
487 .accessibility
488 .interactive
489 .iter()
490 .take(8)
491 .map(|c| c.reference.clone())
492 .collect()
493 })
494 .unwrap_or_default();
495
496 let checkpoint = CheckpointV1 {
497 schema_version: 1,
498 glass_version: env!("CARGO_PKG_VERSION").to_string(),
499 exported_at: chrono::Utc::now().to_rfc3339(),
500 profile: self.profile.clone(),
501 attach_mode: self.chrome.is_none(),
502 topology: CheckpointTopology {
503 target_id: Some(page.target_id),
504 frame_id: Some(page.frame_id),
505 url: bounded_checkpoint_text(&page.url, 1024),
506 title: bounded_checkpoint_text(&page.title, 1024),
507 },
508 observation: CheckpointObservation {
509 revision,
510 last_refs,
511 },
512 policy: format!("{:?}", self.policy.preset()).to_lowercase(),
513 };
514 if serde_json::to_vec(&checkpoint)?.len() > 4 * 1024 {
515 return Err("checkpoint exceeds the 4 KiB serialized limit".into());
516 }
517 Ok(checkpoint)
518 }
519
520 pub async fn import_checkpoint(&self, checkpoint: &CheckpointV1) -> BrowserResult<()> {
523 if checkpoint.schema_version != 1 {
524 return Err(CheckpointError::SchemaVersionMismatch {
525 expected: 1,
526 found: checkpoint.schema_version,
527 }
528 .into());
529 }
530
531 if let Some(ref target_id) = checkpoint.topology.target_id {
532 let targets = self.list_targets().await?;
533 if !targets.iter().any(|t| t.id == *target_id) {
534 return Err(CheckpointError::TargetClosed.into());
535 }
536 self.select_target(target_id).await?;
537 }
538
539 if let Some(ref frame_id) = checkpoint.topology.frame_id {
540 let frames = self.list_frames().await?;
541 if !frames.iter().any(|f| f.id == *frame_id) {
542 return Err(CheckpointError::Stale.into());
543 }
544 self.select_frame(frame_id).await?;
545 }
546
547 Ok(())
548 }
549}
550
551fn bounded_reconciliation_outcome(
552 outcome: ReconciliationOutcome,
553) -> BrowserResult<ReconciliationOutcome> {
554 let bytes = serde_json::to_vec(&outcome)?.len();
555 if bytes > MAX_RECONCILIATION_BYTES {
556 return Err(format!(
557 "reconciliation response exceeds {} bytes; retry with fewer refs",
558 MAX_RECONCILIATION_BYTES
559 )
560 .into());
561 }
562 Ok(outcome)
563}
564
565fn locator_matches_compact(
566 locator: &Locator,
567 control: &(&str, &str, i64, &str, &[String]),
568) -> bool {
569 match locator {
570 Locator::AccessibleName(name) => control.1.eq_ignore_ascii_case(name),
571 Locator::RoleAndName { role, name } => {
572 control.0.eq_ignore_ascii_case(role) && control.1.eq_ignore_ascii_case(name)
573 }
574 Locator::Text(text) => control.1.eq_ignore_ascii_case(text),
575 Locator::Reference(reference) => control.3 == reference,
576 Locator::Css(_) | Locator::Ordinal(_) => false,
577 }
578}
579
580fn bounded_checkpoint_text(value: &str, max_bytes: usize) -> String {
581 if value.len() <= max_bytes {
582 return value.to_string();
583 }
584 let mut end = max_bytes;
585 while end > 0 && !value.is_char_boundary(end) {
586 end -= 1;
587 }
588 value[..end].to_string()
589}