use std::io;
use camino::Utf8Path;
use crate::Result;
use crate::elements::Report;
use crate::error::error;
const VIEWER: &str = include_str!("vendor/mutation-test-elements.js.vendored");
pub const VIEWER_VERSION: &str = include_str!("vendor/VERSION").trim_ascii();
pub fn render(report: &Report) -> Result<String> {
let mut page = Vec::new();
stream(report, &mut page).map_err(|cause| error!("could not serialize the report").caused_by(cause))?;
String::from_utf8(page).map_err(|cause| error!("could not serialize the report").caused_by(cause))
}
pub fn write_page(report: &Report, path: &Utf8Path) -> Result<()> {
crate::elements::write_streamed(path, |writer| stream(report, writer))
}
fn stream(report: &Report, writer: &mut dyn io::Write) -> io::Result<()> {
write!(
writer,
"<!DOCTYPE html>\n\
<html lang=\"en\">\n\
<head>\n\
<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>cargo-gamma mutation report</title>\n\
<style>{PAGE_STYLE}</style>\n"
)?;
write!(writer, "<script>{VIEWER}</script>")?;
write!(
writer,
"\n\
</head>\n\
<body>\n\
<mutation-test-report-app title-postfix=\"cargo-gamma\">\n\
Your browser does not support custom elements, which this report is built from.\n\
</mutation-test-report-app>\n\
<script>\n\
const app = document.querySelector('mutation-test-report-app');\n\
{THEME_SCRIPT}\n\
app.report = "
)?;
{
let mut escaping = EscapeScript { inner: &mut *writer };
serde_json::to_writer(&mut escaping, report).map_err(io::Error::from)?;
}
write!(writer, ";\n</script>\n</body>\n</html>\n")
}
const PAGE_STYLE: &str = "\
:root { color-scheme: light dark; }\
html, body { margin: 0; padding: 0; }\
body { background-color: #fff; }\
@media (prefers-color-scheme: dark) { body { background-color: #18181b; } }\
body:has(mutation-test-report-app[theme=\"dark\"]) { background-color: #18181b; }\
body:has(mutation-test-report-app[theme=\"light\"]) { background-color: #fff; }";
const THEME_SCRIPT: &str = "\
app.addEventListener('theme-changed', (event) => {\
const color = event.detail.themeBackgroundColor;\
if (color) { document.body.style.backgroundColor = color; }\
});";
struct EscapeScript<W> {
inner: W,
}
impl<W: io::Write> io::Write for EscapeScript<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut start = 0;
for (index, &byte) in buf.iter().enumerate() {
let escape: &[u8] = match byte {
b'<' => b"\\u003c",
b'>' => b"\\u003e",
_ => continue,
};
self.inner.write_all(&buf[start..index])?;
self.inner.write_all(escape)?;
start = index + 1;
}
self.inner.write_all(&buf[start..])?;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use serde_json::Value;
use super::*;
use crate::fixtures;
fn report() -> Report {
fixtures::report()
}
#[test]
fn the_inline_page_carries_the_whole_viewer() {
let page = render(&report()).expect("renders");
assert!(page.contains("<mutation-test-report-app"), "the custom element is missing");
assert!(page.len() > VIEWER.len(), "the viewer was not inlined");
assert!(!page.contains("cdn.jsdelivr.net"), "the offline report must not reference a CDN");
}
#[test]
fn the_page_loads_no_code_from_anywhere_else() {
let page = render(&report()).expect("renders");
let head = page.split("app.report =").next().expect("the page has a prefix");
assert!(!head.contains("<script src"), "the viewer must not be fetched");
assert!(!head.contains("<link "), "the page must not fetch a stylesheet");
}
#[test]
fn the_vendored_viewer_records_its_upstream_version() {
assert!(!VIEWER_VERSION.is_empty(), "the vendored viewer has no recorded version");
assert!(
VIEWER_VERSION
.chars()
.all(|character| character.is_ascii_digit() || character == '.'),
"`{VIEWER_VERSION}` is not a bare version number"
);
}
#[test]
fn the_page_follows_the_theme_the_viewer_settled_on() {
let page = render(&report()).expect("renders");
assert!(page.contains("color-scheme: light dark"), "{page}");
assert!(page.contains("prefers-color-scheme: dark"), "{page}");
assert!(page.contains("theme-changed"), "{page}");
}
#[test]
fn the_theme_listener_is_registered_before_the_report_starts_the_update_cycle() {
let page = render(&report()).expect("renders");
let listener = page.find("addEventListener").expect("the listener is present");
let assignment = page.find("app.report =").expect("the report is assigned");
assert!(listener < assignment, "{page}");
}
#[test]
fn a_closing_script_tag_in_the_source_cannot_break_out() {
let mut subject = report();
let _ = subject.files.insert(
"src/lib.rs".to_owned(),
crate::elements::FileResult {
source: "let s = \"</script><script>alert(1)</script>\";".to_owned(),
language: "rust".to_owned(),
mutants: Vec::new(),
},
);
let page = render(&subject).expect("renders");
assert!(!page.contains("</script><script>alert(1)"), "{page}");
assert!(page.contains("\\u003c/script"), "{page}");
}
#[test]
fn escaping_leaves_the_document_valid_json() {
use std::io::Write as _;
let mut escaped = Vec::new();
EscapeScript { inner: &mut escaped }
.write_all(b"{\"a\":\"x < y > z\"}")
.expect("writing to a vec cannot fail");
let escaped = String::from_utf8(escaped).expect("ascii escapes stay valid utf-8");
let parsed: Value = serde_json::from_str(&escaped).expect("still valid JSON");
assert_eq!(parsed["a"], "x < y > z");
assert!(!escaped.contains('<'));
assert!(!escaped.contains('>'));
}
#[test]
fn the_streamed_page_matches_the_string_form() {
let report = report();
let directory = crate::testing::workdir("html-stream-page-");
let root = camino::Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
let path = root.join("report.html");
write_page(&report, &path).expect("streams the page");
let expected = render(&report).expect("renders");
assert_eq!(std::fs::read_to_string(path.as_std_path()).expect("published bytes"), expected);
}
}