⎇ b9067e74319fef46a5d726f0558308e114ee890e
scripts/gentest/src/main.rs
Historia
Quién tocó qué
crudo
Block: return margin-collapsing outputs from vertical axis `ComputeSize` calls (#976)
1use std::borrow::Cow;
2use std::ffi::{OsStr, OsString};
3use std::fmt::Display;
4use std::fs::{self, OpenOptions};
5use std::io::Write as _;
6use std::path::{Component, Path, PathBuf};
7use std::process::Command;
8
9use fantoccini::{Client, ClientBuilder};
10use log::*;
11use serde_json::Value;
12use walkdir::WalkDir;
13use xmlwriter::{Indent, Options, XmlWriter};
14
15#[tokio::main]
16async fn main() {
17 env_logger::init();
18 let root_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
19 let repo_root = root_dir.parent().and_then(Path::parent).unwrap();
20
21 let fixtures_root = repo_root.join("test_fixtures");
22
23 info!("reading test fixtures from disk");
24 let mut fixtures: Vec<_> = WalkDir::new(fixtures_root.clone())
25 .into_iter()
26 .filter_map(|a| a.ok())
27 .filter(|f| !f.path().components().any(|c| c.as_os_str() == OsStr::new("_scratch")))
28 .filter(|f| !f.file_name().to_string_lossy().starts_with('x')) // ignore tests beginning with x
29 .filter(|f| f.path().is_file() && f.path().extension().map(|p| p == "html").unwrap_or(false))
30 .map(|f| {
31 let fixture_path = f.path().to_path_buf();
32 let name = fixture_path.file_stem().unwrap().to_str().unwrap().to_string();
33 (name, fixture_path)
34 })
35 .collect();
36 fixtures.sort_unstable_by_key(|f| f.1.clone());
37
38 info!("starting webdriver instance");
39 let webdriver_url = "http://localhost:4444";
40 let mut webdriver_handle = Command::new("chromedriver")
41 .arg("--port=4444")
42 .spawn()
43 .expect("ChromeDriver not found: Make sure you have it installed and added to your PATH.");
44
45 // this is silly, but it works
46 std::thread::sleep(std::time::Duration::from_secs(1));
47
48 let mut caps = serde_json::map::Map::new();
49 let chrome_opts = serde_json::json!({ "args": ["--headless", "--disable-gpu"] });
50 caps.insert("goog:chromeOptions".to_string(), chrome_opts.clone());
51
52 info!("spawning webdriver client and collecting test descriptions");
53 let client = ClientBuilder::native().capabilities(caps.clone()).connect(webdriver_url).await.unwrap();
54
55 asserts_non_zero_width_scrollbars(client.clone()).await;
56
57 let mut test_descs = vec![];
58 for (name, fixture_path) in fixtures.iter() {
59 test_descs.push(test_root_element(client.clone(), name.clone(), fixture_path).await);
60 }
61
62 info!("killing webdriver instance...");
63 webdriver_handle.kill().unwrap();
64 webdriver_handle.wait().unwrap();
65
66 info!("generating test sources and concatenating...");
67
68 let test_descs: Vec<_> = test_descs
69 .iter()
70 .flat_map(|(name, fixture_path, description)| {
71 debug!("generating test contents for {}", name);
72
73 let border_box_ltr_test =
74 generate_test(format!("{name}__border_box_ltr"), &description["borderBoxLtrData"]);
75 let content_box_ltr_test =
76 generate_test(format!("{name}__content_box_ltr"), &description["contentBoxLtrData"]);
77 let border_box_rtl_test =
78 generate_test(format!("{name}__border_box_rtl"), &description["borderBoxRtlData"]);
79 let content_box_rtl_test =
80 generate_test(format!("{name}__content_box_rtl"), &description["contentBoxRtlData"]);
81
82 [
83 (format!("{name}__border_box_ltr"), fixture_path, border_box_ltr_test),
84 (format!("{name}__content_box_ltr"), fixture_path, content_box_ltr_test),
85 (format!("{name}__border_box_rtl"), fixture_path, border_box_rtl_test),
86 (format!("{name}__content_box_rtl"), fixture_path, content_box_rtl_test),
87 ]
88 })
89 .collect();
90
91 info!("writing generated test file to disk...");
92 let tests_base_path = repo_root.join("tests");
93 let xml_base_path = tests_base_path.join("xml");
94 let _ = fs::remove_dir_all(&xml_base_path);
95 fs::create_dir(&xml_base_path).unwrap();
96
97 let mut mod_file = OpenOptions::new().create(true).append(true).open(xml_base_path.join("mod.rs")).unwrap();
98 writeln!(&mut mod_file, "//! Generated XML tests").unwrap();
99 writeln!(&mut mod_file, "#![allow(non_snake_case)]").unwrap();
100
101 let mut current_module: Option<OsString> = None;
102
103 for (name, fixture_path, test_body) in test_descs {
104 // Create test directory if it doesn't exist
105 let test_path_stripped = fixture_path.parent().unwrap().strip_prefix(&fixtures_root).unwrap();
106 let test_path = xml_base_path.join(test_path_stripped);
107 if !test_path.exists() {
108 fs::create_dir(&test_path).unwrap();
109 }
110
111 let Some(Component::Normal(module)) = test_path_stripped.components().next() else {
112 panic!("unexpected module name")
113 };
114
115 if current_module.as_deref() != Some(module) {
116 if current_module.is_some() {
117 writeln!(&mut mod_file, "}}\n").unwrap();
118 }
119 current_module = Some(module.to_owned());
120 writeln!(&mut mod_file, "mod {} {{", module.display()).unwrap();
121 }
122
123 if name.starts_with("grid") {
124 writeln!(&mut mod_file, r#"#[cfg(feature = "grid")]"#).unwrap();
125 }
126 writeln!(
127 &mut mod_file,
128 "#[test]
129 fn {name} () {{
130 crate::run_xml_test(\"{}\", \"{name}\");
131 }}
132 ",
133 module.display()
134 )
135 .unwrap();
136
137 let mut test_filename = test_path.join(&name);
138 test_filename.set_extension("xml");
139 debug!("writing {} to disk...", name);
140 fs::write(test_filename, test_body).unwrap();
141 }
142
143 if current_module.is_some() {
144 writeln!(&mut mod_file, "}}\n").unwrap();
145 }
146
147 info!("formatting the source directory");
148 Command::new("cargo").arg("fmt").current_dir(repo_root).status().unwrap();
149}
150
151async fn asserts_non_zero_width_scrollbars(client: Client) {
152 // Load minimal test page defined in the string
153 const TEST_PAGE: &str = r#"data:text/html;charset=utf-8,<html><body><div style="overflow:scroll" /></body></html>"#;
154 client.goto(TEST_PAGE).await.unwrap();
155
156 // Determine the width of the scrollbar
157 let scrollbar_width = client
158 .execute("return document.body.firstChild.clientWidth - document.body.firstChild.offsetWidth;", vec![])
159 .await
160 .unwrap();
161 let Value::Number(scrollbar_width) = scrollbar_width else { panic!("Error retrieving scrollbar_width") };
162 let scrollbar_width = scrollbar_width.as_f64().unwrap();
163
164 if scrollbar_width == 0.0 {
165 panic!(concat!(
166 "\n\n Error: Scrollbar width of zero detected. This test generation script must be run with scrollbars set to take up space.\n",
167 " On macOS this can be done by setting Show Scrollbars to 'always' in the Appearance section of the System Settings app.\n\n"
168 ))
169 }
170}
171
172async fn test_root_element(client: Client, name: String, fixture_path: impl AsRef<Path>) -> (String, PathBuf, Value) {
173 let fixture_path = fixture_path.as_ref();
174
175 let url = format!("file://{}", fixture_path.display());
176
177 client.goto(&url).await.unwrap();
178 let description = client.execute("return getTestData()", vec![]).await.unwrap();
179 let description_string = description.as_str().unwrap();
180 let description = serde_json::from_str(description_string).unwrap();
181 (name.to_string(), fixture_path.to_path_buf(), description)
182}
183
184fn generate_test(name: impl AsRef<str>, description: &Value) -> String {
185 let use_rounding = description["useRounding"].as_bool().unwrap();
186
187 let mut w =
188 XmlWriter::new(Options { use_single_quote: false, indent: Indent::Spaces(2), attributes_indent: Indent::None });
189 w.start_element("test");
190 w.write_attribute("name", name.as_ref());
191 w.write_attribute("use-rounding", &use_rounding);
192
193 // Viewport
194 let viewport = &description["viewport"];
195 w.start_element("viewport");
196 w.write_attribute("width", &serialize_dimension(&viewport["width"]).unwrap());
197 w.write_attribute("height", &serialize_dimension(&viewport["height"]).unwrap());
198 w.end_element();
199
200 // Input styles
201 w.start_element("input");
202 generate_node(&mut w, description);
203 w.end_element();
204
205 // Expectations
206 w.start_element("expectations");
207 let use_rounding = description["useRounding"].as_bool().unwrap();
208 generate_assertions(&mut w, description, use_rounding);
209 w.end_element();
210
211 w.end_document()
212}
213
214fn generate_assertions(w: &mut XmlWriter, node: &Value, use_rounding: bool) {
215 let layout = if use_rounding { &node["smartRoundedLayout"] } else { &node["unroundedLayout"] };
216
217 let read_f32 = |s: &str| layout[s].as_f64().unwrap() as f32;
218 let read_naive_f32 = |s: &str| node["naivelyRoundedLayout"][s].as_f64().unwrap() as f32;
219 let scroll_width = (read_f32("scrollWidth") - read_naive_f32("clientWidth")).max(0.0);
220 let scroll_height = (read_f32("scrollHeight") - read_naive_f32("clientHeight")).max(0.0);
221
222 fn is_scrollable(overflow: &Value) -> bool {
223 match overflow {
224 Value::String(ref value) => matches!(value.as_ref(), "hidden" | "scroll" | "auto"),
225 _ => false,
226 }
227 }
228 let is_scroll_container = is_scrollable(&node["style"]["overflowX"]) || is_scrollable(&node["style"]["overflowY"]);
229
230 w.start_element("node");
231
232 w.write_attribute("x", &read_f32("x"));
233 w.write_attribute("y", &read_f32("y"));
234 w.write_attribute("width", &read_f32("width"));
235 w.write_attribute("height", &read_f32("height"));
236
237 if is_scroll_container {
238 w.write_attribute("scroll_width", &scroll_width);
239 w.write_attribute("scroll_height", &scroll_height);
240 }
241
242 if let Value::Array(ref value) = node["children"] {
243 for child in value {
244 generate_assertions(w, child, use_rounding);
245 }
246 };
247
248 w.end_element();
249}
250
251fn generate_node(w: &mut XmlWriter, node: &Value) {
252 let style = &node["style"];
253
254 fn get_string_value(value: &Value) -> Option<&str> {
255 match value {
256 Value::String(ref value) => Some(value),
257 _ => None,
258 }
259 }
260
261 // Handle text/leaf node case
262 let text_content = get_string_value(&node["textContent"]);
263 if text_content.is_some() {
264 w.start_element("text");
265 } else {
266 w.start_element("div");
267 }
268
269 fn maybe_write<T: Display>(w: &mut XmlWriter, name: &str, value: Option<T>) {
270 if let Some(attr) = value {
271 w.write_attribute(name, &attr);
272 }
273 }
274
275 maybe_write(w, "display", get_str_attr(&style["display"], None));
276 maybe_write(w, "box-sizing", get_str_attr(&style["boxSizing"], Some("border-box")));
277 maybe_write(w, "direction", get_str_attr(&style["direction"], None));
278 maybe_write(w, "writing-mode", get_str_attr(&style["writingMode"], None));
279 maybe_write(w, "position", get_str_attr(&style["position"], Some("relative")));
280 maybe_write(w, "float", get_str_attr(&style["cssFloat"], None));
281 maybe_write(w, "clear", get_str_attr(&style["clear"], None));
282 maybe_write(w, "flex-direction", get_str_attr(&style["flexDirection"], Some("row")));
283 maybe_write(w, "flex-wrap", get_str_attr(&style["flexWrap"], Some("nowrap")));
284 maybe_write(w, "overflow-x", get_str_attr(&style["overflowX"], Some("visible")));
285 maybe_write(w, "overflow-y", get_str_attr(&style["overflowY"], Some("visible")));
286
287 let overflow_x = get_str_attr(&style["overflowX"], Some("visible"));
288 let overflow_y = get_str_attr(&style["overflowY"], Some("visible"));
289 if overflow_x.is_some() || overflow_y.is_some() {
290 maybe_write(w, "scrollbar-width", get_num_attr(&style["scrollbarWidth"], None));
291 }
292
293 maybe_write(w, "text-align", get_str_attr(&style["textAlign"], None));
294 maybe_write(w, "align-items", get_str_attr(&style["alignItems"], None));
295 maybe_write(w, "align-self", get_str_attr(&style["alignSelf"], None));
296 maybe_write(w, "justify-items", get_str_attr(&style["justifyItems"], None));
297 maybe_write(w, "justify-self", get_str_attr(&style["justifySelf"], None));
298 maybe_write(w, "align-content", get_str_attr(&style["alignContent"], None));
299 maybe_write(w, "justify-content", get_str_attr(&style["justifyContent"], None));
300
301 maybe_write(w, "flex-grow", get_num_attr(&style["flexGrow"], Some(0.0)));
302 maybe_write(w, "flex-shrink", get_num_attr(&style["flexShrink"], Some(1.0)));
303 maybe_write(w, "flex-basis", get_dim_attr(&style["flexBasis"], Some("auto")));
304
305 maybe_write(w, "width", get_dim_attr(&style["size"]["width"], Some("auto")));
306 maybe_write(w, "height", get_dim_attr(&style["size"]["height"], Some("auto")));
307 maybe_write(w, "min-width", get_dim_attr(&style["minSize"]["width"], Some("auto")));
308 maybe_write(w, "min-height", get_dim_attr(&style["minSize"]["height"], Some("auto")));
309 maybe_write(w, "max-width", get_dim_attr(&style["maxSize"]["width"], Some("auto")));
310 maybe_write(w, "max-height", get_dim_attr(&style["maxSize"]["height"], Some("auto")));
311
312 maybe_write(w, "aspect-ratio", get_num_attr(&style["aspectRatio"], None));
313
314 // TODO: null check in no gap case
315 maybe_write(w, "row-gap", get_dim_attr(&style["gap"]["row"], None));
316 maybe_write(w, "column-gap", get_dim_attr(&style["gap"]["column"], None));
317
318 maybe_write(w, "margin-top", get_dim_attr(&style["margin"]["top"], None));
319 maybe_write(w, "margin-left", get_dim_attr(&style["margin"]["left"], None));
320 maybe_write(w, "margin-bottom", get_dim_attr(&style["margin"]["bottom"], None));
321 maybe_write(w, "margin-right", get_dim_attr(&style["margin"]["right"], None));
322
323 maybe_write(w, "padding-top", get_dim_attr(&style["padding"]["top"], None));
324 maybe_write(w, "padding-left", get_dim_attr(&style["padding"]["left"], None));
325 maybe_write(w, "padding-bottom", get_dim_attr(&style["padding"]["bottom"], None));
326 maybe_write(w, "padding-right", get_dim_attr(&style["padding"]["right"], None));
327
328 maybe_write(w, "border-top", get_dim_attr(&style["border"]["top"], None));
329 maybe_write(w, "border-left", get_dim_attr(&style["border"]["left"], None));
330 maybe_write(w, "border-bottom", get_dim_attr(&style["border"]["bottom"], None));
331 maybe_write(w, "border-right", get_dim_attr(&style["border"]["right"], None));
332
333 maybe_write(w, "top", get_dim_attr(&style["inset"]["top"], None));
334 maybe_write(w, "left", get_dim_attr(&style["inset"]["left"], None));
335 maybe_write(w, "bottom", get_dim_attr(&style["inset"]["bottom"], None));
336 maybe_write(w, "right", get_dim_attr(&style["inset"]["right"], None));
337
338 maybe_write(w, "grid-auto-flow", serialize_grid_auto_flow(&style["gridAutoFlow"]));
339 maybe_write(w, "grid-template-rows", serialize_array(&style["gridTemplateRows"], ' ', serialize_track_definition));
340 maybe_write(
341 w,
342 "grid-template-columns",
343 serialize_array(&style["gridTemplateColumns"], ' ', serialize_track_definition),
344 );
345 maybe_write(w, "grid-auto-rows", serialize_array(&style["gridAutoRows"], ' ', serialize_track_definition));
346 maybe_write(w, "grid-auto-columns", serialize_array(&style["gridAutoColumns"], ' ', serialize_track_definition));
347
348 maybe_write(w, "grid-row-start", serialize_grid_position(&style["gridRowStart"]));
349 maybe_write(w, "grid-row-end", serialize_grid_position(&style["gridRowEnd"]));
350 maybe_write(w, "grid-column-start", serialize_grid_position(&style["gridColumnStart"]));
351 maybe_write(w, "grid-column-end", serialize_grid_position(&style["gridColumnEnd"]));
352
353 // Recurse into children
354 if let Value::Array(ref value) = node["children"] {
355 for child_desc in value {
356 generate_node(w, child_desc);
357 }
358 };
359
360 if let Some(text_content) = text_content {
361 w.write_text(text_content.trim());
362 }
363
364 w.end_element();
365}
366
367fn get_str_attr<'a>(value: &'a Value, elide_if: Option<&str>) -> Option<&'a str> {
368 if let Value::String(ref value) = value {
369 if Some(value.as_str()) != elide_if {
370 return Some(value.as_str());
371 }
372 }
373
374 None
375}
376
377fn get_num_attr(value: &Value, elide_if: Option<f64>) -> Option<f64> {
378 if let Value::Number(ref value) = value {
379 if let Some(num) = value.as_f64() {
380 if Some(num) != elide_if {
381 return Some(num);
382 }
383 }
384 }
385
386 None
387}
388
389fn get_dim_attr(value: &Value, elide_if: Option<&str>) -> Option<Cow<'static, str>> {
390 if let Some(attr) = serialize_dimension(value) {
391 if Some(attr.as_ref()) != elide_if {
392 return Some(attr);
393 }
394 }
395 None
396}
397
398fn serialize_dimension(obj: &serde_json::Value) -> Option<Cow<'static, str>> {
399 if let Value::Object(ref dimen) = &obj {
400 let unit = dimen.get("unit").unwrap();
401 let value = dimen.get("value").and_then(|v| v.as_f64());
402 match unit {
403 Value::String(ref unit) => {
404 return Some(match unit.as_str() {
405 "auto" => Cow::from("auto"),
406 "max-content" => Cow::from("max-content"),
407 "min-content" => Cow::from("min-content"),
408 "px" => Cow::from(format!("{}px", value.expect("Expected value for px unit"))),
409 "percent" => Cow::from(format!("{}%", value.expect("Expected value for % unit") * 100.0)),
410 "fraction" => Cow::from(format!("{}fr", value.expect("Expected value for fr unit"))),
411 _ => unreachable!(),
412 })
413 }
414 _ => panic!("Tried to serialize dimension object, but unit was not a string"),
415 }
416 };
417
418 None
419}
420
421fn serialize_grid_auto_flow(obj: &serde_json::Value) -> Option<Cow<'static, str>> {
422 if let Value::Object(ref auto_flow) = &obj {
423 let direction = auto_flow.get("direction").unwrap().as_str().unwrap();
424 let algorithm = auto_flow.get("algorithm").unwrap().as_str().unwrap();
425
426 let value = match (direction, algorithm) {
427 ("row", "sparse") => "row",
428 ("column", "sparse") => "column",
429 ("row", "dense") => "row dense",
430 ("column", "dense") => "column dense",
431 _ => unreachable!(),
432 };
433
434 return Some(Cow::from(value));
435 }
436 None
437}
438
439fn serialize_grid_position(grid_position: &serde_json::Value) -> Option<Cow<'static, str>> {
440 if let Value::Object(ref grid_position) = &grid_position {
441 let kind = grid_position.get("kind").unwrap();
442 let value = || grid_position.get("value").unwrap().as_f64().unwrap() as f32;
443
444 return match kind {
445 Value::String(ref kind) => match kind.as_ref() {
446 "auto" => None, //Some(Cow::from("auto")),
447 "span" => Some(Cow::from(format!("span {}", value()))),
448 "line" => Some(Cow::from((value() as i32).to_string())),
449 _ => unreachable!(),
450 },
451 _ => unreachable!(),
452 };
453 }
454 None
455}
456
457fn serialize_array(value: &Value, sep: char, quoter: impl Fn(&Value) -> Option<Cow<'static, str>>) -> Option<String> {
458 match &value {
459 Value::Array(ref values) => serialize_value_list(values, sep, quoter),
460 _ => None,
461 }
462}
463
464fn serialize_value_list(
465 values: &[Value],
466 sep: char,
467 quoter: impl Fn(&Value) -> Option<Cow<'static, str>>,
468) -> Option<String> {
469 let mut out = String::new();
470 for item in values {
471 out.push_str(quoter(item)?.as_ref());
472 out.push(sep);
473 }
474 out.pop();
475 Some(out)
476}
477
478fn serialize_track_definition(track_definition: &serde_json::Value) -> Option<Cow<'static, str>> {
479 let serde_json::Value::Object(map) = track_definition else {
480 return None;
481 };
482
483 let kind = map.get("kind").unwrap().as_str().unwrap();
484 let name = || map.get("name").unwrap().as_str().unwrap();
485 let arguments = || map.get("arguments").unwrap();
486
487 match kind {
488 "scalar" => serialize_dimension(track_definition),
489 "function" => match (name(), arguments()) {
490 ("fit-content", Value::Array(arguments)) => {
491 if arguments.len() != 1 {
492 panic!("fit-content function with the wrong number of arguments");
493 }
494 let limit = serialize_dimension(&arguments[0])?;
495 Some(Cow::from(format!("fit-content({limit})")))
496 }
497 ("minmax", Value::Array(arguments)) => {
498 if arguments.len() != 2 {
499 panic!("minmax function with the wrong number of arguments");
500 }
501 let min = serialize_dimension(&arguments[0])?;
502 let max = serialize_dimension(&arguments[1])?;
503
504 Some(Cow::from(format!("minmax({min},{max})")))
505 }
506 ("repeat", Value::Array(arguments)) => {
507 if arguments.len() < 2 {
508 panic!("repeat function with the wrong number of arguments");
509 }
510 let repetition = match arguments[0] {
511 Value::Object(ref arg) => {
512 let unit = arg.get("unit").unwrap().as_str().unwrap();
513 let value = || arg.get("value").unwrap().as_u64().unwrap() as u16;
514
515 match unit {
516 "auto-fill" => Cow::from("auto-fill"),
517 "auto-fit" => Cow::from("auto-fit"),
518 "integer" => {
519 let repetition_count = value();
520 Cow::from(repetition_count.to_string())
521 }
522 _ => unreachable!(),
523 }
524 }
525 _ => unreachable!(),
526 };
527 let track_list = serialize_value_list(&arguments[1..], ' ', serialize_track_definition)?;
528 Some(Cow::from(format!("repeat({repetition}, {track_list})")))
529 }
530 // TODO: Add support for fit-content
531 _ => unreachable!(),
532 },
533 _ => unreachable!(),
534 }
535}