//! Single-file HTML reporter.
//!
//! Emits one self-contained HTML document with inline CSS and no
//! external assets. The output is meant to be opened directly in a
//! browser or attached to a CI job as an artifact.

use std::io::Write;
use std::sync::Arc;

use crate::coverage::CoverageReport;
use crate::redaction::Redactor;
use crate::report::{Report, Reporter, TestStatus};

/// Single-file HTML reporter (see module docs).
///
/// Accepts an optional [`Redactor`] via [`HtmlReporter::with_redactor`].
/// When set, user-supplied strings (test name, failure message, diff
/// body) are redacted before HTML escaping. Prefer [`Report::redacted`]
/// at the dispatch site when redacting many reporters at once.
#[derive(Default)]
pub struct HtmlReporter {
    redactor: Option<Arc<Redactor>>,
    coverage: Option<CoverageReport>,
}

impl HtmlReporter {
    /// Attach a redactor so user-supplied strings render through it.
    pub fn with_redactor(mut self, redactor: Arc<Redactor>) -> Self {
        self.redactor = Some(redactor);
        self
    }

    /// Attach a coverage report so the rendered page includes a
    /// drill-down section listing un-exercised tools. Extends
    /// the existing layout with one new `<section>` rather than
    /// rewriting the document.
    pub fn with_coverage(mut self, coverage: CoverageReport) -> Self {
        self.coverage = Some(coverage);
        self
    }

    fn redact<'a>(&self, s: &'a str) -> std::borrow::Cow<'a, str> {
        match &self.redactor {
            Some(r) => r.redact_value(s),
            None => std::borrow::Cow::Borrowed(s),
        }
    }
}

impl Reporter for HtmlReporter {
    fn name(&self) -> &'static str {
        "html"
    }

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        write_header(out, report)?;
        write_summary(out, report)?;
        // The auditor view: when a run carries provenance metadata (every real
        // run does), lead with who produced it and what to review before the
        // raw test table. This is the human's moment of trust, so it is also
        // where the mcptest brand lands.
        write_auditor_provenance(out, report)?;
        self.write_review_first(out, report)?;
        self.write_slowest_callout(out, report)?;
        self.write_table(out, report)?;
        self.write_coverage_section(out)?;
        write_footer(out)?;
        Ok(())
    }
}

impl HtmlReporter {
    fn write_coverage_section(&self, out: &mut dyn Write) -> std::io::Result<()> {
        let Some(c) = self.coverage.as_ref() else {
            return Ok(());
        };
        writeln!(out, "<section class=\"coverage\">")?;
        writeln!(out, "<h2>Coverage</h2>")?;
        writeln!(
            out,
            "<p>Tool: {:.0}% &middot; Args: {:.0}% &middot; \
             Error paths: {:.0}% &middot; Transport: {:.0}%</p>",
            c.tool_coverage_pct,
            c.arg_coverage_pct,
            c.error_path_coverage_pct,
            c.transport_coverage_pct,
        )?;
        writeln!(out, "<table>")?;
        writeln!(
            out,
            "<thead><tr><th>Tool</th><th>Called</th><th>Calls</th>\
             <th>Arg combos</th><th>Error paths</th></tr></thead>"
        )?;
        writeln!(out, "<tbody>")?;
        for tool in &c.tools {
            let row_class = if tool.called {
                ""
            } else {
                " class=\"uncovered\""
            };
            writeln!(
                out,
                "<tr{}><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
                row_class,
                html_escape(&tool.name),
                if tool.called { "yes" } else { "no" },
                tool.call_count,
                tool.arg_combinations_seen.len(),
                tool.error_paths_seen.len(),
            )?;
        }
        writeln!(out, "</tbody></table>")?;
        writeln!(out, "</section>")?;
        Ok(())
    }

    /// Lead with what to review: the failing and inconclusive tests, so an
    /// auditor sees where the run did not hold up before scrolling the full
    /// table. Renders nothing for an all-pass run (there is nothing to doubt).
    fn write_review_first(&self, out: &mut dyn Write, report: &Report) -> std::io::Result<()> {
        let flagged: Vec<&crate::report::TestResult> = report
            .tests
            .iter()
            .filter(|t| matches!(t.status, TestStatus::Fail | TestStatus::Inconclusive))
            .collect();
        if flagged.is_empty() {
            return Ok(());
        }
        writeln!(out, "<section class=\"review\">")?;
        writeln!(out, "<h2>Review first</h2>")?;
        writeln!(
            out,
            "<p>{} test(s) did not pass cleanly. Start here to decide whether to trust this run.</p>",
            flagged.len()
        )?;
        writeln!(out, "<ul>")?;
        for test in flagged {
            let name = self.redact(&test.name);
            let (cls, label) = match test.status {
                TestStatus::Inconclusive => ("skip", "INCONC"),
                _ => ("fail", "FAIL"),
            };
            let msg = test
                .failure
                .as_ref()
                .map(|f| self.redact(&f.message).into_owned())
                .unwrap_or_default();
            writeln!(
                out,
                "<li><span class=\"badge {cls}\">{label}</span> {} <span class=\"review-msg\">{}</span></li>",
                html_escape(&name),
                html_escape(&msg),
            )?;
        }
        writeln!(out, "</ul>")?;
        writeln!(out, "</section>")?;
        Ok(())
    }

    fn write_slowest_callout(&self, out: &mut dyn Write, report: &Report) -> std::io::Result<()> {
        let slowest = report.slowest(5);
        if slowest.len() < 2 {
            return Ok(());
        }
        writeln!(out, "<section class=\"slowest\">")?;
        writeln!(out, "<h2>Slowest tests</h2>")?;
        writeln!(out, "<ol>")?;
        for test in slowest {
            let name = self.redact(&test.name);
            writeln!(
                out,
                "<li>{} <span class=\"duration\">({} ms)</span></li>",
                html_escape(&name),
                test.duration_ms,
            )?;
        }
        writeln!(out, "</ol>")?;
        writeln!(out, "</section>")?;
        Ok(())
    }

    fn write_table(&self, out: &mut dyn Write, report: &Report) -> std::io::Result<()> {
        let p90 = report.p90_duration_ms();
        // `data-sort` attributes make the duration column sortable when a
        // CI artifact wraps the page in a viewer that respects them; without
        // a viewer the column still renders the visible ms string.
        writeln!(out, "<table class=\"sortable\">")?;
        writeln!(
            out,
            "<thead><tr><th>Status</th><th>Test</th><th data-sort=\"duration\">Duration</th><th>Location</th></tr></thead>"
        )?;
        writeln!(out, "<tbody>")?;
        for test in &report.tests {
            let (badge_class, badge_text) = match test.status {
                TestStatus::Pass => ("pass", "PASS"),
                TestStatus::Fail => ("fail", "FAIL"),
                TestStatus::Skip => ("skip", "SKIP"),
                TestStatus::Inconclusive => ("skip", "INCONC"),
            };
            let row_class = if p90 > 0 && test.duration_ms >= p90 {
                " class=\"slow\""
            } else {
                ""
            };
            writeln!(out, "<tr{row_class}>")?;
            writeln!(
                out,
                "<td><span class=\"badge {badge_class}\">{badge_text}</span></td>"
            )?;
            let name_red = self.redact(&test.name);
            write!(out, "<td>{}", html_escape(&name_red))?;
            if test.cache_hit {
                write!(out, " <span class=\"cache\">cached</span>")?;
            }
            if let Some(failure) = &test.failure {
                let msg_red = self.redact(&failure.message);
                write!(out, "<details><summary>{}</summary>", html_escape(&msg_red),)?;
                if let Some(diff) = &failure.diff {
                    let diff_red = self.redact(diff);
                    write!(out, "<pre>{}</pre>", html_escape(&diff_red))?;
                }
                write!(out, "</details>")?;
            }
            // Per-step bar chart for stepwise tests.
            if !test.steps.is_empty() {
                self.write_step_bars(out, test)?;
            }
            writeln!(out, "</td>")?;
            writeln!(
                out,
                "<td data-duration-ms=\"{}\">{} ms</td>",
                test.duration_ms, test.duration_ms
            )?;
            let location = format_location(test.file.as_deref(), test.line);
            writeln!(out, "<td>{}</td>", html_escape(&location))?;
            writeln!(out, "</tr>")?;
        }
        writeln!(out, "</tbody></table>")?;
        Ok(())
    }

    fn write_step_bars(
        &self,
        out: &mut dyn Write,
        test: &crate::report::TestResult,
    ) -> std::io::Result<()> {
        let max = test
            .steps
            .iter()
            .map(|s| s.duration_ms.max(1))
            .max()
            .unwrap_or(1);
        write!(
            out,
            "<details class=\"steps\"><summary>{} steps</summary><ol class=\"step-bars\">",
            test.steps.len()
        )?;
        for step in &test.steps {
            let label = self.redact(&step.name);
            // Bar width as a percentage of the slowest step, rounded to
            // avoid sub-pixel jitter in browser rendering.
            let pct = ((step.duration_ms.max(1) as f64 / max as f64) * 100.0).round() as u64;
            let cached = if step.cache_hit {
                " <span class=\"cache\">cached</span>"
            } else {
                ""
            };
            write!(
                out,
                "<li><span class=\"step-label\">{}</span><span class=\"step-bar\" style=\"width:{}%\"></span><span class=\"step-duration\">{} ms{}</span></li>",
                html_escape(&label),
                pct,
                step.duration_ms,
                cached,
            )?;
        }
        write!(out, "</ol></details>")?;
        Ok(())
    }
}

fn write_header(out: &mut dyn Write, report: &Report) -> std::io::Result<()> {
    writeln!(out, "<!DOCTYPE html>")?;
    writeln!(out, "<html lang=\"en\"><head>")?;
    writeln!(out, "<meta charset=\"utf-8\">")?;
    writeln!(
        out,
        "<title>mcptest run {}</title>",
        html_escape(&report.run_id),
    )?;
    writeln!(out, "<style>")?;
    writeln!(
        out,
        "body{{font-family:system-ui,sans-serif;margin:2rem;color:#222}}\
         h1{{margin-top:0}}\
         table{{border-collapse:collapse;width:100%}}\
         th,td{{padding:.5rem .75rem;text-align:left;border-bottom:1px solid #ddd;vertical-align:top}}\
         .badge{{display:inline-block;padding:.1rem .5rem;border-radius:.25rem;font-weight:600;font-size:.85em}}\
         .pass{{background:#e6f4ea;color:#137333}}\
         .fail{{background:#fce8e6;color:#a50e0e}}\
         .skip{{background:#fef7e0;color:#7a5b00}}\
         pre{{background:#f6f8fa;padding:.5rem;border-radius:.25rem;overflow-x:auto;margin:.25rem 0}}\
         details{{margin-top:.25rem}}\
         tr.slow{{background:#fff8e1}}\
         .cache{{display:inline-block;padding:0 .35rem;border-radius:.25rem;font-size:.75em;background:#e8f0fe;color:#1a73e8}}\
         section.slowest{{background:#fafafa;border:1px solid #e0e0e0;padding:.5rem 1rem;border-radius:.25rem;margin:.5rem 0}}\
         section.slowest h2{{font-size:1em;margin:.25rem 0}}\
         section.slowest .duration{{color:#888;font-size:.85em}}\
         details.steps{{margin-top:.25rem}}\
         ol.step-bars{{list-style:none;padding-left:0;margin:.25rem 0}}\
         ol.step-bars li{{display:grid;grid-template-columns:1fr 2fr auto;gap:.5rem;align-items:center;padding:.15rem 0}}\
         .step-label{{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.85em;color:#444}}\
         .step-bar{{display:inline-block;background:#1a73e8;height:.5rem;border-radius:.15rem;min-width:1px}}\
         .step-duration{{font-size:.85em;color:#666;text-align:right}}\
         section.coverage{{background:#fafafa;border:1px solid #e0e0e0;padding:.5rem 1rem;border-radius:.25rem;margin:.5rem 0}}\
         section.coverage h2{{font-size:1em;margin:.25rem 0}}\
         tr.uncovered{{background:#fff4f4}}\
         section.provenance{{background:#f4f8ff;border:1px solid #c9ddff;padding:.5rem 1rem;border-radius:.25rem;margin:.5rem 0}}\
         section.provenance h2{{font-size:1em;margin:.25rem 0}}\
         section.provenance dl{{display:grid;grid-template-columns:max-content 1fr;gap:.15rem .75rem;margin:.25rem 0}}\
         section.provenance dt{{font-weight:600;color:#555}}\
         section.provenance dd{{margin:0;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.9em}}\
         section.review{{background:#fff8f8;border:1px solid #f3c9c9;padding:.5rem 1rem;border-radius:.25rem;margin:.5rem 0}}\
         section.review h2{{font-size:1em;margin:.25rem 0}}\
         section.review ul{{margin:.25rem 0;padding-left:1.2rem}}\
         section.review .review-msg{{color:#666;font-size:.9em}}\
         footer.brand{{margin-top:1.5rem;padding-top:.75rem;border-top:1px solid #e0e0e0;color:#777;font-size:.85em}}"
    )?;
    writeln!(out, "</style>")?;
    writeln!(out, "</head><body>")?;
    Ok(())
}

fn write_summary(out: &mut dyn Write, report: &Report) -> std::io::Result<()> {
    writeln!(out, "<h1>mcptest run {}</h1>", html_escape(&report.run_id),)?;
    writeln!(
        out,
        "<p><strong>Summary:</strong> {} passed, {} failed, {} skipped in {} ms</p>",
        report.summary.passed,
        report.summary.failed,
        report.summary.skipped,
        report.summary.duration_ms,
    )?;
    Ok(())
}

/// Render the auditor provenance banner: who produced the run and against
/// what, so a human can answer "do I trust this" at a glance. Renders nothing
/// when the report carries no metadata (the legacy round-trip path), so
/// existing reports are unchanged.
fn write_auditor_provenance(out: &mut dyn Write, report: &Report) -> std::io::Result<()> {
    let Some(meta) = report.metadata.as_ref() else {
        return Ok(());
    };
    writeln!(out, "<section class=\"provenance\">")?;
    writeln!(out, "<h2>Audit this run</h2>")?;
    writeln!(out, "<dl>")?;
    let mut row = |k: &str, v: &str| -> std::io::Result<()> {
        if v.is_empty() {
            return Ok(());
        }
        writeln!(
            out,
            "<dt>{}</dt><dd>{}</dd>",
            html_escape(k),
            html_escape(v)
        )
    };
    // Live vs recorded: a run where every test came from cache is a replay of
    // recorded traffic; any cold test means a live server answered.
    let mode = if !report.tests.is_empty() && report.tests.iter().all(|t| t.cache_hit) {
        "recorded (replayed from cassettes)"
    } else {
        "live"
    };
    row("Run", &meta.run_id)?;
    row("Mode", mode)?;
    if let Some(profile) = meta.profile.as_deref() {
        row("Profile", profile)?;
    }
    let source = format_source(meta);
    row("Source", &source)?;
    row("Environment", &meta.runner_environment)?;
    row("Platform", &format!("{} {}", meta.os, meta.arch))?;
    row("mcptest", &meta.mcptest_version)?;
    if !meta.servers.is_empty() {
        let posture = meta
            .servers
            .iter()
            .map(|s| format!("{} ({}, {})", s.name, s.transport, s.auth))
            .collect::<Vec<_>>()
            .join("; ");
        row("Servers", &posture)?;
    }
    writeln!(out, "</dl>")?;
    writeln!(out, "</section>")?;
    Ok(())
}

/// Compose a one-line source description (repo, branch, short commit) from the
/// metadata fields that are present.
fn format_source(meta: &crate::report::RunMetadata) -> String {
    let mut parts = Vec::new();
    if let Some(repo) = meta.repo.as_deref() {
        parts.push(repo.to_string());
    }
    if let Some(branch) = meta.branch.as_deref() {
        parts.push(format!("@{branch}"));
    }
    if let Some(sha) = meta.commit_sha.as_deref() {
        parts.push(sha.chars().take(8).collect());
    }
    parts.join(" ")
}

fn write_footer(out: &mut dyn Write) -> std::io::Result<()> {
    writeln!(
        out,
        "<footer class=\"brand\">Generated by <strong>mcptest</strong>, the deterministic MCP testing engine. \
         The agent wrote these checks; mcptest ran them and reported what the server actually did.</footer>"
    )?;
    writeln!(out, "</body></html>")?;
    Ok(())
}

fn format_location(file: Option<&str>, line: Option<u32>) -> String {
    match (file, line) {
        (Some(f), Some(l)) => format!("{f}:{l}"),
        (Some(f), None) => f.to_owned(),
        _ => String::new(),
    }
}

/// Escape the five characters that need attention in HTML text and
/// attribute contexts: `&`, `<`, `>`, `"`, `'`.
fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(ch),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn html_reporter_emits_document_skeleton() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<!DOCTYPE html>"));
        assert!(s.contains("<table"));
        assert!(s.contains("</html>"));
    }

    #[test]
    fn html_reporter_includes_failed_test_name_and_diff() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("create_issue rejects empty title"));
        assert!(s.contains("expected: error code -32602"));
        assert!(s.contains("<details>"));
    }

    #[test]
    fn html_reporter_escapes_script_tags_in_test_names() {
        let mut r = Report::sample();
        r.tests[0].name = "rejects <script>alert(\"x\")</script>".into();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("<script>alert"));
        assert!(s.contains("&lt;script&gt;"));
        assert!(s.contains("&quot;x&quot;"));
    }

    #[test]
    fn html_reporter_handles_zero_failure_report() {
        let r = Report::sample_all_pass();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("0 failed"));
        assert!(s.contains("PASS"));
        assert!(!s.contains("FAIL"));
    }

    #[test]
    fn html_reporter_name_is_stable() {
        assert_eq!(HtmlReporter::default().name(), "html");
    }

    #[test]
    fn html_reporter_emits_slowest_callout() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<section class=\"slowest\">"));
        assert!(s.contains("<h2>Slowest tests</h2>"));
    }

    #[test]
    fn html_reporter_highlights_p90_rows() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("class=\"slow\""), "expected a slow-row marker");
    }

    #[test]
    fn html_reporter_emits_sortable_duration_column() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("data-sort=\"duration\""));
        assert!(s.contains("data-duration-ms=\""));
    }

    #[test]
    fn html_reporter_marks_cached_rows() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<span class=\"cache\">cached</span>"));
    }

    #[test]
    fn html_reporter_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        r.tests[0].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = HtmlReporter::default().with_redactor(Arc::new(Redactor::new()));
        let mut buf = Vec::new();
        reporter.render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("ghp_abcdefghijklmnop1234"));
        assert!(s.contains("[REDACTED:token]"));
    }

    #[test]
    fn html_reporter_emits_coverage_section_when_attached() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default()
            .with_coverage(CoverageReport::sample())
            .render(&r, &mut buf)
            .unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<section class=\"coverage\">"));
        assert!(s.contains("create_issue"));
        // Un-exercised tool row carries the uncovered class.
        assert!(s.contains("class=\"uncovered\""));
    }

    #[test]
    fn html_reporter_omits_coverage_when_unset() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("<section class=\"coverage\">"));
    }

    #[test]
    fn auditor_provenance_renders_when_metadata_is_present() {
        let mut r = Report::sample();
        r.metadata = Some(crate::report::RunMetadata::sample());
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<section class=\"provenance\">"));
        assert!(s.contains("Audit this run"));
        // Mode (live vs recorded) and the mcptest brand both land.
        assert!(s.contains("<dt>Mode</dt>"));
        assert!(s.contains("Generated by <strong>mcptest</strong>"));
    }

    #[test]
    fn auditor_provenance_is_absent_without_metadata() {
        let r = Report::sample();
        assert!(r.metadata.is_none());
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("<section class=\"provenance\">"));
        // The brand footer is always present, metadata or not.
        assert!(s.contains("Generated by <strong>mcptest</strong>"));
    }

    #[test]
    fn review_first_leads_with_failures() {
        let r = Report::sample();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<section class=\"review\">"));
        assert!(s.contains("Review first"));
        // The failing test from the sample is surfaced in the review section,
        // ahead of the full table.
        let review_at = s.find("Review first").unwrap();
        let table_at = s.find("<table class=\"sortable\">").unwrap();
        assert!(
            review_at < table_at,
            "review section must precede the table"
        );
    }

    #[test]
    fn review_first_is_absent_for_an_all_pass_run() {
        let r = Report::sample_all_pass();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("<section class=\"review\">"));
    }

    #[test]
    fn recorded_run_is_labeled_recorded_in_provenance() {
        let mut r = Report::sample_all_pass();
        r.metadata = Some(crate::report::RunMetadata::sample());
        r.tests[0].cache_hit = true;
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("recorded (replayed from cassettes)"));
    }

    #[test]
    fn html_reporter_emits_step_bar_chart_for_stepwise_tests() {
        let r = Report::sample_stepwise();
        let mut buf = Vec::new();
        HtmlReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("<details class=\"steps\">"));
        assert!(s.contains("<ol class=\"step-bars\">"));
        assert!(s.contains("create_issue"));
        assert!(s.contains("class=\"step-bar\""));
        // The slowest step (12 ms) should hit width:100%.
        assert!(s.contains("width:100%"));
    }
}
