| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
| 6 |
use crate::auth::Viewer; |
| 7 |
use crate::reports::{self, Metric, Report}; |
| 8 |
use crate::Shared; |
| 9 |
use askama::Template; |
| 10 |
use axum::extract::{Path, Query, State}; |
| 11 |
use axum::http::{header, HeaderValue, StatusCode}; |
| 12 |
use axum::response::{IntoResponse, Response}; |
| 13 |
use serde::Deserialize; |
| 14 |
|
| 15 |
|
| 16 |
const HISTORY: i64 = 60; |
| 17 |
|
| 18 |
#[derive(Deserialize, Default)] |
| 19 |
pub struct ReportsQuery { |
| 20 |
|
| 21 |
bookmark: Option<String>, |
| 22 |
|
| 23 |
id: Option<i64>, |
| 24 |
|
| 25 |
compare: Option<i64>, |
| 26 |
|
| 27 |
play: Option<u32>, |
| 28 |
} |
| 29 |
|
| 30 |
|
| 31 |
pub struct Stop { |
| 32 |
pub id: i64, |
| 33 |
pub href: String, |
| 34 |
pub label: String, |
| 35 |
pub tip: String, |
| 36 |
pub current: bool, |
| 37 |
pub compared: bool, |
| 38 |
} |
| 39 |
|
| 40 |
|
| 41 |
pub struct Compared { |
| 42 |
pub key: String, |
| 43 |
pub here: String, |
| 44 |
pub there: String, |
| 45 |
pub unit: String, |
| 46 |
|
| 47 |
pub delta: String, |
| 48 |
pub trend: &'static str, |
| 49 |
} |
| 50 |
|
| 51 |
|
| 52 |
pub struct MetricView { |
| 53 |
pub key: String, |
| 54 |
pub value: String, |
| 55 |
pub unit: String, |
| 56 |
|
| 57 |
pub delta: String, |
| 58 |
|
| 59 |
pub trend: &'static str, |
| 60 |
|
| 61 |
pub threshold: String, |
| 62 |
|
| 63 |
pub failing: bool, |
| 64 |
|
| 65 |
pub spark: String, |
| 66 |
} |
| 67 |
|
| 68 |
|
| 69 |
pub struct Item { |
| 70 |
pub report: Report, |
| 71 |
pub metrics: Vec<MetricView>, |
| 72 |
|
| 73 |
pub health: &'static str, |
| 74 |
|
| 75 |
pub badge: String, |
| 76 |
} |
| 77 |
|
| 78 |
|
| 79 |
pub struct Builtin { |
| 80 |
pub big: String, |
| 81 |
pub sub: String, |
| 82 |
pub health: &'static str, |
| 83 |
pub when_ms: i64, |
| 84 |
pub bookmark: String, |
| 85 |
} |
| 86 |
|
| 87 |
|
| 88 |
fn health_of(metrics: &[MetricView]) -> (&'static str, String) { |
| 89 |
let with = metrics.iter().filter(|m| !m.threshold.is_empty()).count(); |
| 90 |
let failing = metrics.iter().filter(|m| m.failing).count(); |
| 91 |
match (with, failing) { |
| 92 |
(0, _) => ("neutral", format!("{} metric{}", metrics.len(), if metrics.len() == 1 { "" } else { "s" })), |
| 93 |
(_, 0) => ("good", format!("{with} threshold{} met", if with == 1 { "" } else { "s" })), |
| 94 |
(_, f) => ("bad", format!("{f} over {}", if f == 1 { "its threshold" } else { "their thresholds" })), |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
#[derive(Template)] |
| 99 |
#[template(path = "reports.html")] |
| 100 |
pub struct ReportsPage { |
| 101 |
shell: Shell, |
| 102 |
repo: RepoShell, |
| 103 |
bookmark: String, |
| 104 |
items: Vec<Item>, |
| 105 |
coverage: Option<Builtin>, |
| 106 |
tests: Option<Builtin>, |
| 107 |
} |
| 108 |
|
| 109 |
#[derive(Template)] |
| 110 |
#[template(path = "report.html")] |
| 111 |
pub struct ReportPage { |
| 112 |
shell: Shell, |
| 113 |
repo: RepoShell, |
| 114 |
report: Report, |
| 115 |
bookmarks: Vec<String>, |
| 116 |
metrics: Vec<MetricView>, |
| 117 |
context: Vec<(String, String)>, |
| 118 |
|
| 119 |
runs: Vec<Report>, |
| 120 |
|
| 121 |
frame: Option<String>, |
| 122 |
files_base: String, |
| 123 |
|
| 124 |
timeline: Vec<Stop>, |
| 125 |
|
| 126 |
compare: Option<(Report, Vec<Compared>, Option<String>)>, |
| 127 |
|
| 128 |
play: Option<u32>, |
| 129 |
|
| 130 |
|
| 131 |
runs_json: String, |
| 132 |
} |
| 133 |
|
| 134 |
|
| 135 |
fn num(v: f64) -> String { |
| 136 |
if v.fract() == 0.0 && v.abs() < 1e15 { |
| 137 |
format!("{v:.0}") |
| 138 |
} else { |
| 139 |
let s = format!("{v:.3}"); |
| 140 |
s.trim_end_matches('0').trim_end_matches('.').to_string() |
| 141 |
} |
| 142 |
} |
| 143 |
|
| 144 |
fn spark(values: &[f64]) -> String { |
| 145 |
if values.len() < 2 { |
| 146 |
return String::new(); |
| 147 |
} |
| 148 |
let (w, h) = (120.0, 28.0); |
| 149 |
let lo = values.iter().cloned().fold(f64::INFINITY, f64::min); |
| 150 |
let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); |
| 151 |
let span = if hi > lo { hi - lo } else { 1.0 }; |
| 152 |
let n = values.len(); |
| 153 |
let pts: Vec<String> = values |
| 154 |
.iter() |
| 155 |
.enumerate() |
| 156 |
.map(|(i, v)| format!("{:.1},{:.1}", w * i as f64 / (n - 1) as f64, 2.0 + (h - 4.0) * (hi - v) / span)) |
| 157 |
.collect(); |
| 158 |
let last = pts.last().cloned().unwrap_or_default(); |
| 159 |
let (lx, ly) = last.split_once(',').unwrap_or(("0", "0")); |
| 160 |
format!( |
| 161 |
r#"<svg class="rep-spark" viewBox="0 0 {w} {h}" width="{w}" height="{h}" aria-hidden="true"><polyline points="{}"/><circle cx="{lx}" cy="{ly}" r="2.5"/></svg>"#, |
| 162 |
pts.join(" ") |
| 163 |
) |
| 164 |
} |
| 165 |
|
| 166 |
|
| 167 |
|
| 168 |
fn views(now: &[Metric], before: &[Metric], series: &[Vec<Metric>]) -> Vec<MetricView> { |
| 169 |
now.iter() |
| 170 |
.map(|m| { |
| 171 |
let prev = before.iter().find(|b| b.key == m.key).map(|b| b.value); |
| 172 |
let (delta, trend) = match prev { |
| 173 |
Some(p) if (m.value - p).abs() > f64::EPSILON => { |
| 174 |
let up = m.value > p; |
| 175 |
let trend = match m.better.as_str() { |
| 176 |
"higher" if up => "better", |
| 177 |
"higher" => "worse", |
| 178 |
"lower" if up => "worse", |
| 179 |
"lower" => "better", |
| 180 |
_ => "same", |
| 181 |
}; |
| 182 |
(format!("{}{}", if up { "+" } else { "" }, num(m.value - p)), trend) |
| 183 |
} |
| 184 |
_ => (String::new(), "same"), |
| 185 |
}; |
| 186 |
let mut threshold = Vec::new(); |
| 187 |
if let Some(a) = m.fail_above { |
| 188 |
threshold.push(format!("fails above {}", num(a))); |
| 189 |
} |
| 190 |
if let Some(b) = m.fail_below { |
| 191 |
threshold.push(format!("fails below {}", num(b))); |
| 192 |
} |
| 193 |
let failing = m.fail_above.is_some_and(|a| m.value > a) || m.fail_below.is_some_and(|b| m.value < b); |
| 194 |
let values: Vec<f64> = series.iter().filter_map(|ms| ms.iter().find(|x| x.key == m.key).map(|x| x.value)).collect(); |
| 195 |
MetricView { |
| 196 |
key: m.key.clone(), |
| 197 |
value: num(m.value), |
| 198 |
unit: m.unit.clone(), |
| 199 |
delta, |
| 200 |
trend, |
| 201 |
threshold: threshold.join(", "), |
| 202 |
failing, |
| 203 |
spark: spark(&values), |
| 204 |
} |
| 205 |
}) |
| 206 |
.collect() |
| 207 |
} |
| 208 |
|
| 209 |
|
| 210 |
fn compared(here: &[Metric], there: &[Metric]) -> Vec<Compared> { |
| 211 |
let mut keys: Vec<&str> = here.iter().chain(there).map(|m| m.key.as_str()).collect(); |
| 212 |
keys.sort(); |
| 213 |
keys.dedup(); |
| 214 |
keys.into_iter() |
| 215 |
.map(|k| { |
| 216 |
let (a, b) = (here.iter().find(|m| m.key == k), there.iter().find(|m| m.key == k)); |
| 217 |
let (delta, trend) = match (a, b) { |
| 218 |
(Some(a), Some(b)) if (a.value - b.value).abs() > f64::EPSILON => { |
| 219 |
let up = a.value > b.value; |
| 220 |
let trend = match a.better.as_str() { |
| 221 |
"higher" if up => "better", |
| 222 |
"higher" => "worse", |
| 223 |
"lower" if up => "worse", |
| 224 |
"lower" => "better", |
| 225 |
_ => "same", |
| 226 |
}; |
| 227 |
(format!("{}{}", if up { "+" } else { "" }, num(a.value - b.value)), trend) |
| 228 |
} |
| 229 |
_ => (String::new(), "same"), |
| 230 |
}; |
| 231 |
Compared { |
| 232 |
key: k.to_string(), |
| 233 |
here: a.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), |
| 234 |
there: b.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), |
| 235 |
unit: a.or(b).map(|m| m.unit.clone()).unwrap_or_default(), |
| 236 |
delta, |
| 237 |
trend, |
| 238 |
} |
| 239 |
}) |
| 240 |
.collect() |
| 241 |
} |
| 242 |
|
| 243 |
|
| 244 |
|
| 245 |
pub async fn overview( |
| 246 |
State(app): State<Shared>, |
| 247 |
viewer: Viewer, |
| 248 |
Path((owner, name)): Path<(String, String)>, |
| 249 |
Query(q): Query<ReportsQuery>, |
| 250 |
) -> Result<Page<ReportsPage>, Error> { |
| 251 |
let shell = Shell::new(&app, &format!("/{owner}/{name}/reports"), &viewer); |
| 252 |
let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 253 |
let e = |e| Error::from_db(shell.clone(), e); |
| 254 |
let bookmark = q.bookmark.clone().filter(|b| !b.is_empty()); |
| 255 |
let latest = reports::latest(&app.db, row.id, bookmark.as_deref()).await.map_err(e)?; |
| 256 |
let mut items = Vec::new(); |
| 257 |
for r in latest { |
| 258 |
let history = reports::history(&app.db, row.id, &r.name, &r.bookmark, 2).await.map_err(e)?; |
| 259 |
let now = reports::metrics(&app.db, r.id).await.map_err(e)?; |
| 260 |
let before = match history.get(1) { |
| 261 |
Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?, |
| 262 |
None => vec![], |
| 263 |
}; |
| 264 |
let metrics = views(&now, &before, &[]); |
| 265 |
let (health, badge) = health_of(&metrics); |
| 266 |
items.push(Item { metrics, report: r, health, badge }); |
| 267 |
} |
| 268 |
let repo = RepoShell::new(&app, &row, "reports").with_role(role); |
| 269 |
|
| 270 |
|
| 271 |
let pick = |list: Vec<String>| bookmark.clone().or_else(|| list.iter().find(|b| **b == row.default_bookmark).or(list.first()).cloned()); |
| 272 |
let coverage = match pick(crate::coverage::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.coverage) { |
| 273 |
Some(b) => crate::coverage::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|c| Builtin { |
| 274 |
big: c.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()), |
| 275 |
sub: format!("{} of {} lines covered · {}", c.lines_hit, c.lines_found, c.source()), |
| 276 |
health: match c.percent() { |
| 277 |
Some(p) if p >= 75.0 => "good", |
| 278 |
Some(p) if p < 50.0 => "bad", |
| 279 |
_ => "neutral", |
| 280 |
}, |
| 281 |
when_ms: c.created_ms, |
| 282 |
bookmark: c.bookmark.clone(), |
| 283 |
}), |
| 284 |
None => None, |
| 285 |
}; |
| 286 |
let tests = match pick(crate::junit::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.tests) { |
| 287 |
Some(b) => crate::junit::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|t| Builtin { |
| 288 |
big: if t.broken() > 0 { format!("{} failing", t.broken()) } else { format!("{} passed", t.passed) }, |
| 289 |
sub: t.summary(), |
| 290 |
health: if t.broken() > 0 { "bad" } else { "good" }, |
| 291 |
when_ms: t.created_ms, |
| 292 |
bookmark: t.bookmark.clone(), |
| 293 |
}), |
| 294 |
None => None, |
| 295 |
}; |
| 296 |
Ok(Page(ReportsPage { shell, coverage, tests, repo, bookmark: bookmark.unwrap_or_default(), items })) |
| 297 |
} |
| 298 |
|
| 299 |
|
| 300 |
|
| 301 |
|
| 302 |
pub async fn show( |
| 303 |
State(app): State<Shared>, |
| 304 |
viewer: Viewer, |
| 305 |
Path((owner, name, rname)): Path<(String, String, String)>, |
| 306 |
Query(q): Query<ReportsQuery>, |
| 307 |
) -> Result<Page<ReportPage>, Error> { |
| 308 |
let shell = Shell::new(&app, &format!("/{owner}/{name}/reports/{rname}"), &viewer); |
| 309 |
let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 310 |
let e = |e| Error::from_db(shell.clone(), e); |
| 311 |
let report = match q.id { |
| 312 |
Some(id) => reports::get(&app.db, row.id, id).await.map_err(e)?.filter(|r| r.name == rname), |
| 313 |
None => { |
| 314 |
let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?; |
| 315 |
let bookmark = q |
| 316 |
.bookmark |
| 317 |
.clone() |
| 318 |
.or_else(|| bookmarks.iter().find(|b| **b == row.default_bookmark).cloned()) |
| 319 |
.or_else(|| bookmarks.first().cloned()); |
| 320 |
match bookmark { |
| 321 |
Some(b) => reports::history(&app.db, row.id, &rname, &b, 1).await.map_err(e)?.into_iter().next(), |
| 322 |
None => None, |
| 323 |
} |
| 324 |
} |
| 325 |
}; |
| 326 |
let report = report.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 327 |
let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?; |
| 328 |
let runs = reports::history(&app.db, row.id, &rname, &report.bookmark, HISTORY).await.map_err(e)?; |
| 329 |
let now = reports::metrics(&app.db, report.id).await.map_err(e)?; |
| 330 |
|
| 331 |
let at = runs.iter().position(|r| r.id == report.id).unwrap_or(0); |
| 332 |
let before = match runs.get(at + 1) { |
| 333 |
Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?, |
| 334 |
None => vec![], |
| 335 |
}; |
| 336 |
let mut series = Vec::new(); |
| 337 |
for r in runs.iter().skip(at).rev() { |
| 338 |
series.push(reports::metrics(&app.db, r.id).await.map_err(e)?); |
| 339 |
} |
| 340 |
let base_of = |id: i64| format!("/{owner}/{name}/reports/files/{id}/{}", reports::file_mac(&app, id)); |
| 341 |
let files_base = base_of(report.id); |
| 342 |
let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html")); |
| 343 |
let label = |r: &Report| r.run_number.map(|n| format!("run #{n}")).unwrap_or_else(|| "upload".into()); |
| 344 |
let page = format!("/{owner}/{name}/reports/{rname}"); |
| 345 |
let keep = |id: i64| { |
| 346 |
let mut url = format!("{page}?id={id}"); |
| 347 |
if let Some(c) = q.compare.filter(|c| *c != id) { |
| 348 |
url.push_str(&format!("&compare={c}")); |
| 349 |
} |
| 350 |
if let Some(p) = q.play { |
| 351 |
url.push_str(&format!("&play={p}")); |
| 352 |
} |
| 353 |
url |
| 354 |
}; |
| 355 |
let timeline: Vec<Stop> = runs |
| 356 |
.iter() |
| 357 |
.rev() |
| 358 |
.map(|r| Stop { |
| 359 |
id: r.id, |
| 360 |
href: keep(r.id), |
| 361 |
label: label(r), |
| 362 |
tip: format!("{}{}", label(r), if r.summary.is_empty() { String::new() } else { format!(": {}", r.summary) }), |
| 363 |
current: r.id == report.id, |
| 364 |
compared: Some(r.id) == q.compare, |
| 365 |
}) |
| 366 |
.collect(); |
| 367 |
|
| 368 |
|
| 369 |
let compare = match q.compare.filter(|c| *c != report.id) { |
| 370 |
Some(cid) => match reports::get(&app.db, row.id, cid).await.map_err(e)?.filter(|r| r.name == rname) { |
| 371 |
Some(other) => { |
| 372 |
let theirs = reports::metrics(&app.db, other.id).await.map_err(e)?; |
| 373 |
let rows = compared(&now, &theirs); |
| 374 |
let f = |
| 375 |
reports::file(&app.db, other.id, "index.html").await.map_err(e)?.map(|_| format!("{}/index.html", base_of(other.id))); |
| 376 |
Some((other, rows, f)) |
| 377 |
} |
| 378 |
None => None, |
| 379 |
}, |
| 380 |
None => None, |
| 381 |
}; |
| 382 |
let runs_json = serde_json::to_string( |
| 383 |
&runs |
| 384 |
.iter() |
| 385 |
.rev() |
| 386 |
.map(|r| serde_json::json!({ "id": r.id, "run": r.run_number, "bookmark": r.bookmark, "created_ms": r.created_ms, "summary": r.summary, "files": base_of(r.id), "current": r.id == report.id })) |
| 387 |
.collect::<Vec<_>>(), |
| 388 |
) |
| 389 |
.unwrap_or_default(); |
| 390 |
let repo = RepoShell::new(&app, &row, "reports").with_role(role); |
| 391 |
Ok(Page(ReportPage { |
| 392 |
shell, |
| 393 |
repo, |
| 394 |
context: report.context_rows(), |
| 395 |
metrics: views(&now, &before, &series), |
| 396 |
bookmarks, |
| 397 |
runs, |
| 398 |
frame, |
| 399 |
files_base, |
| 400 |
report, |
| 401 |
timeline, |
| 402 |
compare, |
| 403 |
play: q.play.map(|p| p.clamp(1, 60)), |
| 404 |
runs_json, |
| 405 |
})) |
| 406 |
} |
| 407 |
|
| 408 |
|
| 409 |
fn with_frame_script(page: Vec<u8>, version: &str) -> Vec<u8> { |
| 410 |
let tag = format!(r#"<script src="/static/report-frame.js?v={version}"></script>"#); |
| 411 |
let lower = page.to_ascii_lowercase(); |
| 412 |
let find = |needle: &[u8]| lower.windows(needle.len()).position(|w| w == needle); |
| 413 |
let at = find(b"</head>").or_else(|| find(b"<body")).unwrap_or(page.len()); |
| 414 |
let mut out = Vec::with_capacity(page.len() + tag.len()); |
| 415 |
out.extend_from_slice(&page[..at]); |
| 416 |
out.extend_from_slice(tag.as_bytes()); |
| 417 |
out.extend_from_slice(&page[at..]); |
| 418 |
out |
| 419 |
} |
| 420 |
|
| 421 |
|
| 422 |
pub(crate) fn content_type(path: &str) -> &'static str { |
| 423 |
match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() { |
| 424 |
"html" | "htm" => "text/html; charset=utf-8", |
| 425 |
"js" | "mjs" => "text/javascript; charset=utf-8", |
| 426 |
"css" => "text/css; charset=utf-8", |
| 427 |
"json" => "application/json", |
| 428 |
"csv" => "text/csv; charset=utf-8", |
| 429 |
"svg" => "image/svg+xml", |
| 430 |
"png" => "image/png", |
| 431 |
"jpg" | "jpeg" => "image/jpeg", |
| 432 |
"gif" => "image/gif", |
| 433 |
"webp" => "image/webp", |
| 434 |
"woff2" => "font/woff2", |
| 435 |
"woff" => "font/woff", |
| 436 |
"txt" | "toml" | "md" | "info" | "xml" => "text/plain; charset=utf-8", |
| 437 |
_ => "application/octet-stream", |
| 438 |
} |
| 439 |
} |
| 440 |
|
| 441 |
|
| 442 |
|
| 443 |
|
| 444 |
|
| 445 |
|
| 446 |
pub async fn file(State(app): State<Shared>, Path((owner, name, id, mac, path)): Path<(String, String, i64, String, String)>) -> Response { |
| 447 |
let not_found = || (StatusCode::NOT_FOUND, "no such report file").into_response(); |
| 448 |
if !crate::auth::mac_equal(&mac, &reports::file_mac(&app, id)) { |
| 449 |
return not_found(); |
| 450 |
} |
| 451 |
let Ok(Some(row)) = crate::db::repos::get(&app.db, &owner, &name).await else { return not_found() }; |
| 452 |
let Ok(Some(_)) = reports::get(&app.db, row.id, id).await else { return not_found() }; |
| 453 |
let Ok(Some((blob, _))) = reports::file(&app.db, id, &path).await else { return not_found() }; |
| 454 |
let (Some(repo), Some(oref)) = (crate::repos::RepoName::new(&row.owner, &row.name), oxy_core::hash::ObjectRef::from_hex(&blob)) else { |
| 455 |
return not_found(); |
| 456 |
}; |
| 457 |
let bytes = match app.repos.read(&repo, move |r, _| Ok(r.read_blob(&oref)?)).await { |
| 458 |
Ok(b) => b, |
| 459 |
Err(_) => return not_found(), |
| 460 |
}; |
| 461 |
|
| 462 |
|
| 463 |
let bytes = |
| 464 |
if content_type(&path).starts_with("text/html") { with_frame_script(bytes, super::statics::assets_version()) } else { bytes }; |
| 465 |
let origin = app.config.base_url().trim_end_matches('/').to_string(); |
| 466 |
let csp = format!( |
| 467 |
"sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \ |
| 468 |
style-src 'self' {origin} 'unsafe-inline'; img-src 'self' {origin} data: blob:; font-src 'self' {origin} data:; \ |
| 469 |
connect-src 'self' {origin}; frame-ancestors 'self' {origin}; base-uri 'none'; form-action 'none'" |
| 470 |
); |
| 471 |
let mut res = (StatusCode::OK, bytes).into_response(); |
| 472 |
let h = res.headers_mut(); |
| 473 |
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path))); |
| 474 |
if let Ok(v) = HeaderValue::from_str(&csp) { |
| 475 |
h.insert(header::CONTENT_SECURITY_POLICY, v); |
| 476 |
} |
| 477 |
h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); |
| 478 |
h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); |
| 479 |
h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")); |
| 480 |
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=3600, immutable")); |
| 481 |
res |
| 482 |
} |
| 483 |
|
| 484 |
#[cfg(test)] |
| 485 |
mod tests { |
| 486 |
use super::*; |
| 487 |
|
| 488 |
#[test] |
| 489 |
fn metrics_say_which_way_they_moved() { |
| 490 |
let m = |v: f64, better: &str, above: Option<f64>| Metric { |
| 491 |
key: "frame".into(), |
| 492 |
value: v, |
| 493 |
unit: "ms".into(), |
| 494 |
better: better.into(), |
| 495 |
fail_above: above, |
| 496 |
fail_below: None, |
| 497 |
}; |
| 498 |
let v = &views(&[m(18.0, "lower", Some(20.0))], &[m(16.5, "lower", Some(20.0))], &[])[0]; |
| 499 |
assert_eq!((v.value.as_str(), v.delta.as_str(), v.trend, v.failing), ("18", "+1.5", "worse", false)); |
| 500 |
assert_eq!(v.threshold, "fails above 20"); |
| 501 |
let v = &views(&[m(21.0, "lower", Some(20.0))], &[], &[])[0]; |
| 502 |
assert!(v.failing && v.delta.is_empty()); |
| 503 |
assert_eq!(num(16.40), "16.4"); |
| 504 |
assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline")); |
| 505 |
assert!(spark(&[1.0]).is_empty()); |
| 506 |
let page = with_frame_script(b"<!doctype html><html><HEAD><title>t</title></HEAD><body>x</body></html>".to_vec(), "v1"); |
| 507 |
let page = String::from_utf8(page).unwrap(); |
| 508 |
assert!(page.starts_with("<!doctype html>"), "the doctype stays first"); |
| 509 |
assert!(page.contains(r#"<script src="/static/report-frame.js?v=v1"></script></HEAD>"#), "{page}"); |
| 510 |
let bare = String::from_utf8(with_frame_script(b"<p>hi</p>".to_vec(), "v1")).unwrap(); |
| 511 |
assert!(bare.ends_with("</script>"), "{bare}"); |
| 512 |
} |
| 513 |
} |