Skip to content

Commit 17a2e01

Browse files
authored
[integration-tests] assert on problem list (#82)
Previously we would only assert on the final status in our tests. But we care about which APIs had problems detected with them. Exposing the full `Problem` type is very complex, so we define a `ProblemKind` enum that's just the discriminant. Also fix a `read_dir` ordering issue detected in CI.
1 parent 3f03acc commit 17a2e01

8 files changed

Lines changed: 1056 additions & 78 deletions

File tree

crates/dropshot-api-manager/src/cmd/check.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
CheckResult, OutputOpts, display_load_problems, display_resolution,
88
headers::*,
99
},
10-
resolved::Resolved,
10+
resolved::{ProblemSummary, Resolved},
1111
};
1212

1313
pub(crate) fn check_impl(
@@ -17,6 +17,23 @@ pub(crate) fn check_impl(
1717
generated_source: &GeneratedSource,
1818
output: &OutputOpts,
1919
) -> anyhow::Result<CheckResult> {
20+
let (result, _summaries) = check_impl_with_summaries(
21+
apis,
22+
env,
23+
blessed_source,
24+
generated_source,
25+
output,
26+
)?;
27+
Ok(result)
28+
}
29+
30+
pub(crate) fn check_impl_with_summaries(
31+
apis: &ManagedApis,
32+
env: &ResolvedEnv,
33+
blessed_source: &BlessedSource,
34+
generated_source: &GeneratedSource,
35+
output: &OutputOpts,
36+
) -> anyhow::Result<(CheckResult, Vec<ProblemSummary>)> {
2037
let styles = output.styles(supports_color::Stream::Stderr);
2138

2239
eprintln!("{:>HEADER_WIDTH$}", SEPARATOR);
@@ -38,6 +55,9 @@ pub(crate) fn check_impl(
3855
eprintln!("{:>HEADER_WIDTH$}", SEPARATOR);
3956
let result = display_resolution(env, apis, &resolved, &styles)?;
4057

58+
// Extract owned summaries before dropping the borrowed resolved state.
59+
let summaries = resolved.problem_summaries();
60+
4161
// Release borrows held by `resolved`, then drop the source
4262
// collections in parallel. Each contains many parsed OpenAPI
4363
// documents whose sequential drops are costly.
@@ -48,5 +68,5 @@ pub(crate) fn check_impl(
4868
s.spawn(|| drop(local_files));
4969
});
5070

51-
Ok(result)
71+
Ok((result, summaries))
5272
}

crates/dropshot-api-manager/src/resolved.rs

Lines changed: 204 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,77 @@ impl Display for ResolutionKind {
132132
}
133133
}
134134

135+
/// Identifies the kind of a `Problem` without carrying borrowed data.
136+
///
137+
/// Each variant corresponds 1:1 to a `Problem` variant. The exhaustive
138+
/// match in `Problem::kind` ensures that adding a new `Problem` variant
139+
/// without updating this enum causes a compile error.
140+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
141+
#[expect(missing_docs)]
142+
pub enum ProblemKind {
143+
LocalSpecFileOrphaned,
144+
UnparseableLocalFile,
145+
BlessedVersionMissingLocal,
146+
BlessedVersionExtraLocalSpec,
147+
BlessedVersionCompareError,
148+
BlessedVersionBroken,
149+
BlessedLatestVersionBytewiseMismatch,
150+
LockstepMissingLocal,
151+
LockstepStale,
152+
LocalVersionMissingLocal,
153+
LocalVersionExtra,
154+
LocalVersionStale,
155+
GeneratedSourceMissing,
156+
GeneratedValidationError,
157+
ExtraFileStale,
158+
LatestLinkMissing,
159+
LatestLinkStale,
160+
BlessedVersionShouldBeGitStub,
161+
GitStubShouldBeJson,
162+
BlessedVersionCorruptedLocal,
163+
DuplicateLocalFile,
164+
GitStubCommitStale,
165+
GitStubFirstCommitUnknown,
166+
}
167+
168+
/// Owned summary of a `Problem` for test assertions.
169+
///
170+
/// Contains just enough information to identify a problem: which API it
171+
/// belongs to, which version (if any), and its [`ProblemKind`]. Because all
172+
/// fields are owned and implement `PartialEq`, summaries can be compared
173+
/// with `assert_eq!`.
174+
#[derive(Clone, Debug, Eq, PartialEq)]
175+
pub struct ProblemSummary {
176+
/// The API this problem is associated with.
177+
pub api_ident: ApiIdent,
178+
/// The version this problem is associated with, or `None` for
179+
/// non-version-specific problems (e.g. orphaned files, symlinks).
180+
pub version: Option<semver::Version>,
181+
/// The kind of problem.
182+
pub kind: ProblemKind,
183+
}
184+
185+
impl ProblemSummary {
186+
/// Creates a new problem summary for a version-specific problem.
187+
pub fn new(api_ident: &str, version: &str, kind: ProblemKind) -> Self {
188+
ProblemSummary {
189+
api_ident: ApiIdent::from(api_ident),
190+
version: Some(version.parse().expect("valid semver")),
191+
kind,
192+
}
193+
}
194+
195+
/// Creates a new problem summary for a non-version-specific problem
196+
/// (e.g. symlink issues, orphaned files).
197+
pub fn for_api(api_ident: &str, kind: ProblemKind) -> Self {
198+
ProblemSummary {
199+
api_ident: ApiIdent::from(api_ident),
200+
version: None,
201+
kind,
202+
}
203+
}
204+
}
205+
135206
/// Describes a problem resolving the blessed spec(s), generated spec(s), and
136207
/// local spec files for a particular API.
137208
#[derive(Debug, Error)]
@@ -363,6 +434,72 @@ pub enum Problem<'a> {
363434
}
364435

365436
impl<'a> Problem<'a> {
437+
/// Returns the discriminant of this problem as a [`ProblemKind`].
438+
///
439+
/// The match is exhaustive (no wildcard), so adding a new `Problem`
440+
/// variant without updating this method causes a compile error.
441+
pub fn kind(&self) -> ProblemKind {
442+
match self {
443+
Problem::LocalSpecFileOrphaned { .. } => {
444+
ProblemKind::LocalSpecFileOrphaned
445+
}
446+
Problem::UnparseableLocalFile { .. } => {
447+
ProblemKind::UnparseableLocalFile
448+
}
449+
Problem::BlessedVersionMissingLocal { .. } => {
450+
ProblemKind::BlessedVersionMissingLocal
451+
}
452+
Problem::BlessedVersionExtraLocalSpec { .. } => {
453+
ProblemKind::BlessedVersionExtraLocalSpec
454+
}
455+
Problem::BlessedVersionCompareError { .. } => {
456+
ProblemKind::BlessedVersionCompareError
457+
}
458+
Problem::BlessedVersionBroken { .. } => {
459+
ProblemKind::BlessedVersionBroken
460+
}
461+
Problem::BlessedLatestVersionBytewiseMismatch { .. } => {
462+
ProblemKind::BlessedLatestVersionBytewiseMismatch
463+
}
464+
Problem::LockstepMissingLocal { .. } => {
465+
ProblemKind::LockstepMissingLocal
466+
}
467+
Problem::LockstepStale { .. } => ProblemKind::LockstepStale,
468+
Problem::LocalVersionMissingLocal { .. } => {
469+
ProblemKind::LocalVersionMissingLocal
470+
}
471+
Problem::LocalVersionExtra { .. } => ProblemKind::LocalVersionExtra,
472+
Problem::LocalVersionStale { .. } => ProblemKind::LocalVersionStale,
473+
Problem::GeneratedSourceMissing { .. } => {
474+
ProblemKind::GeneratedSourceMissing
475+
}
476+
Problem::GeneratedValidationError { .. } => {
477+
ProblemKind::GeneratedValidationError
478+
}
479+
Problem::ExtraFileStale { .. } => ProblemKind::ExtraFileStale,
480+
Problem::LatestLinkMissing { .. } => ProblemKind::LatestLinkMissing,
481+
Problem::LatestLinkStale { .. } => ProblemKind::LatestLinkStale,
482+
Problem::BlessedVersionShouldBeGitStub { .. } => {
483+
ProblemKind::BlessedVersionShouldBeGitStub
484+
}
485+
Problem::GitStubShouldBeJson { .. } => {
486+
ProblemKind::GitStubShouldBeJson
487+
}
488+
Problem::BlessedVersionCorruptedLocal { .. } => {
489+
ProblemKind::BlessedVersionCorruptedLocal
490+
}
491+
Problem::DuplicateLocalFile { .. } => {
492+
ProblemKind::DuplicateLocalFile
493+
}
494+
Problem::GitStubCommitStale { .. } => {
495+
ProblemKind::GitStubCommitStale
496+
}
497+
Problem::GitStubFirstCommitUnknown { .. } => {
498+
ProblemKind::GitStubFirstCommitUnknown
499+
}
500+
}
501+
}
502+
366503
pub fn is_fixable(&self) -> bool {
367504
self.fix().is_some()
368505
}
@@ -914,7 +1051,7 @@ fn symlink_file(target: &str, path: &Utf8Path) -> std::io::Result<()> {
9141051
/// local spec files for a given API
9151052
pub struct Resolved<'a> {
9161053
notes: Vec<Note>,
917-
non_version_problems: Vec<Problem<'a>>,
1054+
non_version_problems: Vec<(ApiIdent, Option<semver::Version>, Problem<'a>)>,
9181055
api_results: BTreeMap<ApiIdent, ApiResolved<'a>>,
9191056
nexpected_documents: usize,
9201057
}
@@ -961,12 +1098,23 @@ impl<'a> Resolved<'a> {
9611098
// Get the other easy case out of the way: if there are any local spec
9621099
// files for APIs or API versions that aren't supported any more, that's
9631100
// a (fixable) problem.
964-
let mut non_version_problems: Vec<Problem<'_>> =
965-
resolve_orphaned_local_specs(&supported_versions_by_api, local)
966-
.map(|spec_file_name| Problem::LocalSpecFileOrphaned {
967-
spec_file_name: spec_file_name.clone(),
968-
})
969-
.collect();
1101+
let mut non_version_problems: Vec<(
1102+
ApiIdent,
1103+
Option<semver::Version>,
1104+
Problem<'_>,
1105+
)> = resolve_orphaned_local_specs(&supported_versions_by_api, local)
1106+
.map(|spec_file_name| {
1107+
let ident = spec_file_name.ident().clone();
1108+
let version = Some(spec_file_name.version().clone());
1109+
(
1110+
ident,
1111+
version,
1112+
Problem::LocalSpecFileOrphaned {
1113+
spec_file_name: spec_file_name.clone(),
1114+
},
1115+
)
1116+
})
1117+
.collect();
9701118

9711119
// Resolve each of the supported API versions first, so we know what
9721120
// paths will be written. (Do this in parallel across each API version.)
@@ -1039,13 +1187,17 @@ impl<'a> Resolved<'a> {
10391187
}
10401188
}
10411189

1042-
for (_ident, api_files) in local.iter() {
1190+
for (ident, api_files) in local.iter() {
10431191
for unparseable in api_files.unparseable_files() {
10441192
// Only report if no fix will overwrite this path.
10451193
if !paths_written.contains(&unparseable.path) {
1046-
non_version_problems.push(Problem::UnparseableLocalFile {
1047-
unparseable_file: unparseable.clone(),
1048-
});
1194+
non_version_problems.push((
1195+
ident.clone(),
1196+
None,
1197+
Problem::UnparseableLocalFile {
1198+
unparseable_file: unparseable.clone(),
1199+
},
1200+
));
10491201
}
10501202
}
10511203
}
@@ -1067,7 +1219,7 @@ impl<'a> Resolved<'a> {
10671219
}
10681220

10691221
pub fn general_problems(&self) -> impl Iterator<Item = &Problem<'a>> + '_ {
1070-
self.non_version_problems.iter()
1222+
self.non_version_problems.iter().map(|(_, _, problem)| problem)
10711223
}
10721224

10731225
pub fn resolution_for_api_version(
@@ -1086,6 +1238,46 @@ impl<'a> Resolved<'a> {
10861238
self.general_problems().any(|p| !p.is_fixable())
10871239
|| self.api_results.values().any(|a| a.has_unfixable_problems())
10881240
}
1241+
1242+
/// Returns an owned, ordered list of all problems as summaries.
1243+
///
1244+
/// Order: general (non-version-specific) problems first, then per-API
1245+
/// (sorted by ident), per-version (sorted by semver), then symlink
1246+
/// problems.
1247+
pub fn problem_summaries(&self) -> Vec<ProblemSummary> {
1248+
let mut summaries = Vec::new();
1249+
1250+
// General problems.
1251+
for (ident, version, problem) in &self.non_version_problems {
1252+
summaries.push(ProblemSummary {
1253+
api_ident: ident.clone(),
1254+
version: version.clone(),
1255+
kind: problem.kind(),
1256+
});
1257+
}
1258+
1259+
// Per-API problems.
1260+
for (ident, api_resolved) in &self.api_results {
1261+
for (version, resolution) in &api_resolved.by_version {
1262+
for problem in resolution.problems() {
1263+
summaries.push(ProblemSummary {
1264+
api_ident: ident.clone(),
1265+
version: Some(version.clone()),
1266+
kind: problem.kind(),
1267+
});
1268+
}
1269+
}
1270+
if let Some(symlink) = &api_resolved.symlink {
1271+
summaries.push(ProblemSummary {
1272+
api_ident: ident.clone(),
1273+
version: None,
1274+
kind: symlink.kind(),
1275+
});
1276+
}
1277+
}
1278+
1279+
summaries
1280+
}
10891281
}
10901282

10911283
struct ApiResolved<'a> {

crates/dropshot-api-manager/src/spec_files_local.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,17 +299,22 @@ fn discover_local_entries(
299299
let top_iter =
300300
dir.read_dir_utf8().with_context(|| format!("readdir {:?}", dir))?;
301301

302+
// Collect and sort by file name to ensure deterministic ordering
303+
// across platforms and filesystems.
304+
let mut top_entries = Vec::new();
302305
for maybe_entry in top_iter {
303-
let entry = match maybe_entry {
304-
Ok(e) => e,
306+
match maybe_entry {
307+
Ok(e) => top_entries.push(e),
305308
Err(error) => {
306309
entries.push(LocalDiscoveredEntry::Error(
307310
anyhow!(error).context(format!("readdir {:?} entry", dir)),
308311
));
309-
continue;
310312
}
311-
};
313+
}
314+
}
315+
top_entries.sort_by(|a, b| a.file_name().cmp(b.file_name()));
312316

317+
for entry in top_entries {
313318
let path = entry.path().to_owned();
314319
let file_name = entry.file_name().to_owned();
315320
let file_type = match entry.file_type() {
@@ -345,7 +350,7 @@ fn discover_versioned_directory(
345350
path: &Utf8Path,
346351
dir_basename: &str,
347352
) {
348-
let sub_entries = match path
353+
let mut sub_entries = match path
349354
.read_dir_utf8()
350355
.and_then(|iter| iter.collect::<Result<Vec<_>, _>>())
351356
{
@@ -357,6 +362,9 @@ fn discover_versioned_directory(
357362
return;
358363
}
359364
};
365+
// Sort by file name to ensure deterministic ordering across
366+
// platforms and filesystems.
367+
sub_entries.sort_by(|a, b| a.file_name().cmp(b.file_name()));
360368

361369
// Construct a temporary ApiIdent so we can use its canonical
362370
// symlink-detection method. This ident is not validated against the

0 commit comments

Comments
 (0)