use std::io::Write;
use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use flate2::Compression;
use flate2::write::GzEncoder;
use crate::observe::structured::StructuredLog;
use crate::viewer;
const PREFIX: &str = "<!doctype html>\n\
<html lang=\"en\">\n\
<head><meta charset=\"utf-8\"><title>Relux test report</title>\
<style>html,body{margin:0;padding:0}</style></head>\n\
<body><div id=\"app\"></div>\n";
const BOOTSTRAP: &str = "<script>\n\
(async () => {\n\
if (!window.DecompressionStream) {\n\
document.body.textContent = \
\"This report needs Chrome 80+, Firefox 113+, or Safari 16.4+.\";\n\
return;\n\
}\n\
const unzip = async id => {\n\
const b64 = document.getElementById(id).textContent;\n\
const bin = Uint8Array.from(atob(b64), c => c.charCodeAt(0));\n\
const s = new Blob([bin]).stream().pipeThrough(new DecompressionStream(\"gzip\"));\n\
return await new Response(s).text();\n\
};\n\
const runJs = code => {\n\
const s = document.createElement(\"script\");\n\
s.textContent = code;\n\
document.head.appendChild(s);\n\
};\n\
window.RELUX_DATA = JSON.parse(await unzip(\"d\"));\n\
runJs(await unzip(\"h\"));\n\
runJs(await unzip(\"i\"));\n\
runJs(await unzip(\"v\"));\n\
})();\n\
</script>\n";
const SUFFIX: &str = "</body></html>\n";
pub fn write(log_dir: &Path, structured: &StructuredLog) -> std::io::Result<()> {
let html = render(structured)?;
std::fs::write(log_dir.join("event.html"), html)
}
fn render(structured: &StructuredLog) -> std::io::Result<String> {
let json = serde_json::to_vec(structured).map_err(std::io::Error::other)?;
let data_b64 = encode_gz(&json)?;
let hljs_b64 = BASE64.encode(viewer::hljs_gz());
let init_b64 = BASE64.encode(viewer::hljs_init_gz());
let bundle_b64 = BASE64.encode(viewer::bundle_gz());
let mut html = String::with_capacity(
PREFIX.len()
+ payload_tag_len("d", data_b64.len())
+ payload_tag_len("h", hljs_b64.len())
+ payload_tag_len("i", init_b64.len())
+ payload_tag_len("v", bundle_b64.len())
+ BOOTSTRAP.len()
+ SUFFIX.len(),
);
html.push_str(PREFIX);
push_payload_tag(&mut html, "d", &data_b64);
push_payload_tag(&mut html, "h", &hljs_b64);
push_payload_tag(&mut html, "i", &init_b64);
push_payload_tag(&mut html, "v", &bundle_b64);
html.push_str(BOOTSTRAP);
html.push_str(SUFFIX);
Ok(html)
}
fn encode_gz(bytes: &[u8]) -> std::io::Result<String> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder.write_all(bytes)?;
let gz = encoder.finish()?;
Ok(BASE64.encode(gz))
}
fn push_payload_tag(html: &mut String, id: &str, b64: &str) {
html.push_str("<script type=\"application/octet-stream\" id=\"");
html.push_str(id);
html.push_str("\">");
html.push_str(b64);
html.push_str("</script>\n");
}
fn payload_tag_len(id: &str, b64_len: usize) -> usize {
"<script type=\"application/octet-stream\" id=\"".len()
+ id.len()
+ "\">".len()
+ b64_len
+ "</script>\n".len()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::io::Read;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use flate2::read::GzDecoder;
use super::*;
use crate::observe::structured::EnvInfo;
use crate::observe::structured::StructuredLog;
use crate::observe::structured::TestInfo;
use crate::observe::structured::TestOutcome;
fn sample_log(test_name: &str) -> StructuredLog {
StructuredLog {
schema_version: crate::observe::structured::SCHEMA_VERSION,
info: TestInfo {
name: test_name.to_string(),
path: "tests/foo.relux".to_string(),
duration_ms: 42,
},
outcome: TestOutcome::Pass,
env: EnvInfo::default(),
shells: HashMap::new(),
spans: HashMap::new(),
events: Vec::new(),
buffer_events: Vec::new(),
sources: HashMap::new(),
artifacts: Vec::new(),
}
}
fn decode_payload(html: &str, id: &str) -> Vec<u8> {
let opener = format!("<script type=\"application/octet-stream\" id=\"{id}\">");
let start = html.find(&opener).expect("payload tag opener missing") + opener.len();
let end = html[start..]
.find("</script>")
.expect("payload tag closer missing")
+ start;
let b64 = &html[start..end];
let gz = BASE64.decode(b64).expect("payload base64 decode failed");
let mut out = Vec::new();
GzDecoder::new(gz.as_slice())
.read_to_end(&mut out)
.expect("payload gunzip failed");
out
}
#[test]
fn html_inlines_payload_and_bundle_entry_hook() {
let html = render(&sample_log("hello-world")).unwrap();
let data: serde_json::Value = serde_json::from_slice(&decode_payload(&html, "d")).unwrap();
assert_eq!(data["info"]["name"], "hello-world");
let bundle = decode_payload(&html, "v");
let bundle_str = std::str::from_utf8(&bundle).unwrap();
assert!(bundle_str.contains("RELUX_DATA"));
}
#[test]
fn render_inlines_artifacts_into_data_payload() {
use crate::observe::structured::ArtifactEntry;
let mut log = sample_log("with-artifacts");
log.artifacts = vec![
ArtifactEntry {
path: "out.txt".to_string(),
size: 12,
mime: Some("text/plain".to_string()),
},
ArtifactEntry {
path: "sut/error.log".to_string(),
size: 4096,
mime: None,
},
];
let html = render(&log).unwrap();
let data: serde_json::Value = serde_json::from_slice(&decode_payload(&html, "d")).unwrap();
let artifacts = data["artifacts"].as_array().expect("artifacts array");
assert_eq!(artifacts.len(), 2);
assert_eq!(artifacts[0]["path"], "out.txt");
assert_eq!(artifacts[0]["size"], 12);
assert_eq!(artifacts[0]["mime"], "text/plain");
assert_eq!(artifacts[1]["path"], "sut/error.log");
assert_eq!(artifacts[1]["size"], 4096);
assert!(artifacts[1]["mime"].is_null());
}
#[test]
fn closing_tag_in_payload_is_isolated_from_html() {
let mut log = sample_log("hostile");
log.info.name = "evil</script>name".to_string();
let html = render(&log).unwrap();
let data: serde_json::Value = serde_json::from_slice(&decode_payload(&html, "d")).unwrap();
assert_eq!(data["info"]["name"], "evil</script>name");
let opener = "<script type=\"application/octet-stream\" id=\"d\">";
let start = html.find(opener).unwrap() + opener.len();
let end = html[start..].find("</script>").unwrap() + start;
let payload = &html[start..end];
assert!(payload.bytes().all(|b| b != b'<'));
}
#[test]
fn bootstrap_shows_browser_floor_when_decompression_stream_missing() {
let html = render(&sample_log("any")).unwrap();
let bootstrap = bootstrap_script(&html);
assert!(
bootstrap.contains("!window.DecompressionStream"),
"bootstrap is missing the `!window.DecompressionStream` guard",
);
for marker in ["Chrome 80+", "Firefox 113+", "Safari 16.4+"] {
assert!(
bootstrap.contains(marker),
"bootstrap fallback message is missing `{marker}`",
);
}
assert!(
bootstrap.contains("document.body.textContent"),
"fallback assigns to something other than `document.body.textContent`",
);
let return_idx = bootstrap
.find("return;")
.expect("fallback branch is missing an early `return`");
let first_unzip = bootstrap
.find("await unzip(")
.expect("bootstrap no longer calls unzip — refactor invalidates this test");
assert!(
return_idx < first_unzip,
"the fallback `return` no longer precedes the first `unzip` call \
— DecompressionStream-less browsers may try to decompress anyway",
);
}
fn bootstrap_script(html: &str) -> &str {
let opener = "<script>";
let start = html.find(opener).expect("bootstrap opener missing") + opener.len();
let end = html[start..]
.find("</script>")
.expect("bootstrap closer missing")
+ start;
&html[start..end]
}
#[test]
fn write_creates_event_html_under_log_dir() {
let dir = std::env::temp_dir().join(format!(
"relux-event-html-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir_all(&dir).unwrap();
let result = write(&dir, &sample_log("disk")).map(|()| dir.join("event.html"));
let path = match result {
Ok(p) => p,
Err(e) => {
let _ = std::fs::remove_dir_all(&dir);
panic!("write failed: {e}");
}
};
let html = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(html.starts_with("<!doctype html>"));
let data: serde_json::Value = serde_json::from_slice(&decode_payload(&html, "d")).unwrap();
assert_eq!(data["info"]["name"], "disk");
}
}