Skip to main content

ic_testkit/pic/
diagnostics.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    time::{Duration, Instant},
5};
6
7use candid::Principal;
8use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
9
10use super::{PocketIcOperationError, transport};
11
12/// Default maximum number of canister-log records retained in diagnostics.
13pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
14
15/// Default maximum aggregate raw canister-log bytes retained in diagnostics.
16pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
17
18/// Explicit bounds applied when canister logs are converted to diagnostic text.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CanisterLogRenderLimits {
21    record_limit: usize,
22    byte_limit: usize,
23}
24
25impl CanisterLogRenderLimits {
26    /// Set exact record and aggregate raw-content byte limits.
27    ///
28    /// Zero is valid for either limit and retains no content in that dimension.
29    #[must_use]
30    pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
31        Self {
32            record_limit,
33            byte_limit,
34        }
35    }
36
37    /// Maximum number of retained records.
38    #[must_use]
39    pub const fn record_limit(self) -> usize {
40        self.record_limit
41    }
42
43    /// Maximum aggregate number of retained raw content bytes.
44    #[must_use]
45    pub const fn byte_limit(self) -> usize {
46        self.byte_limit
47    }
48}
49
50impl Default for CanisterLogRenderLimits {
51    fn default() -> Self {
52        Self::new(
53            DEFAULT_CANISTER_LOG_RECORD_LIMIT,
54            DEFAULT_CANISTER_LOG_BYTE_LIMIT,
55        )
56    }
57}
58
59/// Exact controller-aware inputs for one best-effort diagnostic collection.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct CanisterDiagnosticsRequest {
62    canister_id: Principal,
63    status_sender: Principal,
64    log_sender: Principal,
65    log_limits: CanisterLogRenderLimits,
66}
67
68impl CanisterDiagnosticsRequest {
69    /// Create a request with independent, exact status and log senders.
70    ///
71    /// Anonymous access remains available only by explicitly supplying
72    /// [`Principal::anonymous`] for the corresponding operation.
73    #[must_use]
74    pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
75        Self {
76            canister_id,
77            status_sender,
78            log_sender,
79            log_limits: CanisterLogRenderLimits::default(),
80        }
81    }
82
83    /// Override the bounds used to retain and render fetched log content.
84    #[must_use]
85    pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
86        self.log_limits = limits;
87        self
88    }
89
90    /// Target canister.
91    #[must_use]
92    pub const fn canister_id(self) -> Principal {
93        self.canister_id
94    }
95
96    /// Exact sender supplied to `canister_status`.
97    #[must_use]
98    pub const fn status_sender(self) -> Principal {
99        self.status_sender
100    }
101
102    /// Exact sender supplied to `fetch_canister_logs`.
103    #[must_use]
104    pub const fn log_sender(self) -> Principal {
105        self.log_sender
106    }
107
108    /// Log rendering bounds.
109    #[must_use]
110    pub const fn log_limits(self) -> CanisterLogRenderLimits {
111        self.log_limits
112    }
113}
114
115/// One caller-labeled exact diagnostic request in a collect-all batch.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct LabeledCanisterDiagnosticsRequest {
118    label: String,
119    request: CanisterDiagnosticsRequest,
120}
121
122/// Structural error that prevents a diagnostics batch from starting.
123#[non_exhaustive]
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub enum CanisterDiagnosticsBatchContractError {
126    /// An entry label was empty.
127    EmptyLabel {
128        /// Zero-based position of the invalid entry.
129        index: usize,
130    },
131    /// Two entries used the same label.
132    DuplicateLabel {
133        /// Duplicated caller label.
134        label: String,
135        /// Position where the label first appeared.
136        first_index: usize,
137        /// Position where the label was repeated.
138        duplicate_index: usize,
139    },
140}
141
142impl LabeledCanisterDiagnosticsRequest {
143    /// Attach a stable caller-facing label to an exact diagnostic request.
144    #[must_use]
145    pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
146        Self {
147            label: label.into(),
148            request,
149        }
150    }
151
152    /// Caller-supplied label retained in the batch report.
153    #[must_use]
154    pub fn label(&self) -> &str {
155        &self.label
156    }
157
158    /// Exact controller-aware request.
159    #[must_use]
160    pub const fn request(&self) -> CanisterDiagnosticsRequest {
161        self.request
162    }
163
164    /// Consume the labeled request into its caller label and exact request.
165    #[must_use]
166    pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
167        (self.label, self.request)
168    }
169}
170
171/// A failed best-effort PocketIC diagnostic call.
172#[non_exhaustive]
173#[derive(Debug)]
174pub enum CanisterDiagnosticFailure {
175    /// PocketIC returned a structured management-canister rejection.
176    Rejected(RejectResponse),
177    /// Captured PocketIC panic, retaining its original transport classification.
178    Operation(PocketIcOperationError),
179}
180
181impl fmt::Display for CanisterDiagnosticFailure {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
185            Self::Operation(source) if source.is_transport() => {
186                write!(formatter, "PocketIC instance unavailable: {source}")
187            }
188            Self::Operation(source) => write!(formatter, "panicked: {source}"),
189        }
190    }
191}
192
193impl std::error::Error for CanisterDiagnosticFailure {
194    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
195        match self {
196            Self::Rejected(_) => None,
197            Self::Operation(source) => Some(source),
198        }
199    }
200}
201
202/// One canister-log record retained as bounded lossy UTF-8 text.
203#[derive(Clone, Debug, Eq, PartialEq)]
204pub struct CanisterDiagnosticLogRecord {
205    index: u64,
206    timestamp_nanos: u64,
207    content: String,
208    original_content_bytes: usize,
209    omitted_content_bytes: usize,
210}
211
212impl CanisterDiagnosticLogRecord {
213    /// Upstream record index.
214    #[must_use]
215    pub const fn index(&self) -> u64 {
216        self.index
217    }
218
219    /// Upstream record timestamp in nanoseconds.
220    #[must_use]
221    pub const fn timestamp_nanos(&self) -> u64 {
222        self.timestamp_nanos
223    }
224
225    /// Retained content converted with lossy UTF-8 decoding.
226    #[must_use]
227    pub fn content(&self) -> &str {
228        &self.content
229    }
230
231    /// Original raw content length before bounding and conversion.
232    #[must_use]
233    pub const fn original_content_bytes(&self) -> usize {
234        self.original_content_bytes
235    }
236
237    /// Raw content bytes omitted from this retained record.
238    #[must_use]
239    pub const fn omitted_content_bytes(&self) -> usize {
240        self.omitted_content_bytes
241    }
242
243    /// Whether this record's content was truncated.
244    #[must_use]
245    pub const fn was_truncated(&self) -> bool {
246        self.omitted_content_bytes != 0
247    }
248}
249
250/// Successfully fetched canister logs after bounded text conversion.
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct CanisterDiagnosticLogs {
253    records: Vec<CanisterDiagnosticLogRecord>,
254    total_records: usize,
255    total_content_bytes: usize,
256    omitted_content_bytes: usize,
257}
258
259impl CanisterDiagnosticLogs {
260    /// Retained records in upstream order.
261    #[must_use]
262    pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
263        &self.records
264    }
265
266    /// Total number of records returned by PocketIC before bounding.
267    #[must_use]
268    pub const fn total_records(&self) -> usize {
269        self.total_records
270    }
271
272    /// Total raw content bytes returned by PocketIC before bounding.
273    #[must_use]
274    pub const fn total_content_bytes(&self) -> usize {
275        self.total_content_bytes
276    }
277
278    /// Number of whole records omitted by the configured bounds.
279    #[must_use]
280    pub const fn omitted_records(&self) -> usize {
281        self.total_records - self.records.len()
282    }
283
284    /// Aggregate raw content bytes omitted across retained and omitted records.
285    #[must_use]
286    pub const fn omitted_content_bytes(&self) -> usize {
287        self.omitted_content_bytes
288    }
289
290    /// Whether either whole records or record content were truncated.
291    #[must_use]
292    pub const fn was_truncated(&self) -> bool {
293        self.omitted_records() != 0 || self.omitted_content_bytes != 0
294    }
295}
296
297impl fmt::Display for CanisterDiagnosticLogs {
298    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
299        if self.records.is_empty() {
300            if self.total_records == 0 {
301                formatter.write_str("<empty>")?;
302            } else {
303                formatter.write_str("<no retained records>")?;
304            }
305        } else {
306            for (position, record) in self.records.iter().enumerate() {
307                if position != 0 {
308                    formatter.write_str(", ")?;
309                }
310                write!(
311                    formatter,
312                    "[{}@{}]={:?}",
313                    record.index, record.timestamp_nanos, record.content
314                )?;
315                if record.was_truncated() {
316                    write!(
317                        formatter,
318                        " (truncated {} bytes)",
319                        record.omitted_content_bytes
320                    )?;
321                }
322            }
323        }
324        if self.was_truncated() {
325            write!(
326                formatter,
327                "; truncated omitted_records={} omitted_content_bytes={}",
328                self.omitted_records(),
329                self.omitted_content_bytes
330            )?;
331        }
332        Ok(())
333    }
334}
335
336/// Independent status and log outcomes for one diagnostic collection.
337#[derive(Debug)]
338pub struct CanisterDiagnosticsReport {
339    request: CanisterDiagnosticsRequest,
340    status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
341    logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
342}
343
344impl CanisterDiagnosticsReport {
345    /// Exact request used for this report.
346    #[must_use]
347    pub const fn request(&self) -> CanisterDiagnosticsRequest {
348        self.request
349    }
350
351    /// Status result, independent of log retrieval.
352    pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
353        self.status.as_ref()
354    }
355
356    /// Log result, independent of status retrieval.
357    pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
358        self.logs.as_ref()
359    }
360
361    /// Whether both status and log collection succeeded.
362    #[must_use]
363    pub const fn is_success(&self) -> bool {
364        self.status.is_ok() && self.logs.is_ok()
365    }
366
367    /// Consume the report into its exact request and independent outcomes.
368    pub fn into_parts(
369        self,
370    ) -> (
371        CanisterDiagnosticsRequest,
372        Result<CanisterStatusResult, CanisterDiagnosticFailure>,
373        Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
374    ) {
375        (self.request, self.status, self.logs)
376    }
377}
378
379/// One ordered labeled entry in a collect-all diagnostics batch.
380#[derive(Debug)]
381pub struct CanisterDiagnosticsBatchEntry {
382    label: String,
383    report: CanisterDiagnosticsReport,
384    entry_elapsed: Duration,
385}
386
387impl CanisterDiagnosticsBatchEntry {
388    /// Caller-supplied label.
389    #[must_use]
390    pub fn label(&self) -> &str {
391        &self.label
392    }
393
394    /// Structured status and log outcomes for this entry.
395    #[must_use]
396    pub const fn report(&self) -> &CanisterDiagnosticsReport {
397        &self.report
398    }
399
400    /// Whether both status and log collection succeeded for this entry.
401    #[must_use]
402    pub const fn is_success(&self) -> bool {
403        self.report.is_success()
404    }
405
406    /// Complete wall-clock time for this target's diagnostic collection.
407    #[must_use]
408    pub const fn entry_elapsed(&self) -> Duration {
409        self.entry_elapsed
410    }
411
412    /// Consume the entry into its label, structured report, and wall time.
413    #[must_use]
414    pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
415        (self.label, self.report, self.entry_elapsed)
416    }
417}
418
419/// Ordered entries from a sequential collect-all diagnostics batch.
420#[derive(Debug, Default)]
421pub struct CanisterDiagnosticsBatchReport {
422    entries: Vec<CanisterDiagnosticsBatchEntry>,
423    total: Duration,
424}
425
426impl CanisterDiagnosticsBatchReport {
427    /// Entries in the supplied request order.
428    #[must_use]
429    pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
430        &self.entries
431    }
432
433    /// Entries with at least one failed status or log operation.
434    pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
435        self.entries.iter().filter(|entry| !entry.is_success())
436    }
437
438    /// Whether every entry collected both status and logs successfully.
439    #[must_use]
440    pub fn is_success(&self) -> bool {
441        self.entries
442            .iter()
443            .all(CanisterDiagnosticsBatchEntry::is_success)
444    }
445
446    /// Complete wall-clock time for the sequential collect-all batch.
447    #[must_use]
448    pub const fn total(&self) -> Duration {
449        self.total
450    }
451
452    /// Consume the report into its ordered entries.
453    #[must_use]
454    pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
455        self.entries
456    }
457}
458
459impl fmt::Display for CanisterDiagnosticsBatchReport {
460    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
461        write!(
462            formatter,
463            "diagnostics={} total={:?}",
464            self.entries.len(),
465            self.total
466        )?;
467        for entry in &self.entries {
468            write!(
469                formatter,
470                "; label={:?} elapsed={:?} {}",
471                entry.label, entry.entry_elapsed, entry.report
472            )?;
473        }
474        Ok(())
475    }
476}
477
478impl fmt::Display for CanisterDiagnosticsReport {
479    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
480        write!(
481            formatter,
482            "canister={} status_sender={} status=",
483            self.request.canister_id, self.request.status_sender
484        )?;
485        match &self.status {
486            Ok(status) => write!(
487                formatter,
488                "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
489                status.status,
490                status.version,
491                status.settings.controllers.len(),
492                status.module_hash.as_ref().map_or(0, Vec::len),
493                status.memory_size,
494                status.cycles,
495            ),
496            Err(failure) => write!(formatter, "<{failure}>"),
497        }?;
498        write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
499        match &self.logs {
500            Err(failure) => write!(formatter, "<{failure}>")?,
501            Ok(logs) => write!(formatter, "{logs}")?,
502        }
503        Ok(())
504    }
505}
506
507/// Reusable structured PocketIC failure diagnostics.
508pub trait PocketIcDiagnosticsExt {
509    /// Collect status and bounded logs using the request's exact senders.
510    ///
511    /// Both calls are attempted independently. PocketIC transport panics are
512    /// captured so diagnostics can remain subordinate to the original failure.
513    fn collect_canister_diagnostics(
514        &self,
515        request: CanisterDiagnosticsRequest,
516    ) -> CanisterDiagnosticsReport;
517
518    /// Collect every labeled request sequentially in its supplied order.
519    ///
520    /// Every target is attempted even when an earlier entry fails or panics.
521    /// Each entry preserves its exact request and independent status/log
522    /// outcomes; no anonymous retry or fallback is performed.
523    fn collect_canister_diagnostics_batch(
524        &self,
525        requests: &[LabeledCanisterDiagnosticsRequest],
526    ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
527        validate_diagnostics_batch_labels(requests)?;
528        let started = Instant::now();
529        let entries = requests
530            .iter()
531            .map(|labeled| {
532                let entry_started = Instant::now();
533                let request = labeled.request;
534                let report =
535                    transport::capture_operation(|| self.collect_canister_diagnostics(request))
536                        .unwrap_or_else(|source| CanisterDiagnosticsReport {
537                            request,
538                            status: Err(CanisterDiagnosticFailure::Operation(source.clone())),
539                            logs: Err(CanisterDiagnosticFailure::Operation(source)),
540                        });
541                CanisterDiagnosticsBatchEntry {
542                    label: labeled.label.clone(),
543                    report,
544                    entry_elapsed: entry_started.elapsed(),
545                }
546            })
547            .collect();
548        Ok(CanisterDiagnosticsBatchReport {
549            entries,
550            total: started.elapsed(),
551        })
552    }
553}
554
555fn validate_diagnostics_batch_labels(
556    requests: &[LabeledCanisterDiagnosticsRequest],
557) -> Result<(), CanisterDiagnosticsBatchContractError> {
558    let mut seen = HashMap::new();
559    for (index, request) in requests.iter().enumerate() {
560        let label = request.label.as_str();
561        if label.is_empty() {
562            return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
563        }
564        if let Some(first_index) = seen.insert(label, index) {
565            return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
566                label: label.to_owned(),
567                first_index,
568                duplicate_index: index,
569            });
570        }
571    }
572    Ok(())
573}
574
575impl fmt::Display for CanisterDiagnosticsBatchContractError {
576    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
577        match self {
578            Self::EmptyLabel { index } => {
579                write!(
580                    formatter,
581                    "diagnostics batch label at index {index} is empty"
582                )
583            }
584            Self::DuplicateLabel {
585                label,
586                first_index,
587                duplicate_index,
588            } => write!(
589                formatter,
590                "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
591            ),
592        }
593    }
594}
595
596impl std::error::Error for CanisterDiagnosticsBatchContractError {}
597
598impl PocketIcDiagnosticsExt for PocketIc {
599    fn collect_canister_diagnostics(
600        &self,
601        request: CanisterDiagnosticsRequest,
602    ) -> CanisterDiagnosticsReport {
603        let status = capture_diagnostic_call(|| {
604            self.canister_status(request.canister_id, Some(request.status_sender))
605        });
606        let logs = capture_diagnostic_call(|| {
607            self.fetch_canister_logs(request.canister_id, request.log_sender)
608        })
609        .map(|records| render_log_records(records, request.log_limits));
610
611        CanisterDiagnosticsReport {
612            request,
613            status,
614            logs,
615        }
616    }
617}
618
619fn capture_diagnostic_call<T>(
620    call: impl FnOnce() -> Result<T, RejectResponse>,
621) -> Result<T, CanisterDiagnosticFailure> {
622    match transport::capture_operation(call) {
623        Ok(Ok(value)) => Ok(value),
624        Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
625        Err(source) => Err(CanisterDiagnosticFailure::Operation(source)),
626    }
627}
628
629fn render_log_records(
630    records: Vec<CanisterLogRecord>,
631    limits: CanisterLogRenderLimits,
632) -> CanisterDiagnosticLogs {
633    let total_records = records.len();
634    let mut total_content_bytes = 0usize;
635    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
636    let mut retained_bytes = 0usize;
637    let mut omitted_content_bytes = 0usize;
638
639    for record in records {
640        total_content_bytes = total_content_bytes.saturating_add(record.content.len());
641        if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
642            omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
643            continue;
644        }
645
646        let available = limits.byte_limit.saturating_sub(retained_bytes);
647        let retained = record.content.len().min(available);
648        let omitted = record.content.len().saturating_sub(retained);
649        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
650        retained_bytes = retained_bytes.saturating_add(retained);
651        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
652        rendered.push(CanisterDiagnosticLogRecord {
653            index: record.idx,
654            timestamp_nanos: record.timestamp_nanos,
655            content,
656            original_content_bytes: record.content.len(),
657            omitted_content_bytes: omitted,
658        });
659    }
660
661    CanisterDiagnosticLogs {
662        records: rendered,
663        total_records,
664        total_content_bytes,
665        omitted_content_bytes,
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use std::cell::Cell;
672
673    use candid::Principal;
674    use pocket_ic::CanisterLogRecord;
675
676    use super::{
677        CanisterDiagnosticFailure, CanisterDiagnosticsBatchContractError,
678        CanisterDiagnosticsReport, CanisterDiagnosticsRequest, CanisterLogRenderLimits,
679        LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt, capture_diagnostic_call,
680        render_log_records,
681    };
682
683    #[test]
684    fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
685        const REFUSED: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/read/get_time\", source: ConnectError(\"tcp connect error\", Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }) }";
686        const RESET: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:49238/instances/0/read/get_time\", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Io, Os { code: 54, kind: ConnectionReset, message: \"Connection reset by peer\" })) }";
687        for message in [REFUSED, RESET, "application worker channel closed"] {
688            let error = capture_diagnostic_call::<()>(|| panic!("{message}"))
689                .expect_err("diagnostic operation should capture the panic");
690            let expected = message != "application worker channel closed";
691            let CanisterDiagnosticFailure::Operation(source) = &error else {
692                panic!("expected the original operation error");
693            };
694            assert_eq!(source.message(), message);
695            assert_eq!(source.is_transport(), expected);
696            let chained = std::error::Error::source(&error)
697                .and_then(|cause| cause.downcast_ref::<super::PocketIcOperationError>())
698                .expect("diagnostic error exposes its original typed cause");
699            assert_eq!(chained, source);
700            assert_eq!(
701                super::super::is_dead_pocket_ic_transport_error(&error),
702                expected,
703            );
704            let wrapped = std::io::Error::other(error);
705            assert_eq!(
706                super::super::is_dead_pocket_ic_transport_error(&wrapped),
707                expected,
708            );
709        }
710    }
711
712    struct PanickingThenReporting {
713        calls: Cell<usize>,
714    }
715
716    impl PocketIcDiagnosticsExt for PanickingThenReporting {
717        fn collect_canister_diagnostics(
718            &self,
719            request: CanisterDiagnosticsRequest,
720        ) -> CanisterDiagnosticsReport {
721            let call = self.calls.get();
722            self.calls.set(call + 1);
723            assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
724            CanisterDiagnosticsReport {
725                request,
726                status: Err(CanisterDiagnosticFailure::Operation(
727                    super::PocketIcOperationError::new("synthetic status failure"),
728                )),
729                logs: Err(CanisterDiagnosticFailure::Operation(
730                    super::PocketIcOperationError::new("synthetic log failure"),
731                )),
732            }
733        }
734    }
735
736    #[test]
737    fn labeled_batch_retains_order_and_continues_after_entry_panic() {
738        let collector = PanickingThenReporting {
739            calls: Cell::new(0),
740        };
741        let first = CanisterDiagnosticsRequest::new(
742            Principal::from_slice(&[1]),
743            Principal::from_slice(&[2]),
744            Principal::from_slice(&[3]),
745        );
746        let second = CanisterDiagnosticsRequest::new(
747            Principal::from_slice(&[4]),
748            Principal::from_slice(&[5]),
749            Principal::from_slice(&[6]),
750        );
751        let report = collector
752            .collect_canister_diagnostics_batch(&[
753                LabeledCanisterDiagnosticsRequest::new("root", first),
754                LabeledCanisterDiagnosticsRequest::new("worker", second),
755            ])
756            .expect("valid labeled diagnostics batch");
757
758        assert_eq!(collector.calls.get(), 2);
759        assert_eq!(report.entries().len(), 2);
760        assert_eq!(report.entries()[0].label(), "root");
761        assert_eq!(report.entries()[0].report().request(), first);
762        assert_eq!(report.entries()[1].label(), "worker");
763        assert_eq!(report.entries()[1].report().request(), second);
764        assert!(
765            report
766                .entries()
767                .iter()
768                .all(|entry| entry.entry_elapsed() <= report.total())
769        );
770        assert_eq!(report.failures().count(), 2);
771        assert!(!report.is_success());
772        let compact = report.to_string();
773        assert!(compact.contains("label=\"root\""));
774        assert!(compact.contains("label=\"worker\""));
775        assert!(compact.contains("synthetic first-entry diagnostic panic"));
776    }
777
778    #[test]
779    fn diagnostic_batch_rejects_invalid_labels_before_collection() {
780        let collector = PanickingThenReporting {
781            calls: Cell::new(0),
782        };
783        let request = CanisterDiagnosticsRequest::new(
784            Principal::from_slice(&[1]),
785            Principal::from_slice(&[2]),
786            Principal::from_slice(&[3]),
787        );
788        let empty = collector
789            .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
790                "", request,
791            )])
792            .expect_err("empty label must reject diagnostics batch");
793        assert_eq!(
794            empty,
795            CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
796        );
797        assert_eq!(collector.calls.get(), 0);
798
799        let duplicate = collector
800            .collect_canister_diagnostics_batch(&[
801                LabeledCanisterDiagnosticsRequest::new("same", request),
802                LabeledCanisterDiagnosticsRequest::new("same", request),
803            ])
804            .expect_err("duplicate labels must reject diagnostics batch");
805        assert_eq!(
806            duplicate,
807            CanisterDiagnosticsBatchContractError::DuplicateLabel {
808                label: "same".to_owned(),
809                first_index: 0,
810                duplicate_index: 1,
811            }
812        );
813        assert_eq!(collector.calls.get(), 0);
814    }
815
816    #[test]
817    fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
818        let logs = render_log_records(
819            vec![
820                CanisterLogRecord {
821                    idx: 7,
822                    timestamp_nanos: 11,
823                    content: vec![b'f', 0x80, b'o'],
824                },
825                CanisterLogRecord {
826                    idx: 8,
827                    timestamp_nanos: 12,
828                    content: b"bar".to_vec(),
829                },
830            ],
831            CanisterLogRenderLimits::new(1, 2),
832        );
833
834        assert_eq!(logs.total_records(), 2);
835        assert_eq!(logs.total_content_bytes(), 6);
836        assert_eq!(logs.omitted_records(), 1);
837        assert_eq!(logs.omitted_content_bytes(), 4);
838        assert!(logs.was_truncated());
839        assert_eq!(logs.records().len(), 1);
840        assert_eq!(logs.records()[0].content(), "f�");
841        assert_eq!(logs.records()[0].original_content_bytes(), 3);
842        assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
843        assert!(logs.records()[0].was_truncated());
844        let rendered = logs.to_string();
845        assert!(rendered.contains("f�"));
846        assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
847    }
848
849    #[test]
850    fn zero_log_bounds_retain_only_aggregate_truncation() {
851        for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
852            let logs = render_log_records(
853                vec![CanisterLogRecord {
854                    idx: 1,
855                    timestamp_nanos: 2,
856                    content: b"hello".to_vec(),
857                }],
858                CanisterLogRenderLimits::new(record_limit, byte_limit),
859            );
860
861            assert_eq!(logs.records(), []);
862            assert_eq!(logs.total_records(), 1);
863            assert_eq!(logs.total_content_bytes(), 5);
864            assert_eq!(logs.omitted_records(), 1);
865            assert_eq!(logs.omitted_content_bytes(), 5);
866            assert!(logs.was_truncated());
867            assert_eq!(
868                logs.to_string(),
869                "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
870            );
871        }
872    }
873
874    #[test]
875    fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
876        let limits = CanisterLogRenderLimits::new(3, 3);
877        let empty = render_log_records(vec![], limits);
878        assert_eq!(empty.total_records(), 0);
879        assert_eq!(empty.total_content_bytes(), 0);
880        assert_eq!(empty.omitted_records(), 0);
881        assert_eq!(empty.omitted_content_bytes(), 0);
882        assert!(!empty.was_truncated());
883        assert_eq!(empty.to_string(), "<empty>");
884
885        let logs = render_log_records(
886            vec![
887                CanisterLogRecord {
888                    idx: 1,
889                    timestamp_nanos: 2,
890                    content: vec![],
891                },
892                CanisterLogRecord {
893                    idx: 3,
894                    timestamp_nanos: 4,
895                    content: vec![0x80, b'a'],
896                },
897                CanisterLogRecord {
898                    idx: 5,
899                    timestamp_nanos: 6,
900                    content: vec![],
901                },
902            ],
903            limits,
904        );
905        assert_eq!(logs.total_records(), 3);
906        assert_eq!(logs.records().len(), 3);
907        assert_eq!(logs.total_content_bytes(), 2);
908        assert_eq!(logs.records()[0].content(), "");
909        assert_eq!(logs.records()[1].content(), "�a");
910        assert_eq!(logs.records()[2].content(), "");
911        assert_eq!(logs.omitted_records(), 0);
912        assert_eq!(logs.omitted_content_bytes(), 0);
913        assert!(!logs.was_truncated());
914    }
915}