Skip to main content

max / makenotwork

6.5 KB · 219 lines History Blame Raw
1 //! Test run and per-test detail storage, plus regression detection (tests that
2 //! passed in the previous run for a target and fail in the current one).
3
4 use super::{Result, SqlitePool, TestDetail, TestRun, TestRunId, TestSummary};
5 use tracing::instrument;
6
7 #[instrument(skip_all)]
8 pub async fn insert_test_run(pool: &SqlitePool, run: &TestRun) -> Result<TestRunId> {
9 let summary_json = serde_json::to_string(&run.summary).unwrap_or_default();
10
11 let result = sqlx::query(
12 "INSERT INTO test_runs (target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter)
13 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
14 )
15 .bind(&run.target)
16 .bind(&run.started_at)
17 .bind(&run.finished_at)
18 .bind(run.duration_secs)
19 .bind(run.exit_code)
20 .bind(run.passed)
21 .bind(&summary_json)
22 .bind(&run.raw_output)
23 .bind(&run.filter)
24 .execute(pool)
25 .await?;
26
27 Ok(TestRunId(result.last_insert_rowid()))
28 }
29
30 #[instrument(skip_all)]
31 pub async fn get_test_history(
32 pool: &SqlitePool,
33 target: Option<&str>,
34 limit: i64,
35 ) -> Result<Vec<TestRun>> {
36 let rows = match target {
37 Some(t) => {
38 sqlx::query_as::<_, TestRunRow>(
39 "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter
40 FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT ?",
41 )
42 .bind(t)
43 .bind(limit)
44 .fetch_all(pool)
45 .await?
46 }
47 None => {
48 sqlx::query_as::<_, TestRunRow>(
49 "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter
50 FROM test_runs ORDER BY id DESC LIMIT ?",
51 )
52 .bind(limit)
53 .fetch_all(pool)
54 .await?
55 }
56 };
57
58 Ok(rows.into_iter().map(TestRunRow::into_test_run).collect())
59 }
60
61 #[instrument(skip_all)]
62 pub async fn get_latest_test_run(pool: &SqlitePool, target: &str) -> Result<Option<TestRun>> {
63 let row = sqlx::query_as::<_, TestRunRow>(
64 "SELECT id, target, started_at, finished_at, duration_secs, exit_code, passed, summary_json, raw_output, filter
65 FROM test_runs WHERE target = ? ORDER BY id DESC LIMIT 1",
66 )
67 .bind(target)
68 .fetch_optional(pool)
69 .await?;
70
71 Ok(row.map(TestRunRow::into_test_run))
72 }
73
74 /// Insert per-test results for a given test run.
75 #[instrument(skip_all)]
76 pub async fn insert_test_details(
77 pool: &SqlitePool,
78 run_id: TestRunId,
79 details: &[TestDetail],
80 ) -> Result<()> {
81 // One transaction for the whole detail set: a crash mid-loop otherwise left a
82 // partial set, which skews get_test_regressions (fuzz-2026-07-06 crash
83 // atomicity). All-or-nothing.
84 let mut tx = pool.begin().await?;
85 for detail in details {
86 sqlx::query("INSERT INTO test_details (run_id, test_name, passed) VALUES (?, ?, ?)")
87 .bind(run_id.0)
88 .bind(&detail.test_name)
89 .bind(detail.passed)
90 .execute(&mut *tx)
91 .await?;
92 }
93 tx.commit().await?;
94 Ok(())
95 }
96
97 /// Find tests that passed in the previous run but failed in this one (regressions).
98 #[instrument(skip_all)]
99 pub async fn get_test_regressions(
100 pool: &SqlitePool,
101 target: &str,
102 current_run_id: TestRunId,
103 ) -> Result<Vec<String>> {
104 // Find the run immediately before this one for the same target
105 let prev_run = sqlx::query_as::<_, (i64,)>(
106 "SELECT id FROM test_runs
107 WHERE target = ? AND id < ?
108 ORDER BY id DESC LIMIT 1",
109 )
110 .bind(target)
111 .bind(current_run_id.0)
112 .fetch_optional(pool)
113 .await?;
114
115 let Some((prev_id,)) = prev_run else {
116 return Ok(vec![]);
117 };
118
119 // Tests that passed in prev run but failed in current run
120 let rows = sqlx::query_as::<_, (String,)>(
121 "SELECT curr.test_name FROM test_details curr
122 INNER JOIN test_details prev ON prev.test_name = curr.test_name AND prev.run_id = ?
123 WHERE curr.run_id = ? AND curr.passed = 0 AND prev.passed = 1",
124 )
125 .bind(prev_id)
126 .bind(current_run_id.0)
127 .fetch_all(pool)
128 .await?;
129
130 Ok(rows.into_iter().map(|r| r.0).collect())
131 }
132
133 /// Get test duration history for a target (most recent first).
134 #[instrument(skip_all)]
135 pub async fn get_test_durations(
136 pool: &SqlitePool,
137 target: &str,
138 limit: i64,
139 ) -> Result<Vec<(String, i64)>> {
140 let rows = sqlx::query_as::<_, (String, i64)>(
141 "SELECT started_at, duration_secs FROM test_runs
142 WHERE target = ? AND duration_secs IS NOT NULL
143 ORDER BY id DESC LIMIT ?",
144 )
145 .bind(target)
146 .bind(limit)
147 .fetch_all(pool)
148 .await?;
149 Ok(rows)
150 }
151
152 /// Get the version from the health check closest to (but before) a given timestamp.
153 #[instrument(skip_all)]
154 pub async fn get_version_at_time(
155 pool: &SqlitePool,
156 target: &str,
157 before_rfc3339: &str,
158 ) -> Result<Option<String>> {
159 let row = sqlx::query_as::<_, (Option<String>,)>(
160 "SELECT details_json FROM health_checks
161 WHERE target = ? AND checked_at <= ?
162 ORDER BY checked_at DESC LIMIT 1",
163 )
164 .bind(target)
165 .bind(before_rfc3339)
166 .fetch_optional(pool)
167 .await?;
168
169 let version = row
170 .and_then(|r| r.0)
171 .and_then(|json_str| serde_json::from_str::<serde_json::Value>(&json_str).ok())
172 .and_then(|json| {
173 json.get("version")
174 .and_then(|v| v.as_str())
175 .map(String::from)
176 });
177
178 Ok(version)
179 }
180
181 #[derive(sqlx::FromRow)]
182 struct TestRunRow {
183 id: i64,
184 target: String,
185 started_at: String,
186 finished_at: Option<String>,
187 duration_secs: Option<i64>,
188 exit_code: Option<i32>,
189 passed: bool,
190 summary_json: String,
191 raw_output: String,
192 filter: Option<String>,
193 }
194
195 impl TestRunRow {
196 fn into_test_run(self) -> TestRun {
197 let summary =
198 serde_json::from_str::<TestSummary>(&self.summary_json).unwrap_or(TestSummary {
199 steps: vec![],
200 total_passed: None,
201 total_failed: None,
202 details: vec![],
203 });
204
205 TestRun {
206 id: Some(self.id),
207 target: self.target,
208 started_at: self.started_at,
209 finished_at: self.finished_at,
210 duration_secs: self.duration_secs,
211 exit_code: self.exit_code,
212 passed: self.passed,
213 summary,
214 raw_output: self.raw_output,
215 filter: self.filter,
216 }
217 }
218 }
219