Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5cce8ea
Add public owned TerminalSnapshot with atomic live capture
donbeave Oct 25, 2026
f7acf83
Cover snapshot validation, corpus, and lock-atomic capture with tests
donbeave Oct 25, 2026
9e13fc1
Add owned_snapshot acceptance tests U02-A through U02-K
donbeave Oct 25, 2026
364719a
Fix bool assert lint and document try_new with an example
donbeave Oct 25, 2026
2045171
U10: add quiet waits with per-request cancellation
donbeave Oct 25, 2026
dd1ada0
U10: cover quiet waits and cancellation with U10-A through U10-J tests
donbeave Oct 25, 2026
c543e41
U10: apply cargo fmt to quiet-wait sources and tests
donbeave Oct 25, 2026
1742fba
U10: address review verdict (deadline tie, sample grid, lifecycle pre…
Oct 25, 2026
f7effb9
U10: fix quiet-wait lifecycle defects (R7, R8) with deterministic tes…
donbeave Oct 25, 2026
103d296
U10: harden quiet-wait determinism (R8 barrier, R25 request_waits har…
donbeave Oct 26, 2026
4030775
fix(snapshot): validate leading/continuation relations per row (U02-R2)
donbeave Oct 25, 2026
50823fe
fix(snapshot): reconcile live width with backend occupancy (U02-R2)
donbeave Oct 26, 2026
5472eea
U10: route quiet waits through the shared request wrapper (R3)
donbeave Oct 26, 2026
70fdad2
U10: finalize quiet-wait acquisition errors with full diagnostics (F3)
donbeave Oct 26, 2026
528bbc0
U10: write observation-free failure files; keep mid-Close acquisition…
donbeave Oct 26, 2026
68a9648
F3-A: write observation-free HTML report for Html/All modes
donbeave Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions crates/tui-test/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,252 @@ pub(crate) fn write_failure_artifact(
reference
}

/// Write a failure artifact without a terminal observation.
///
/// Some failures finalize while terminal state is unavailable: a quiet-wait
/// acquisition error returns while a lifecycle transition still holds the
/// session or emulator-state lock, so no observation can be captured without
/// blocking on that transition. The finalized report — operation, reason,
/// context, and history — is still exported as a manifest plus a text
/// report; every terminal-derived file is marked omitted and the missing
/// terminal evidence is stated explicitly. This function only renders and
/// writes files; it takes no lifecycle, session, or emulator-state locks.
fn escape_html(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(ch),
}
}
out
}

/// F3-A: observation-free HTML fallback report. Carries the finalized
/// operation, reason, context, and history with an explicit statement that
/// terminal evidence is unavailable. All embedded data is HTML-escaped;
/// the byte bound is enforced by the caller's `write_optional_file` limit.
fn observation_free_html(details: &FailureReport) -> String {
use std::fmt::Write as _;
let mut out = String::from(
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Failure report</title>\n</head>\n<body>\n<h1>Failure report</h1>\n<p>terminal evidence unavailable</p>\n",
);
let _ = writeln!(
out,
"<h2>Operation</h2>\n<p>{}</p>",
escape_html(&details.operation.name)
);
let _ = writeln!(
out,
"<h2>Reason</h2>\n<p>{}</p>",
escape_html(failure_reason_code(details.reason))
);
let _ = writeln!(
out,
"<h2>Summary</h2>\n<p>{}</p>",
escape_html(&details.summary)
);
out.push_str("<h2>Context</h2>\n");
if details.context.is_empty() {
out.push_str("<p>none</p>\n");
} else {
out.push_str("<dl>\n");
for (key, value) in &details.context {
let _ = writeln!(
out,
"<dt>{}</dt><dd>{}</dd>",
escape_html(key),
escape_html(value)
);
}
out.push_str("</dl>\n");
}
out.push_str("<h2>History</h2>\n");
if details.recent_operations.is_empty() {
out.push_str("<p>none</p>\n");
} else {
out.push_str("<ol>\n");
for event in &details.recent_operations {
let _ = writeln!(
out,
"<li>{} ({})</li>",
escape_html(&event.name),
escape_html(&event.result)
);
}
out.push_str("</ol>\n");
}
out.push_str("</body>\n</html>\n");
out
}

pub(crate) fn write_observation_free_artifact(
options: &FailureArtifactOptions,
details: &mut FailureReport,
directory: PathBuf,
) -> FailureArtifactRef {
let mut reference = FailureArtifactRef {
status: FailureArtifactStatus::Failed,
directory: directory.to_string_lossy().into_owned(),
manifest: None,
report: None,
report_html: None,
timeline: None,
screen_text: None,
screen_svg: None,
recording: None,
errors: Vec::new(),
};
if options.mode == FailureArtifactMode::None {
return reference;
}
const UNAVAILABLE: &str =
"terminal evidence unavailable: the failure finalized without a terminal observation";
reference.errors.push(UNAVAILABLE.to_string());
let mut files = Vec::new();
let mut total = 0u64;
if options.include_recording {
files.push(ArtifactFile {
kind: "recording".to_string(),
path: "session.cast".to_string(),
status: ArtifactFileStatus::Omitted,
bytes: None,
sha256: None,
reason: Some(UNAVAILABLE.to_string()),
});
}
if options.wants_text() {
files.push(ArtifactFile {
kind: "screen_text".to_string(),
path: "current.txt".to_string(),
status: ArtifactFileStatus::Omitted,
bytes: None,
sha256: None,
reason: Some(UNAVAILABLE.to_string()),
});
}
if options.wants_svg() {
files.push(ArtifactFile {
kind: "screen_svg".to_string(),
path: "current.svg".to_string(),
status: ArtifactFileStatus::Omitted,
bytes: None,
sha256: None,
reason: Some(UNAVAILABLE.to_string()),
});
}

let markdown_name = details.artifact_name("md");
let manifest_name = details.artifact_name("json");
if options.wants_markdown() {
let markdown = markdown::render(details, &files);
write_optional_file(
&directory,
"report",
&markdown_name,
markdown.as_bytes(),
REPORT_LIMIT as u64,
&mut total,
&mut files,
&mut reference.errors,
);
if files
.last()
.is_some_and(|file| file.status == ArtifactFileStatus::Written)
{
reference.report = Some(
directory
.join(&markdown_name)
.to_string_lossy()
.into_owned(),
);
}
}

// F3-A: observation-free HTML fallback. Mirrors the report_html file
// kind used with an observation, but carries only finalized details
// (operation, reason, context, history) since no terminal snapshot
// exists. Touches only `details` and the filesystem: no
// lifecycle/session/terminal-state locks are acquired.
if options.wants_html() {
let html_name = details.artifact_name("html");
let html = observation_free_html(details);
write_optional_file(
&directory,
"report_html",
&html_name,
html.as_bytes(),
HTML_LIMIT as u64,
&mut total,
&mut files,
&mut reference.errors,
);
if files
.last()
.is_some_and(|file| file.status == ArtifactFileStatus::Written)
{
reference.report_html = Some(directory.join(&html_name).to_string_lossy().into_owned());
}
}

if !options.wants_json() {
reference.status = if reference.report_html.is_some() {
FailureArtifactStatus::Partial
} else {
FailureArtifactStatus::Failed
};
return reference;
}

let sensitivity = sensitivity(details, &files);
let manifest = FailureArtifactManifest {
details: details.clone(),
sensitivity,
files,
errors: reference.errors.clone(),
};
let json = match serde_json::to_vec_pretty(&manifest) {
Ok(json) if json.len() <= FAILURE_JSON_LIMIT => json,
Ok(_) => {
reference
.errors
.push(format!("{manifest_name} exceeded the 2 MiB limit"));
return reference;
}
Err(error) => {
reference
.errors
.push(format!("failed to serialize {manifest_name}: {error}"));
return reference;
}
};
if total.saturating_add(json.len() as u64) > ARTIFACT_TOTAL_LIMIT {
reference.errors.push(format!(
"{manifest_name} would exceed the total artifact limit"
));
return reference;
}
let manifest_path = directory.join(&manifest_name);
match write_atomic(&manifest_path, &json) {
Ok(()) => {
reference.manifest = Some(manifest_path.to_string_lossy().into_owned());
reference.status = if reference.report.is_some() {
FailureArtifactStatus::Partial
} else {
FailureArtifactStatus::Failed
};
}
Err(error) => reference
.errors
.push(format!("failed to commit {manifest_name}: {error}")),
}
reference
}

#[allow(clippy::too_many_arguments)]
fn write_optional_file(
directory: &Path,
Expand Down
Loading