| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
use crate::db::{now_ms, Pool}; |
| 10 |
use crate::Shared; |
| 11 |
use serde::Deserialize; |
| 12 |
|
| 13 |
|
| 14 |
pub const MAX_FILES: usize = 2000; |
| 15 |
pub const MAX_BYTES: u64 = 512 << 20; |
| 16 |
|
| 17 |
const MAX_DECLARATION: usize = 64 << 10; |
| 18 |
|
| 19 |
const MAX_METRICS: usize = 200; |
| 20 |
|
| 21 |
pub const KINDS: [&str; 4] = ["coverage", "tests", "performance", "custom"]; |
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
pub fn path_ok(p: &str) -> bool { |
| 27 |
p.len() <= 1024 |
| 28 |
&& p.split('/').count() <= 32 |
| 29 |
&& p.split('/').all(|n| { |
| 30 |
!n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b)) |
| 31 |
}) |
| 32 |
} |
| 33 |
|
| 34 |
|
| 35 |
#[derive(Clone, Debug, Deserialize)] |
| 36 |
pub struct Manifest { |
| 37 |
pub version: u32, |
| 38 |
pub files: Vec<ManifestFile>, |
| 39 |
} |
| 40 |
|
| 41 |
#[derive(Clone, Debug, Deserialize)] |
| 42 |
pub struct ManifestFile { |
| 43 |
pub path: String, |
| 44 |
pub blob: String, |
| 45 |
pub size: u64, |
| 46 |
} |
| 47 |
|
| 48 |
impl Manifest { |
| 49 |
pub fn bytes(&self) -> u64 { |
| 50 |
self.files.iter().map(|f| f.size).sum() |
| 51 |
} |
| 52 |
|
| 53 |
|
| 54 |
pub fn blob(&self, path: &str) -> Option<&str> { |
| 55 |
self.files.iter().find(|f| f.path == path).map(|f| f.blob.as_str()) |
| 56 |
} |
| 57 |
} |
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
pub fn parse_manifest(bytes: &[u8]) -> Result<Manifest, String> { |
| 62 |
let m: Manifest = serde_json::from_slice(bytes).map_err(|e| format!("the report manifest is not valid: {e}"))?; |
| 63 |
if m.version != 1 { |
| 64 |
return Err(format!("report manifest version {} is not known; this hub reads version 1", m.version)); |
| 65 |
} |
| 66 |
if m.files.len() > MAX_FILES { |
| 67 |
return Err(format!("the report holds {} files; at most {MAX_FILES}", m.files.len())); |
| 68 |
} |
| 69 |
if m.bytes() > MAX_BYTES { |
| 70 |
return Err(format!("the report holds {} MiB; at most {} MiB", m.bytes() >> 20, MAX_BYTES >> 20)); |
| 71 |
} |
| 72 |
let mut seen = std::collections::HashSet::new(); |
| 73 |
for f in &m.files { |
| 74 |
if !path_ok(&f.path) { |
| 75 |
return Err(format!("report file {:?}: names may hold letters, digits, '.', '_', '-' and '+'", f.path)); |
| 76 |
} |
| 77 |
if !seen.insert(f.path.as_str()) { |
| 78 |
return Err(format!("report file {} is named twice", f.path)); |
| 79 |
} |
| 80 |
if oxy_core::hash::ObjectRef::from_hex(&f.blob).is_none() { |
| 81 |
return Err(format!("report file {}: {:?} is not a blob name", f.path, f.blob)); |
| 82 |
} |
| 83 |
} |
| 84 |
if m.blob("report.toml").is_none() { |
| 85 |
return Err("the report has no report.toml at its top".into()); |
| 86 |
} |
| 87 |
Ok(m) |
| 88 |
} |
| 89 |
|
| 90 |
|
| 91 |
#[derive(Clone, Debug, PartialEq)] |
| 92 |
pub struct Declaration { |
| 93 |
pub kind: String, |
| 94 |
pub title: String, |
| 95 |
pub summary: String, |
| 96 |
|
| 97 |
pub file: Option<String>, |
| 98 |
pub metrics: Vec<Metric>, |
| 99 |
|
| 100 |
pub context: serde_json::Value, |
| 101 |
} |
| 102 |
|
| 103 |
|
| 104 |
#[derive(Clone, Debug, PartialEq, sqlx::FromRow)] |
| 105 |
pub struct Metric { |
| 106 |
pub key: String, |
| 107 |
pub value: f64, |
| 108 |
pub unit: String, |
| 109 |
|
| 110 |
pub better: String, |
| 111 |
pub fail_above: Option<f64>, |
| 112 |
pub fail_below: Option<f64>, |
| 113 |
} |
| 114 |
|
| 115 |
impl Declaration { |
| 116 |
|
| 117 |
|
| 118 |
|
| 119 |
pub fn unreadable(name: &str, why: &str) -> Declaration { |
| 120 |
Declaration { |
| 121 |
kind: "custom".into(), |
| 122 |
title: name.into(), |
| 123 |
summary: format!("report.toml: {why}"), |
| 124 |
file: None, |
| 125 |
metrics: vec![], |
| 126 |
context: serde_json::json!({}), |
| 127 |
} |
| 128 |
} |
| 129 |
} |
| 130 |
|
| 131 |
fn number(v: &toml::Value) -> Option<f64> { |
| 132 |
match v { |
| 133 |
toml::Value::Integer(i) => Some(*i as f64), |
| 134 |
toml::Value::Float(f) if f.is_finite() => Some(*f), |
| 135 |
_ => None, |
| 136 |
} |
| 137 |
} |
| 138 |
|
| 139 |
fn metric_key_ok(k: &str) -> bool { |
| 140 |
!k.is_empty() && k.len() <= 64 && k.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) |
| 141 |
} |
| 142 |
|
| 143 |
|
| 144 |
pub fn parse_declaration(name: &str, bytes: &[u8]) -> Result<Declaration, String> { |
| 145 |
if bytes.len() > MAX_DECLARATION { |
| 146 |
return Err(format!("{} KiB; at most {} KiB", bytes.len() >> 10, MAX_DECLARATION >> 10)); |
| 147 |
} |
| 148 |
let text = std::str::from_utf8(bytes).map_err(|_| "not UTF-8 text".to_string())?; |
| 149 |
let table: toml::Table = toml::from_str(text).map_err(|e| { |
| 150 |
let line = e.span().map(|s| text[..s.start.min(text.len())].lines().count().max(1)); |
| 151 |
let what = if e.message().trim().is_empty() { "not valid TOML" } else { e.message().trim() }; |
| 152 |
match line { |
| 153 |
Some(n) => format!("line {n}: {what}"), |
| 154 |
None => what.to_string(), |
| 155 |
} |
| 156 |
})?; |
| 157 |
let text_of = |key: &str, max: usize| -> Result<Option<String>, String> { |
| 158 |
match table.get(key) { |
| 159 |
None => Ok(None), |
| 160 |
Some(toml::Value::String(s)) if s.len() <= max => Ok(Some(s.clone())), |
| 161 |
Some(toml::Value::String(_)) => Err(format!("`{key}` is longer than {max} bytes")), |
| 162 |
Some(_) => Err(format!("`{key}` is a string")), |
| 163 |
} |
| 164 |
}; |
| 165 |
for key in table.keys() { |
| 166 |
if !matches!(key.as_str(), "kind" | "title" | "summary" | "file" | "metrics" | "context") { |
| 167 |
return Err(format!("`{key}` is not known (kind, title, summary, file, metrics, context)")); |
| 168 |
} |
| 169 |
} |
| 170 |
let kind = text_of("kind", 32)?.ok_or("`kind` is required: one of coverage, tests, performance, custom")?; |
| 171 |
if !KINDS.contains(&kind.as_str()) { |
| 172 |
return Err(format!("kind {kind:?} is not known: one of coverage, tests, performance, custom")); |
| 173 |
} |
| 174 |
let file = text_of("file", 1024)?; |
| 175 |
if let Some(f) = &file { |
| 176 |
if !path_ok(f) { |
| 177 |
return Err(format!("file {f:?} is not a path in the report")); |
| 178 |
} |
| 179 |
} |
| 180 |
let mut metrics = Vec::new(); |
| 181 |
match table.get("metrics") { |
| 182 |
None => {} |
| 183 |
Some(toml::Value::Table(t)) => { |
| 184 |
if t.len() > MAX_METRICS { |
| 185 |
return Err(format!("{} metrics; at most {MAX_METRICS}", t.len())); |
| 186 |
} |
| 187 |
for (key, v) in t { |
| 188 |
if !metric_key_ok(key) { |
| 189 |
return Err(format!("metric {key:?}: names hold letters, digits, '.', '_' and '-'")); |
| 190 |
} |
| 191 |
metrics.push(parse_metric(key, v).map_err(|e| format!("metric {key}: {e}"))?); |
| 192 |
} |
| 193 |
} |
| 194 |
Some(_) => return Err("`metrics` is a table".into()), |
| 195 |
} |
| 196 |
let context = match table.get("context") { |
| 197 |
None => serde_json::json!({}), |
| 198 |
Some(v @ toml::Value::Table(_)) => serde_json::to_value(v).map_err(|e| format!("context: {e}"))?, |
| 199 |
Some(_) => return Err("`context` is a table".into()), |
| 200 |
}; |
| 201 |
Ok(Declaration { |
| 202 |
kind, |
| 203 |
title: text_of("title", 200)?.unwrap_or_else(|| name.to_string()), |
| 204 |
summary: text_of("summary", 1000)?.unwrap_or_default(), |
| 205 |
file, |
| 206 |
metrics, |
| 207 |
context, |
| 208 |
}) |
| 209 |
} |
| 210 |
|
| 211 |
fn parse_metric(key: &str, v: &toml::Value) -> Result<Metric, String> { |
| 212 |
let mut m = Metric { key: key.into(), value: 0.0, unit: String::new(), better: String::new(), fail_above: None, fail_below: None }; |
| 213 |
if let Some(n) = number(v) { |
| 214 |
m.value = n; |
| 215 |
return Ok(m); |
| 216 |
} |
| 217 |
let toml::Value::Table(t) = v else { |
| 218 |
return Err("a number, or a table with `value`".into()); |
| 219 |
}; |
| 220 |
for (k, v) in t { |
| 221 |
match k.as_str() { |
| 222 |
"value" => m.value = number(v).ok_or("`value` is a number")?, |
| 223 |
"unit" => match v { |
| 224 |
toml::Value::String(s) if s.len() <= 32 => m.unit = s.clone(), |
| 225 |
_ => return Err("`unit` is a short string".into()), |
| 226 |
}, |
| 227 |
"better" => match v.as_str() { |
| 228 |
Some(s @ ("higher" | "lower")) => m.better = s.into(), |
| 229 |
_ => return Err("`better` is \"higher\" or \"lower\"".into()), |
| 230 |
}, |
| 231 |
"fail_above" => m.fail_above = Some(number(v).ok_or("`fail_above` is a number")?), |
| 232 |
"fail_below" => m.fail_below = Some(number(v).ok_or("`fail_below` is a number")?), |
| 233 |
_ => return Err(format!("`{k}` is not known (value, unit, better, fail_above, fail_below)")), |
| 234 |
} |
| 235 |
} |
| 236 |
if !t.contains_key("value") { |
| 237 |
return Err("`value` is required".into()); |
| 238 |
} |
| 239 |
Ok(m) |
| 240 |
} |
| 241 |
|
| 242 |
|
| 243 |
pub struct Origin<'a> { |
| 244 |
pub repo_id: i64, |
| 245 |
pub run_id: i64, |
| 246 |
pub run_number: i64, |
| 247 |
pub job_id: i64, |
| 248 |
pub job: &'a str, |
| 249 |
pub bookmark: &'a str, |
| 250 |
pub state: &'a str, |
| 251 |
} |
| 252 |
|
| 253 |
|
| 254 |
pub async fn keep(app: &Shared, o: &Origin<'_>, name: &str, d: &Declaration, m: &Manifest, manifest: &str) -> sqlx::Result<i64> { |
| 255 |
let mut tx = crate::db::begin_write(&app.db).await?; |
| 256 |
sqlx::query("DELETE FROM reports WHERE job_id = $1 AND name = $2").bind(o.job_id).bind(name).execute(&mut *tx).await?; |
| 257 |
let id: i64 = sqlx::query_scalar( |
| 258 |
"INSERT INTO reports (repo_id, name, kind, title, summary, context, run_id, job_id, run_number, job, bookmark, state, \ |
| 259 |
manifest, files, bytes, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id", |
| 260 |
) |
| 261 |
.bind(o.repo_id) |
| 262 |
.bind(name) |
| 263 |
.bind(&d.kind) |
| 264 |
.bind(&d.title) |
| 265 |
.bind(&d.summary) |
| 266 |
.bind(d.context.to_string()) |
| 267 |
.bind(o.run_id) |
| 268 |
.bind(o.job_id) |
| 269 |
.bind(o.run_number) |
| 270 |
.bind(o.job) |
| 271 |
.bind(o.bookmark) |
| 272 |
.bind(o.state) |
| 273 |
.bind(manifest) |
| 274 |
.bind(m.files.len() as i64) |
| 275 |
.bind(m.bytes() as i64) |
| 276 |
.bind(now_ms()) |
| 277 |
.fetch_one(&mut *tx) |
| 278 |
.await?; |
| 279 |
for x in &d.metrics { |
| 280 |
sqlx::query( |
| 281 |
"INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above, fail_below) VALUES ($1, $2, $3, $4, $5, $6, $7)", |
| 282 |
) |
| 283 |
.bind(id) |
| 284 |
.bind(&x.key) |
| 285 |
.bind(x.value) |
| 286 |
.bind(&x.unit) |
| 287 |
.bind(&x.better) |
| 288 |
.bind(x.fail_above) |
| 289 |
.bind(x.fail_below) |
| 290 |
.execute(&mut *tx) |
| 291 |
.await?; |
| 292 |
} |
| 293 |
for f in &m.files { |
| 294 |
sqlx::query("INSERT INTO report_files (report_id, path, blob, size) VALUES ($1, $2, $3, $4)") |
| 295 |
.bind(id) |
| 296 |
.bind(&f.path) |
| 297 |
.bind(&f.blob) |
| 298 |
.bind(f.size as i64) |
| 299 |
.execute(&mut *tx) |
| 300 |
.await?; |
| 301 |
} |
| 302 |
tx.commit().await?; |
| 303 |
mark(app, o.repo_id); |
| 304 |
Ok(id) |
| 305 |
} |
| 306 |
|
| 307 |
static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None); |
| 308 |
|
| 309 |
|
| 310 |
fn key(app: &Shared, repo_id: i64) -> String { |
| 311 |
format!("{}#{repo_id}", app.repos.root().display()) |
| 312 |
} |
| 313 |
|
| 314 |
|
| 315 |
pub fn has_reports(app: &Shared, repo_id: i64) -> bool { |
| 316 |
HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id))) |
| 317 |
} |
| 318 |
|
| 319 |
fn mark(app: &Shared, repo_id: i64) { |
| 320 |
HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id)); |
| 321 |
} |
| 322 |
|
| 323 |
|
| 324 |
pub async fn load(app: &Shared) -> sqlx::Result<()> { |
| 325 |
let ids: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT repo_id FROM reports").fetch_all(&app.db).await?; |
| 326 |
for id in ids { |
| 327 |
mark(app, id); |
| 328 |
} |
| 329 |
Ok(()) |
| 330 |
} |
| 331 |
|
| 332 |
|
| 333 |
#[derive(Clone, Debug, sqlx::FromRow)] |
| 334 |
pub struct Report { |
| 335 |
pub id: i64, |
| 336 |
pub repo_id: i64, |
| 337 |
pub name: String, |
| 338 |
pub kind: String, |
| 339 |
pub title: String, |
| 340 |
pub summary: String, |
| 341 |
|
| 342 |
pub context: String, |
| 343 |
pub run_id: Option<i64>, |
| 344 |
pub job_id: Option<i64>, |
| 345 |
pub run_number: Option<i64>, |
| 346 |
pub job: String, |
| 347 |
pub bookmark: String, |
| 348 |
pub state: String, |
| 349 |
pub manifest: Option<String>, |
| 350 |
pub files: i64, |
| 351 |
pub bytes: i64, |
| 352 |
pub created_ms: i64, |
| 353 |
|
| 354 |
pub files_expired_ms: Option<i64>, |
| 355 |
} |
| 356 |
|
| 357 |
impl Report { |
| 358 |
|
| 359 |
pub fn context_rows(&self) -> Vec<(String, String)> { |
| 360 |
let v: serde_json::Value = serde_json::from_str(&self.context).unwrap_or_default(); |
| 361 |
v.as_object() |
| 362 |
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().map(String::from).unwrap_or_else(|| v.to_string()))).collect()) |
| 363 |
.unwrap_or_default() |
| 364 |
} |
| 365 |
} |
| 366 |
|
| 367 |
|
| 368 |
|
| 369 |
pub async fn latest(pool: &Pool, repo_id: i64, bookmark: Option<&str>) -> sqlx::Result<Vec<Report>> { |
| 370 |
sqlx::query_as( |
| 371 |
"SELECT * FROM reports r WHERE repo_id = $1 AND ($2 IS NULL OR bookmark = $3) \ |
| 372 |
AND id = (SELECT id FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND ($4 IS NULL OR s.bookmark = $5) \ |
| 373 |
ORDER BY created_ms DESC, id DESC LIMIT 1) \ |
| 374 |
ORDER BY created_ms DESC", |
| 375 |
) |
| 376 |
.bind(repo_id) |
| 377 |
.bind(bookmark) |
| 378 |
.bind(bookmark) |
| 379 |
.bind(bookmark) |
| 380 |
.bind(bookmark) |
| 381 |
.fetch_all(pool) |
| 382 |
.await |
| 383 |
} |
| 384 |
|
| 385 |
|
| 386 |
pub async fn history(pool: &Pool, repo_id: i64, name: &str, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> { |
| 387 |
sqlx::query_as("SELECT * FROM reports WHERE repo_id = $1 AND name = $2 AND bookmark = $3 ORDER BY created_ms DESC, id DESC LIMIT $4") |
| 388 |
.bind(repo_id) |
| 389 |
.bind(name) |
| 390 |
.bind(bookmark) |
| 391 |
.bind(limit) |
| 392 |
.fetch_all(pool) |
| 393 |
.await |
| 394 |
} |
| 395 |
|
| 396 |
|
| 397 |
pub async fn bookmarks_of(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Vec<String>> { |
| 398 |
sqlx::query_scalar("SELECT bookmark FROM reports WHERE repo_id = $1 AND name = $2 GROUP BY bookmark ORDER BY MAX(created_ms) DESC") |
| 399 |
.bind(repo_id) |
| 400 |
.bind(name) |
| 401 |
.fetch_all(pool) |
| 402 |
.await |
| 403 |
} |
| 404 |
|
| 405 |
|
| 406 |
pub async fn get(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> { |
| 407 |
sqlx::query_as("SELECT * FROM reports WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).fetch_optional(pool).await |
| 408 |
} |
| 409 |
|
| 410 |
|
| 411 |
pub async fn file(pool: &Pool, id: i64, path: &str) -> sqlx::Result<Option<(String, i64)>> { |
| 412 |
sqlx::query_as("SELECT blob, size FROM report_files WHERE report_id = $1 AND path = $2").bind(id).bind(path).fetch_optional(pool).await |
| 413 |
} |
| 414 |
|
| 415 |
|
| 416 |
|
| 417 |
|
| 418 |
pub fn file_mac(app: &Shared, id: i64) -> String { |
| 419 |
app.cookie_key.mac(format!("report-files:{id}").as_bytes())[..32].to_string() |
| 420 |
} |
| 421 |
|
| 422 |
|
| 423 |
|
| 424 |
pub const KEEP_OTHER_RUNS: i64 = 50; |
| 425 |
|
| 426 |
|
| 427 |
|
| 428 |
|
| 429 |
|
| 430 |
|
| 431 |
|
| 432 |
pub async fn expire(pool: &Pool) -> sqlx::Result<u64> { |
| 433 |
let mut tx = crate::db::begin_write(pool).await?; |
| 434 |
let ids: Vec<i64> = sqlx::query_scalar( |
| 435 |
"SELECT r.id FROM reports r JOIN repositories p ON p.id = r.repo_id \ |
| 436 |
WHERE r.files_expired_ms IS NULL AND r.bookmark != p.default_bookmark \ |
| 437 |
AND (SELECT COUNT(*) FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND s.bookmark = r.bookmark \ |
| 438 |
AND (s.created_ms > r.created_ms OR (s.created_ms = r.created_ms AND s.id > r.id))) >= $1", |
| 439 |
) |
| 440 |
.bind(KEEP_OTHER_RUNS) |
| 441 |
.fetch_all(&mut *tx) |
| 442 |
.await?; |
| 443 |
let now = now_ms(); |
| 444 |
for id in &ids { |
| 445 |
sqlx::query("DELETE FROM report_files WHERE report_id = $1").bind(id).execute(&mut *tx).await?; |
| 446 |
sqlx::query("UPDATE reports SET files_expired_ms = $1 WHERE id = $2").bind(now).bind(id).execute(&mut *tx).await?; |
| 447 |
} |
| 448 |
tx.commit().await?; |
| 449 |
Ok(ids.len() as u64) |
| 450 |
} |
| 451 |
|
| 452 |
|
| 453 |
pub async fn metrics(pool: &Pool, id: i64) -> sqlx::Result<Vec<Metric>> { |
| 454 |
sqlx::query_as("SELECT key, value, unit, better, fail_above, fail_below FROM report_metrics WHERE report_id = $1 ORDER BY key") |
| 455 |
.bind(id) |
| 456 |
.fetch_all(pool) |
| 457 |
.await |
| 458 |
} |
| 459 |
|
| 460 |
|
| 461 |
|
| 462 |
pub async fn file_blobs(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> { |
| 463 |
sqlx::query_scalar("SELECT DISTINCT f.blob FROM report_files f JOIN reports r ON r.id = f.report_id WHERE r.repo_id = $1") |
| 464 |
.bind(repo_id) |
| 465 |
.fetch_all(pool) |
| 466 |
.await |
| 467 |
} |
| 468 |
|
| 469 |
#[cfg(test)] |
| 470 |
mod tests { |
| 471 |
use super::*; |
| 472 |
|
| 473 |
#[test] |
| 474 |
fn declarations_are_read_with_their_metrics_and_context() { |
| 475 |
let d = parse_declaration( |
| 476 |
"frame-timing", |
| 477 |
br#" |
| 478 |
kind = "performance" |
| 479 |
title = "Frame timing" |
| 480 |
summary = "Median frame 16.4 ms" |
| 481 |
|
| 482 |
[metrics] |
| 483 |
median_frame_ms = { value = 16.4, unit = "ms", better = "lower" } |
| 484 |
p99_frame_ms = { value = 18.1, unit = "ms", better = "lower", fail_above = 20 } |
| 485 |
scenes = 12 |
| 486 |
|
| 487 |
[context] |
| 488 |
target_fps = 30 |
| 489 |
budget_cycles = 268800 |
| 490 |
"#, |
| 491 |
) |
| 492 |
.unwrap(); |
| 493 |
assert_eq!((d.kind.as_str(), d.title.as_str()), ("performance", "Frame timing")); |
| 494 |
let keys: Vec<&str> = d.metrics.iter().map(|m| m.key.as_str()).collect(); |
| 495 |
assert_eq!(keys, ["median_frame_ms", "p99_frame_ms", "scenes"]); |
| 496 |
assert_eq!(d.metrics[1].fail_above, Some(20.0)); |
| 497 |
assert_eq!((d.metrics[2].value, d.metrics[2].unit.as_str()), (12.0, "")); |
| 498 |
assert_eq!(d.context["budget_cycles"], 268800); |
| 499 |
|
| 500 |
let minimal = parse_declaration("cov", b"kind = \"coverage\"\nfile = \"lcov.info\"\n").unwrap(); |
| 501 |
assert_eq!((minimal.title.as_str(), minimal.file.as_deref()), ("cov", Some("lcov.info"))); |
| 502 |
|
| 503 |
let bad = |t: &str| parse_declaration("r", t.as_bytes()).unwrap_err(); |
| 504 |
assert!(bad("title = \"x\"").contains("`kind` is required")); |
| 505 |
assert!(bad("kind = \"graphs\"").contains("not known")); |
| 506 |
assert!(bad("kind = \"custom\"\ntitel = \"x\"").contains("`titel` is not known")); |
| 507 |
assert!(bad("kind = \"custom\"\n[metrics]\nfps = \"fast\"").contains("metric fps")); |
| 508 |
assert!(bad("kind = \"custom\"\n[metrics]\nfps = { value = 1, better = \"more\" }").contains("better")); |
| 509 |
assert!(bad("kind = \"custom\"\n[metrics]\nfps = { unit = \"Hz\" }").contains("`value` is required")); |
| 510 |
assert!(bad("kind = \"custom\"\ntitle = ").starts_with("line 2"), "{}", bad("kind = \"custom\"\ntitle = ")); |
| 511 |
} |
| 512 |
|
| 513 |
#[test] |
| 514 |
fn manifests_are_checked() { |
| 515 |
let blob = "a".repeat(64); |
| 516 |
let m = |files: &str| parse_manifest(format!("{{\"version\":1,\"files\":[{files}]}}").as_bytes()); |
| 517 |
let file = |p: &str| format!("{{\"path\":\"{p}\",\"blob\":\"{blob}\",\"size\":3}}"); |
| 518 |
let ok = m(&[file("report.toml"), file("data/a.csv")].join(",")).unwrap(); |
| 519 |
assert_eq!((ok.files.len(), ok.bytes()), (2, 6)); |
| 520 |
assert!(m(&file("index.html")).unwrap_err().contains("report.toml")); |
| 521 |
assert!(m(&[file("report.toml"), file("../x")].join(",")).is_err()); |
| 522 |
assert!(m(&[file("report.toml"), file("a b")].join(",")).is_err()); |
| 523 |
assert!(m(&[file("report.toml"), file("report.toml")].join(",")).unwrap_err().contains("twice")); |
| 524 |
assert!(m("{\"path\":\"report.toml\",\"blob\":\"zz\",\"size\":1}").unwrap_err().contains("blob")); |
| 525 |
assert!(parse_manifest(b"{\"version\":2,\"files\":[]}").unwrap_err().contains("version 2")); |
| 526 |
assert!(path_ok("assets/js/chart.min.js") && !path_ok("/abs") && !path_ok("a//b") && !path_ok("")); |
| 527 |
} |
| 528 |
} |