browser_commander/browser/parity/
comparison.rs1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::fingerprint::find_fingerprint_limitation;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ProbeDifference {
13 pub path: String,
15 pub reference: Value,
17 pub candidate: Value,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct ParityDifference {
24 pub path: String,
26 pub expected: Value,
28 pub actual: Value,
30 pub limitation: Option<String>,
32 pub requested: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct FeatureComparison {
39 pub reference: Vec<String>,
41 pub candidate: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ChangedSwitch {
48 pub name: String,
50 pub reference: Option<String>,
52 pub candidate: Option<String>,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct CommandLineComparison {
59 pub extra: Vec<String>,
61 pub missing: Vec<String>,
63 pub changed: Vec<ChangedSwitch>,
65 pub attachment: Vec<String>,
67 pub features: BTreeMap<String, FeatureComparison>,
69}
70
71#[derive(Debug, Clone)]
73pub struct ParityContext {
74 pub launch: String,
76 pub attached: bool,
78 pub extra_switches: Vec<String>,
80 pub requested_args: Vec<String>,
82}
83
84impl Default for ParityContext {
85 fn default() -> Self {
86 Self {
87 launch: "real".into(),
88 attached: false,
89 extra_switches: Vec::new(),
90 requested_args: Vec::new(),
91 }
92 }
93}
94
95pub fn parse_switch_args(args: &[String]) -> BTreeMap<String, Option<String>> {
97 args.iter()
98 .filter(|arg| arg.starts_with("--"))
99 .map(|arg| match arg.split_once('=') {
100 Some((name, value)) => (name.into(), Some(value.into())),
101 None => (arg.clone(), None),
102 })
103 .collect()
104}
105
106pub fn parse_switches(command_line: &str) -> BTreeMap<String, Option<String>> {
109 let mut words: Vec<&str> = command_line.split_whitespace().collect();
110 if words.len() > 1
111 && words
112 .last()
113 .is_some_and(|word| !word.starts_with("--") && url::Url::parse(word).is_ok())
114 {
115 words.pop();
116 }
117 let mut args: Vec<String> = Vec::new();
118 for word in words {
119 if word.starts_with("--") || args.is_empty() {
120 args.push(word.into());
121 } else if let Some(last) = args.last_mut() {
122 last.push(' ');
123 last.push_str(word);
124 }
125 }
126 parse_switch_args(&args)
127}
128
129fn feature_list(value: Option<&Option<String>>) -> Vec<String> {
130 let mut features: Vec<String> = value
131 .and_then(Option::as_deref)
132 .unwrap_or("")
133 .split(',')
134 .map(str::trim)
135 .filter(|entry| !entry.is_empty())
136 .map(str::to_owned)
137 .collect();
138 features.sort();
139 features
140}
141
142fn formatted(name: &str, value: &Option<String>) -> String {
143 match value {
144 Some(value) => format!("{name}={value}"),
145 None => name.into(),
146 }
147}
148
149pub fn compare_command_lines(reference: &str, candidate: &str) -> CommandLineComparison {
151 let left = parse_switches(reference);
152 let right = parse_switches(candidate);
153 let mut comparison = CommandLineComparison::default();
154 for name in left.keys().chain(right.keys()).collect::<BTreeSet<_>>() {
155 if matches!(
156 name.as_str(),
157 "--flag-switches-begin" | "--flag-switches-end"
158 ) {
159 continue;
160 }
161 if name == "--remote-debugging-port" {
162 if let Some(value) = right.get(name) {
163 comparison
164 .attachment
165 .push(format!("{name}={}", value.as_deref().unwrap_or("null")));
166 }
167 continue;
168 }
169 match (left.get(name), right.get(name)) {
170 (None, Some(value)) => comparison.extra.push(formatted(name, value)),
171 (Some(value), None) => comparison.missing.push(formatted(name, value)),
172 (Some(a), Some(b)) if name != "--user-data-dir" && a != b => {
173 comparison.changed.push(ChangedSwitch {
174 name: name.clone(),
175 reference: a.clone(),
176 candidate: b.clone(),
177 });
178 }
179 _ => {}
180 }
181 if matches!(
182 name.as_str(),
183 "--enable-features"
184 | "--disable-features"
185 | "--enable-blink-features"
186 | "--disable-blink-features"
187 ) {
188 comparison.features.insert(
189 name.clone(),
190 FeatureComparison {
191 reference: feature_list(left.get(name)),
192 candidate: feature_list(right.get(name)),
193 },
194 );
195 }
196 }
197 comparison
198}
199
200pub(super) fn command_line_differences(comparison: &CommandLineComparison) -> Vec<ProbeDifference> {
201 let mut differences = Vec::new();
202 for (kind, entries) in [
203 ("extra", &comparison.extra),
204 ("missing", &comparison.missing),
205 ] {
206 for entry in entries {
207 let value = Value::String(entry.clone());
208 differences.push(ProbeDifference {
209 path: format!(
210 "commandLine.{kind}.{}",
211 entry.split('=').next().unwrap_or(entry)
212 ),
213 reference: if kind == "missing" {
214 value.clone()
215 } else {
216 Value::Null
217 },
218 candidate: if kind == "extra" { value } else { Value::Null },
219 });
220 }
221 }
222 differences.extend(comparison.changed.iter().map(|switch| ProbeDifference {
223 path: format!("commandLine.changed.{}", switch.name),
224 reference: switch.reference.clone().map_or(Value::Null, Value::String),
225 candidate: switch.candidate.clone().map_or(Value::Null, Value::String),
226 }));
227 differences
228}
229
230fn ignored(path: &str) -> bool {
231 matches!(
232 path,
233 "window.innerWidth"
234 | "window.innerHeight"
235 | "window.outerWidth"
236 | "window.outerHeight"
237 | "window.screenX"
238 | "window.screenY"
239 | "window.screenLeft"
240 | "window.screenTop"
241 | "document.referrer"
242 | "document.hasFocus"
243 | "document.bodyClientHeightIsPositive"
244 | "connection.downlink"
245 | "connection.rtt"
246 ) || path.starts_with("viewportRelation.")
247 || path.starts_with("probeErrors.")
248}
249
250pub fn diff_reports(reference: &Value, candidate: &Value) -> Vec<ProbeDifference> {
253 fn walk(
254 left: Option<&Value>,
255 right: Option<&Value>,
256 path: &str,
257 output: &mut Vec<ProbeDifference>,
258 ) {
259 if ignored(path) {
260 return;
261 }
262 if let (Some(Value::Object(a)), Some(Value::Object(b))) = (left, right) {
263 for key in a.keys().chain(b.keys()).collect::<BTreeSet<_>>() {
264 let trail = if path.is_empty() {
265 key.clone()
266 } else {
267 format!("{path}.{key}")
268 };
269 walk(a.get(key), b.get(key), &trail, output);
270 }
271 } else if left != right {
272 output.push(ProbeDifference {
273 path: path.into(),
274 reference: left.cloned().unwrap_or(Value::Null),
275 candidate: right.cloned().unwrap_or(Value::Null),
276 });
277 }
278 }
279 let mut output = Vec::new();
280 walk(Some(reference), Some(candidate), "", &mut output);
281 output
282}
283
284pub fn classify_differences(
286 differences: &[ProbeDifference],
287 context: &ParityContext,
288) -> (Vec<ParityDifference>, Vec<ParityDifference>) {
289 let tagged: Vec<ParityDifference> = differences
290 .iter()
291 .map(|difference| {
292 let command_line = difference.path.starts_with("commandLine.");
293 let requested = command_line
294 && difference
295 .candidate
296 .as_str()
297 .or(difference.reference.as_str())
298 .is_some_and(|value| context.requested_args.iter().any(|arg| arg == value));
299 let feature_config = context.extra_switches.iter().any(|arg| {
300 matches!(
301 arg.split('=').next(),
302 Some(
303 "--disable-field-trial-config" | "--disable-features" | "--enable-features"
304 )
305 )
306 });
307 let limitation = if requested {
308 None
309 } else if context.launch == "engine"
310 && (command_line
311 || (feature_config
312 && (difference.path.ends_with(".keys")
313 || difference.path.ends_with(".languages"))))
314 {
315 Some("engine-launch-switches")
316 } else if difference
317 .path
318 .starts_with("navigator.userAgentData.brands")
319 {
320 Some("grease-brand-not-reproduced")
321 } else if difference.path == "navigator.webdriver" && context.attached {
322 Some("automation-controlled-is-launch-only")
323 } else {
324 None
325 };
326 ParityDifference {
327 path: difference.path.clone(),
328 expected: difference.reference.clone(),
329 actual: difference.candidate.clone(),
330 limitation: limitation
331 .filter(|id| find_fingerprint_limitation(id).is_some())
332 .map(str::to_owned),
333 requested,
334 }
335 })
336 .collect();
337 let unlisted = tagged
338 .iter()
339 .filter(|entry| entry.limitation.is_none() && !entry.requested)
340 .cloned()
341 .collect();
342 (tagged, unlisted)
343}