1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use super::{html, tests, Origin};
21use crate::error::{Error, Result};
22
23pub const RESULTS_SCHEMA: &str = "ui-audit/v1";
25
26pub const SECTION: &str = "ui";
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct Element {
36 pub selector: String,
38 #[serde(flatten)]
40 pub measured: BTreeMap<String, Value>,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct Finding {
46 pub route: String,
48 pub width: u32,
50 #[serde(default)]
52 pub tier: String,
53 pub rule: String,
55 pub detail: String,
57 #[serde(default)]
59 pub elements: Vec<Element>,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct Surface {
82 pub id: String,
84 pub mount: String,
86 #[serde(default)]
88 pub kind: String,
89 #[serde(default)]
91 pub routes: usize,
92 #[serde(default)]
94 pub families: usize,
95 #[serde(default)]
97 pub sampled: usize,
98 #[serde(default)]
100 pub truncated: bool,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct Foreign {
122 #[serde(default)]
124 pub route: String,
125 #[serde(default)]
127 pub selector: String,
128 #[serde(default)]
130 pub declares: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct Run {
136 pub schema: String,
138 #[serde(default)]
140 pub origin: String,
141 #[serde(default)]
143 pub complete: bool,
144 #[serde(default)]
146 pub source: BTreeMap<String, String>,
147 #[serde(default)]
149 pub breakpoints: Vec<u32>,
150 #[serde(default)]
152 pub viewports: Vec<u32>,
153 #[serde(default)]
155 pub surfaces: Vec<Surface>,
156 #[serde(default)]
158 pub foreign: Vec<Foreign>,
159 pub pages: usize,
161 pub visits: usize,
163 #[serde(default)]
165 pub seconds: u64,
166 pub findings: Vec<Finding>,
168}
169
170impl Run {
171 pub fn green(&self) -> bool {
173 self.findings.is_empty()
174 }
175
176 pub fn by_rule(&self) -> Vec<(String, usize)> {
190 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
191 for finding in &self.findings {
192 *counts.entry(finding.rule.as_str()).or_default() += 1;
193 }
194 let mut ordered: Vec<(String, usize)> = counts
195 .into_iter()
196 .map(|(k, v)| (k.to_string(), v))
197 .collect();
198 ordered.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
199 ordered
200 }
201
202 pub fn routes_of(&self, rule: &str) -> Vec<&str> {
204 let mut routes: Vec<&str> = self
205 .findings
206 .iter()
207 .filter(|f| f.rule == rule)
208 .map(|f| f.route.as_str())
209 .collect();
210 routes.sort_unstable();
211 routes.dedup();
212 routes
213 }
214}
215
216pub fn parse(text: &str) -> Result<Run> {
218 let run: Run = serde_json::from_str(text).map_err(|e| Error::InvalidSurface {
219 surface: "ui".into(),
220 reason: format!(
221 "the results document does not parse: {e}; the contract is {RESULTS_SCHEMA}"
222 ),
223 })?;
224 if run.schema != RESULTS_SCHEMA {
225 return Err(Error::InvalidSurface {
226 surface: "ui".into(),
227 reason: format!(
228 "the results document declares schema '{}', and this executable reads {RESULTS_SCHEMA}",
229 run.schema
230 ),
231 });
232 }
233 Ok(run)
234}
235
236pub fn read(path: &Path) -> Result<Run> {
238 let text =
239 std::fs::read_to_string(path).map_err(|e| Error::io(path.display().to_string(), e))?;
240 parse(&text)
241}
242
243pub fn render(root: &Path, run: &Run) -> Result<PathBuf> {
247 let surface = super::declare_if_absent(
250 root,
251 tests::SURFACE_ID,
252 tests::SURFACE_TITLE,
253 tests::SURFACE_PRODUCER,
254 )?;
255 let dir = surface.join(SECTION);
256 std::fs::create_dir_all(&dir).map_err(|e| Error::io(dir.display().to_string(), e))?;
257 let origin = Origin::read(root);
258 super::write_json(&dir, "results.json", run)?;
259
260 let mut summary = vec![
261 ("pages", run.pages.to_string()),
262 ("visits", run.visits.to_string()),
263 ("findings", run.findings.len().to_string()),
264 ("rules broken", run.by_rule().len().to_string()),
265 ("seconds", run.seconds.to_string()),
266 ];
267 if !run.viewports.is_empty() {
268 summary.push(("widths", run.viewports.len().to_string()));
269 }
270
271 let mut rule_rows: Vec<Vec<String>> = Vec::new();
272 for (rule, count) in run.by_rule() {
273 let routes = run.routes_of(&rule);
274 let example = routes.first().copied().unwrap_or("");
275 rule_rows.push(vec![
276 format!("<span class=\"mono\">{}</span>", html::escape(&rule)),
277 format!("<span class=\"num\">{count}</span>"),
278 format!("<span class=\"num\">{}</span>", routes.len()),
279 format!("<span class=\"mono\">{}</span>", html::escape(example)),
280 ]);
281 }
282
283 let mut finding_rows: Vec<Vec<String>> = Vec::new();
284 for finding in &run.findings {
285 let elements = finding
286 .elements
287 .iter()
288 .take(3)
289 .map(|e| html::escape(&e.selector))
290 .collect::<Vec<_>>()
291 .join("<br>");
292 finding_rows.push(vec![
293 format!(
294 "<span class=\"mono\">{}</span>",
295 html::escape(&finding.route)
296 ),
297 format!("<span class=\"num\">{}</span>", finding.width),
298 format!(
299 "<span class=\"mono\">{}</span>",
300 html::escape(&finding.rule)
301 ),
302 html::escape(&finding.detail),
303 format!("<span class=\"mono\">{elements}</span>"),
304 ]);
305 }
306
307 let verdict = if run.green() {
308 "Every page the audit visited satisfies every invariant it checks."
309 } else {
310 "The audit found conformance failures; every one is named below, with the page, the \
311 width and the element."
312 };
313 let scope = if run.complete {
314 String::new()
315 } else {
316 "<p class=\"lede\"><strong>This run was narrowed</strong> — it visited part of the \
317 site, not all of it, so it cannot stand for a clean audit.</p>"
318 .to_string()
319 };
320 let provenance = format!(
321 "<h2>Where the target set came from</h2><ul><li>pages: {}</li><li>widths: {}</li>\
322 <li>breakpoints: <span class=\"mono\">{}</span></li>\
323 <li>widths visited: <span class=\"mono\">{}</span></li></ul>\
324 <p class=\"lede\">Nothing in this run names a page or a width. Add a page to the \
325 site and it is audited; change a breakpoint in the theme and the widths follow.</p>",
326 html::escape(
327 run.source
328 .get("pages")
329 .map(String::as_str)
330 .unwrap_or("the built site")
331 ),
332 html::escape(
333 run.source
334 .get("viewports")
335 .map(String::as_str)
336 .unwrap_or("the compiled stylesheet")
337 ),
338 run.breakpoints
339 .iter()
340 .map(u32::to_string)
341 .collect::<Vec<_>>()
342 .join(", "),
343 run.viewports
344 .iter()
345 .map(u32::to_string)
346 .collect::<Vec<_>>()
347 .join(", "),
348 );
349
350 let surfaces_section = if run.surfaces.is_empty() {
355 String::new()
356 } else {
357 let rows: Vec<Vec<String>> = run
358 .surfaces
359 .iter()
360 .map(|surface| {
361 let native = surface.kind == "native-route";
362 let found = if !native {
363 "its directory and its sitemap".to_string()
364 } else if surface.routes == 0 {
365 "no pages: the mount is not a document".to_string()
366 } else {
367 format!(
368 "{} route(s) in {} family(ies), crawled from its own anchors{}",
369 surface.routes,
370 surface.families,
371 if surface.truncated {
372 ", and the crawl hit its budget, so that is a floor"
373 } else {
374 ""
375 }
376 )
377 };
378 vec![
379 format!("<span class=\"mono\">{}</span>", html::escape(&surface.id)),
380 format!(
381 "<span class=\"mono\">{}</span>",
382 html::escape(&surface.mount)
383 ),
384 format!(
385 "<span class=\"mono\">{}</span>",
386 html::escape(&surface.kind)
387 ),
388 if native {
389 format!("<span class=\"num\">{}</span>", surface.sampled)
390 } else {
391 "every page".to_string()
392 },
393 html::escape(&found),
394 ]
395 })
396 .collect();
397 format!(
398 "<h2>The surfaces this run measured</h2>{}",
399 html::table(&["surface", "mount", "kind", "visited", "found by"], &rows)
400 )
401 };
402
403 let foreign_section = if run.foreign.is_empty() {
406 String::new()
407 } else {
408 let rows: Vec<Vec<String>> = run
409 .foreign
410 .iter()
411 .map(|subtree| {
412 vec![
413 format!(
414 "<span class=\"mono\">{}</span>",
415 html::escape(&subtree.declares)
416 ),
417 format!(
418 "<span class=\"mono\">{}</span>",
419 html::escape(&subtree.selector)
420 ),
421 format!(
422 "<span class=\"mono\">{}</span>",
423 html::escape(&subtree.route)
424 ),
425 ]
426 })
427 .collect();
428 format!(
429 "<h2>Not measured, and why</h2><p class=\"lede\">A page may declare a subtree \
430 that is a third party's to answer for. The accessibility engine skipped these; \
431 everything around them was measured as usual.</p>{}",
432 html::table(&["declared as", "element", "first seen on"], &rows)
433 )
434 };
435
436 let findings_section = if run.green() {
437 String::new()
438 } else {
439 format!(
440 "<h2>By rule</h2>{}<h2>Every finding</h2>{}",
441 html::table(&["rule", "findings", "pages", "first page"], &rule_rows),
442 html::table(
443 &["page", "width", "rule", "detail", "elements"],
444 &finding_rows
445 ),
446 )
447 };
448
449 let body = format!(
450 "{}{}{}{}{}{}<p><a href=\"../\">The test run this belongs to</a></p>{}",
451 scope,
452 html::summary(&summary),
453 surfaces_section,
454 foreign_section,
455 findings_section,
456 provenance,
457 html::origin(&origin, &[("results.json", "results.json")])
458 );
459 let page = html::page("UI conformance", verdict, &body);
460 super::write(&dir, "index.html", &page)?;
461 Ok(dir)
462}
463
464#[cfg(test)]
465mod unit {
466 use super::*;
467
468 const RUN: &str = r#"{
469 "schema": "ui-audit/v1", "origin": "http://127.0.0.1:1", "complete": true,
470 "source": {"pages": "the built site", "viewports": "the stylesheet"},
471 "breakpoints": [640, 768], "viewports": [320, 639, 640, 1440],
472 "pages": 2, "visits": 4, "seconds": 7,
473 "findings": [
474 {"route": "/", "width": 320, "tier": "sweep",
475 "rule": "responsive.horizontal-overflow", "detail": "the document is 420px wide",
476 "elements": [{"selector": "pre.code", "right": 420, "width": 400}]}
477 ]
478 }"#;
479
480 #[test]
481 fn a_document_from_another_contract_is_refused_rather_than_guessed() {
482 let err = parse(r#"{"schema":"ui-audit/v2","pages":0,"visits":0,"findings":[]}"#)
483 .unwrap_err()
484 .to_string();
485 assert!(err.contains("ui-audit/v1"), "{err}");
486 }
487
488 #[test]
489 fn what_a_rule_measured_survives_the_round_trip() {
490 let run = parse(RUN).unwrap();
491 let element = &run.findings[0].elements[0];
492 assert_eq!(element.selector, "pre.code");
493 assert_eq!(element.measured.get("right").unwrap().as_i64(), Some(420));
494 }
495
496 #[test]
497 fn the_report_is_a_section_of_the_test_surface_and_declares_no_surface_of_its_own() {
498 let tmp = tempfile::tempdir().unwrap();
499 let run = parse(RUN).unwrap();
500 let dir = render(tmp.path(), &run).unwrap();
501 assert!(dir.ends_with("tests/ui"));
502 assert!(dir.join("index.html").is_file());
503 assert!(dir.join("results.json").is_file());
504 assert!(
505 !dir.join(crate::web::discover::DECLARATION_FILE).is_file(),
506 "a section of another surface declares nothing of its own"
507 );
508 let surfaces = crate::web::discover::generated(tmp.path()).unwrap();
509 assert_eq!(surfaces.len(), 1, "one surface, not two: {surfaces:?}");
510 assert_eq!(surfaces[0].mount.as_str(), "/tests");
511 }
512
513 #[test]
514 fn rendering_the_section_never_overwrites_the_enclosing_report() {
515 let tmp = tempfile::tempdir().unwrap();
516 let suite = tests::parse_cases("a\tok\t1\tparallel\n").unwrap();
517 tests::render(tmp.path(), &suite).unwrap();
518 let before = std::fs::read_to_string(
519 super::super::directory(tmp.path(), "tests").join("surface.json"),
520 )
521 .unwrap();
522 render(tmp.path(), &parse(RUN).unwrap()).unwrap();
523 let after = std::fs::read_to_string(
524 super::super::directory(tmp.path(), "tests").join("surface.json"),
525 )
526 .unwrap();
527 assert_eq!(before, after);
528 }
529
530 #[test]
531 fn a_narrowed_run_says_so_on_the_page() {
532 let tmp = tempfile::tempdir().unwrap();
533 let mut run = parse(RUN).unwrap();
534 run.complete = false;
535 let dir = render(tmp.path(), &run).unwrap();
536 let page = std::fs::read_to_string(dir.join("index.html")).unwrap();
537 assert!(page.contains("narrowed"), "{page}");
538 }
539
540 #[test]
541 fn a_clean_run_says_so_and_lists_nothing() {
542 let tmp = tempfile::tempdir().unwrap();
543 let mut run = parse(RUN).unwrap();
544 run.findings.clear();
545 let dir = render(tmp.path(), &run).unwrap();
546 let page = std::fs::read_to_string(dir.join("index.html")).unwrap();
547 assert!(page.contains("satisfies every invariant"), "{page}");
548 assert!(!page.contains("Every finding"), "{page}");
549 }
550}