OXYGENHUB
◆ v0.9.15 oxygenhub / crates / hub / src / web / reports.rs
21.4 KiB · Rust · last changed Flow job reports and timings over the API, oh run report and oh report; 0.9.14 (closes #348) 7 days ago source blame history raw
c8df33a6 Andy McCall ↶ 1
//! Reports pages (oxygenhub#127, docs/reports.md): the Reports tab, a
2
//! report's page with its metrics across runs and its own page in a
3
//! sandboxed frame, and the files that frame reads.
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
/// Runs a report's page reads for its sparklines and run list.
16
const HISTORY: i64 = 60;
17
18
#[derive(Deserialize, Default)]
19
pub struct ReportsQuery {
20
    /// Only this bookmark's reports.
21
    bookmark: Option<String>,
22
    /// One report, by id.
23
    id: Option<i64>,
95b525b0 Andy McCall ↶ 24
    /// Another run of the same report to compare with, by id (#127, RP4).
25
    compare: Option<i64>,
26
    /// Playing: seconds per run, carried from stop to stop.
27
    play: Option<u32>,
c8df33a6 Andy McCall ↶ 28
}
29
95b525b0 Andy McCall ↶ 30
/// A stop on the timeline: one run of the report on its bookmark.
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
/// A metric shown against another run.
41
pub struct Compared {
42
    pub key: String,
43
    pub here: String,
44
    pub there: String,
45
    pub unit: String,
46
    /// here − there, `+1.5`; empty when equal or missing.
47
    pub delta: String,
48
    pub trend: &'static str,
49
}
50
c8df33a6 Andy McCall ↶ 51
/// A metric as shown: its value, and its change against the run before.
52
pub struct MetricView {
53
    pub key: String,
54
    pub value: String,
55
    pub unit: String,
56
    /// `+0.4`, empty when there is no run before or no change.
57
    pub delta: String,
58
    /// `better`, `worse` or `same`, by the metric's `better`.
59
    pub trend: &'static str,
60
    /// Its threshold, as words, when it has one.
61
    pub threshold: String,
62
    /// Whether it crosses its threshold.
63
    pub failing: bool,
64
    /// An inline SVG of its values across runs, oldest first.
65
    pub spark: String,
66
}
67
68
/// A report on the Reports tab.
69
pub struct Item {
70
    pub report: Report,
71
    pub metrics: Vec<MetricView>,
f3e5e2bc Andy McCall ↶ 72
    /// `good`, `bad` or `neutral`: its thresholds met, one crossed, or none.
73
    pub health: &'static str,
74
    /// What the badge says: "2 thresholds met", "1 over its threshold".
75
    pub badge: String,
c8df33a6 Andy McCall ↶ 76
}
77
f3e5e2bc Andy McCall ↶ 78
/// The Coverage or Tests entry on the Reports tab: its latest figure.
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
/// The health and badge of a report from its metrics' thresholds.
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
c8df33a6 Andy McCall ↶ 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>,
f3e5e2bc Andy McCall ↶ 105
    coverage: Option<Builtin>,
106
    tests: Option<Builtin>,
c8df33a6 Andy McCall ↶ 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
    /// Newest first, the one shown among them.
119
    runs: Vec<Report>,
120
    /// Where the frame loads the report's page from, when it has one.
121
    frame: Option<String>,
122
    files_base: String,
95b525b0 Andy McCall ↶ 123
    /// Oldest first, the run shown marked.
124
    timeline: Vec<Stop>,
125
    /// The run compared with, its metrics against this one's, and its frame.
126
    compare: Option<(Report, Vec<Compared>, Option<String>)>,
127
    /// Seconds per run while playing.
128
    play: Option<u32>,
129
    /// The runs, oldest first, as JSON for the frame interface
130
    /// (`report.run()`, `report.history()`).
131
    runs_json: String,
c8df33a6 Andy McCall ↶ 132
}
133
134
/// A number as a person would write it: no trailing zeros.
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
/// The metrics of `now` against `before`, with each one's values across
167
/// `series` (oldest first) for a sparkline.
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),
95b525b0 Andy McCall ↶ 204
            }
205
        })
206
        .collect()
207
}
208
209
/// The metrics of this run against another's, every key either has.
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,
c8df33a6 Andy McCall ↶ 238
            }
239
        })
240
        .collect()
241
}
242
243
/// `GET /{owner}/{name}/reports`: the newest of each report, with its
244
/// metrics against the run before on its bookmark.
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
        };
f3e5e2bc Andy McCall ↶ 264
        let metrics = views(&now, &before, &[]);
265
        let (health, badge) = health_of(&metrics);
266
        items.push(Item { metrics, report: r, health, badge });
c8df33a6 Andy McCall ↶ 267
    }
268
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
f3e5e2bc Andy McCall ↶ 269
    // Coverage and Tests: their latest, on the default bookmark when it has
270
    // one, else wherever was newest.
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()),
40dd3f86 Andy McCall ↶ 275
            sub: format!("{} of {} lines covered · {}", c.lines_hit, c.lines_found, c.source()),
f3e5e2bc Andy McCall ↶ 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 }))
c8df33a6 Andy McCall ↶ 297
}
298
299
/// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its
300
/// bookmark unless `?id=` names a run, with its metrics across runs and
301
/// its page in a sandboxed frame.
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
    // The run before the one shown, and every run up to it for the lines.
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
    }
95b525b0 Andy McCall ↶ 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);
c8df33a6 Andy McCall ↶ 342
    let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html"));
95b525b0 Andy McCall ↶ 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
    // Another run to compare with (RP4): every metric, both values, the
368
    // difference; and its page beside this one's.
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();
c8df33a6 Andy McCall ↶ 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,
95b525b0 Andy McCall ↶ 401
        timeline,
402
        compare,
403
        play: q.play.map(|p| p.clamp(1, 60)),
404
        runs_json,
c8df33a6 Andy McCall ↶ 405
    }))
f3e5e2bc Andy McCall ↶ 406
}
407
408
/// `page` with the report frame script added (#127).
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
c8df33a6 Andy McCall ↶ 419
}
420
421
/// A file's type, by its extension.
fac6853c Andy McCall ↶ 422
pub(crate) fn content_type(path: &str) -> &'static str {
c8df33a6 Andy McCall ↶ 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
/// `GET /{owner}/{name}/reports/files/{id}/{mac}/{*path}`: a report's file,
442
/// for its frame. The frame is sandboxed (scripts, no same origin), so it
443
/// carries no session: the MAC in the link is what lets it in. Every file
444
/// is served sandboxed too, with nothing beyond this hub to load or call,
445
/// and readable across origins, since the frame's own origin is opaque.
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
    };
f3e5e2bc Andy McCall ↶ 461
    // A page gets the frame script, which sizes the frame to it and passes
462
    // the hub's theme on: before </head>, else <body>, else at the end.
463
    let bytes =
464
        if content_type(&path).starts_with("text/html") { with_frame_script(bytes, super::statics::assets_version()) } else { bytes };
c8df33a6 Andy McCall ↶ 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());
f3e5e2bc Andy McCall ↶ 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}");
c8df33a6 Andy McCall ↶ 512
    }
513
}