1use std::{
2 collections::HashMap,
3 fmt,
4 time::{Duration, Instant},
5};
6
7use candid::Principal;
8use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc};
9
10use super::{
11 PocketIcCallFailure,
12 transport::{self, capture_call},
13};
14
15pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
17
18pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct CanisterLogRenderLimits {
24 record_limit: usize,
25 byte_limit: usize,
26}
27
28impl CanisterLogRenderLimits {
29 #[must_use]
33 pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
34 Self {
35 record_limit,
36 byte_limit,
37 }
38 }
39
40 #[must_use]
42 pub const fn record_limit(self) -> usize {
43 self.record_limit
44 }
45
46 #[must_use]
48 pub const fn byte_limit(self) -> usize {
49 self.byte_limit
50 }
51}
52
53impl Default for CanisterLogRenderLimits {
54 fn default() -> Self {
55 Self::new(
56 DEFAULT_CANISTER_LOG_RECORD_LIMIT,
57 DEFAULT_CANISTER_LOG_BYTE_LIMIT,
58 )
59 }
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub struct CanisterDiagnosticsRequest {
65 canister_id: Principal,
66 status_sender: Principal,
67 log_sender: Principal,
68 log_limits: CanisterLogRenderLimits,
69}
70
71impl CanisterDiagnosticsRequest {
72 #[must_use]
77 pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
78 Self {
79 canister_id,
80 status_sender,
81 log_sender,
82 log_limits: CanisterLogRenderLimits::default(),
83 }
84 }
85
86 #[must_use]
88 pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
89 self.log_limits = limits;
90 self
91 }
92
93 #[must_use]
95 pub const fn canister_id(self) -> Principal {
96 self.canister_id
97 }
98
99 #[must_use]
101 pub const fn status_sender(self) -> Principal {
102 self.status_sender
103 }
104
105 #[must_use]
107 pub const fn log_sender(self) -> Principal {
108 self.log_sender
109 }
110
111 #[must_use]
113 pub const fn log_limits(self) -> CanisterLogRenderLimits {
114 self.log_limits
115 }
116}
117
118#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct LabeledCanisterDiagnosticsRequest {
121 label: String,
122 request: CanisterDiagnosticsRequest,
123}
124
125#[non_exhaustive]
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub enum CanisterDiagnosticsBatchContractError {
129 EmptyLabel {
131 index: usize,
133 },
134 DuplicateLabel {
136 label: String,
138 first_index: usize,
140 duplicate_index: usize,
142 },
143}
144
145impl LabeledCanisterDiagnosticsRequest {
146 #[must_use]
148 pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
149 Self {
150 label: label.into(),
151 request,
152 }
153 }
154
155 #[must_use]
157 pub fn label(&self) -> &str {
158 &self.label
159 }
160
161 #[must_use]
163 pub const fn request(&self) -> CanisterDiagnosticsRequest {
164 self.request
165 }
166
167 #[must_use]
169 pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
170 (self.label, self.request)
171 }
172}
173
174#[derive(Clone, Debug, Eq, PartialEq)]
176pub struct CanisterDiagnosticLogRecord {
177 index: u64,
178 timestamp_nanos: u64,
179 content: String,
180 original_content_bytes: usize,
181 omitted_content_bytes: usize,
182}
183
184impl CanisterDiagnosticLogRecord {
185 #[must_use]
187 pub const fn index(&self) -> u64 {
188 self.index
189 }
190
191 #[must_use]
193 pub const fn timestamp_nanos(&self) -> u64 {
194 self.timestamp_nanos
195 }
196
197 #[must_use]
199 pub fn content(&self) -> &str {
200 &self.content
201 }
202
203 #[must_use]
205 pub const fn original_content_bytes(&self) -> usize {
206 self.original_content_bytes
207 }
208
209 #[must_use]
211 pub const fn omitted_content_bytes(&self) -> usize {
212 self.omitted_content_bytes
213 }
214
215 #[must_use]
217 pub const fn was_truncated(&self) -> bool {
218 self.omitted_content_bytes != 0
219 }
220}
221
222#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct CanisterDiagnosticLogs {
225 records: Vec<CanisterDiagnosticLogRecord>,
226 total_records: usize,
227 total_content_bytes: usize,
228 omitted_content_bytes: usize,
229}
230
231impl CanisterDiagnosticLogs {
232 #[must_use]
234 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
235 &self.records
236 }
237
238 #[must_use]
240 pub const fn total_records(&self) -> usize {
241 self.total_records
242 }
243
244 #[must_use]
246 pub const fn total_content_bytes(&self) -> usize {
247 self.total_content_bytes
248 }
249
250 #[must_use]
252 pub const fn omitted_records(&self) -> usize {
253 self.total_records - self.records.len()
254 }
255
256 #[must_use]
258 pub const fn omitted_content_bytes(&self) -> usize {
259 self.omitted_content_bytes
260 }
261
262 #[must_use]
264 pub const fn was_truncated(&self) -> bool {
265 self.omitted_records() != 0 || self.omitted_content_bytes != 0
266 }
267}
268
269impl fmt::Display for CanisterDiagnosticLogs {
270 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271 if self.records.is_empty() {
272 if self.total_records == 0 {
273 formatter.write_str("<empty>")?;
274 } else {
275 formatter.write_str("<no retained records>")?;
276 }
277 } else {
278 for (position, record) in self.records.iter().enumerate() {
279 if position != 0 {
280 formatter.write_str(", ")?;
281 }
282 write!(
283 formatter,
284 "[{}@{}]={:?}",
285 record.index, record.timestamp_nanos, record.content
286 )?;
287 if record.was_truncated() {
288 write!(
289 formatter,
290 " (truncated {} bytes)",
291 record.omitted_content_bytes
292 )?;
293 }
294 }
295 }
296 if self.was_truncated() {
297 write!(
298 formatter,
299 "; truncated omitted_records={} omitted_content_bytes={}",
300 self.omitted_records(),
301 self.omitted_content_bytes
302 )?;
303 }
304 Ok(())
305 }
306}
307
308#[derive(Debug)]
310pub struct CanisterDiagnosticsReport {
311 request: CanisterDiagnosticsRequest,
312 status: Result<CanisterStatusResult, PocketIcCallFailure>,
313 logs: Result<CanisterDiagnosticLogs, PocketIcCallFailure>,
314}
315
316impl CanisterDiagnosticsReport {
317 #[must_use]
319 pub const fn request(&self) -> CanisterDiagnosticsRequest {
320 self.request
321 }
322
323 pub const fn status(&self) -> Result<&CanisterStatusResult, &PocketIcCallFailure> {
325 self.status.as_ref()
326 }
327
328 pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &PocketIcCallFailure> {
330 self.logs.as_ref()
331 }
332
333 #[must_use]
335 pub const fn is_success(&self) -> bool {
336 self.status.is_ok() && self.logs.is_ok()
337 }
338
339 pub fn into_parts(
341 self,
342 ) -> (
343 CanisterDiagnosticsRequest,
344 Result<CanisterStatusResult, PocketIcCallFailure>,
345 Result<CanisterDiagnosticLogs, PocketIcCallFailure>,
346 ) {
347 (self.request, self.status, self.logs)
348 }
349}
350
351#[derive(Debug)]
353pub struct CanisterDiagnosticsBatchEntry {
354 label: String,
355 report: CanisterDiagnosticsReport,
356 entry_elapsed: Duration,
357}
358
359impl CanisterDiagnosticsBatchEntry {
360 #[must_use]
362 pub fn label(&self) -> &str {
363 &self.label
364 }
365
366 #[must_use]
368 pub const fn report(&self) -> &CanisterDiagnosticsReport {
369 &self.report
370 }
371
372 #[must_use]
374 pub const fn is_success(&self) -> bool {
375 self.report.is_success()
376 }
377
378 #[must_use]
380 pub const fn entry_elapsed(&self) -> Duration {
381 self.entry_elapsed
382 }
383
384 #[must_use]
386 pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
387 (self.label, self.report, self.entry_elapsed)
388 }
389}
390
391#[derive(Debug, Default)]
393pub struct CanisterDiagnosticsBatchReport {
394 entries: Vec<CanisterDiagnosticsBatchEntry>,
395 total: Duration,
396}
397
398impl CanisterDiagnosticsBatchReport {
399 #[must_use]
401 pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
402 &self.entries
403 }
404
405 pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
407 self.entries.iter().filter(|entry| !entry.is_success())
408 }
409
410 #[must_use]
412 pub fn is_success(&self) -> bool {
413 self.entries
414 .iter()
415 .all(CanisterDiagnosticsBatchEntry::is_success)
416 }
417
418 #[must_use]
420 pub const fn total(&self) -> Duration {
421 self.total
422 }
423
424 #[must_use]
426 pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
427 self.entries
428 }
429}
430
431impl fmt::Display for CanisterDiagnosticsBatchReport {
432 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
433 write!(
434 formatter,
435 "diagnostics={} total={:?}",
436 self.entries.len(),
437 self.total
438 )?;
439 for entry in &self.entries {
440 write!(
441 formatter,
442 "; label={:?} elapsed={:?} {}",
443 entry.label, entry.entry_elapsed, entry.report
444 )?;
445 }
446 Ok(())
447 }
448}
449
450impl fmt::Display for CanisterDiagnosticsReport {
451 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
452 write!(
453 formatter,
454 "canister={} status_sender={} status=",
455 self.request.canister_id, self.request.status_sender
456 )?;
457 match &self.status {
458 Ok(status) => write!(
459 formatter,
460 "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
461 status.status,
462 status.version,
463 status.settings.controllers.len(),
464 status.module_hash.as_ref().map_or(0, Vec::len),
465 status.memory_size,
466 status.cycles,
467 ),
468 Err(failure) => write!(formatter, "<{failure}>"),
469 }?;
470 write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
471 match &self.logs {
472 Err(failure) => write!(formatter, "<{failure}>")?,
473 Ok(logs) => write!(formatter, "{logs}")?,
474 }
475 Ok(())
476 }
477}
478
479pub trait PocketIcDiagnosticsExt {
481 fn collect_canister_diagnostics(
486 &self,
487 request: CanisterDiagnosticsRequest,
488 ) -> CanisterDiagnosticsReport;
489
490 fn collect_canister_diagnostics_batch(
496 &self,
497 requests: &[LabeledCanisterDiagnosticsRequest],
498 ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
499 validate_diagnostics_batch_labels(requests)?;
500 let started = Instant::now();
501 let entries = requests
502 .iter()
503 .map(|labeled| {
504 let entry_started = Instant::now();
505 let request = labeled.request;
506 let report =
507 transport::capture_operation(|| self.collect_canister_diagnostics(request))
508 .unwrap_or_else(|source| CanisterDiagnosticsReport {
509 request,
510 status: Err(PocketIcCallFailure::Operation(source.clone())),
511 logs: Err(PocketIcCallFailure::Operation(source)),
512 });
513 CanisterDiagnosticsBatchEntry {
514 label: labeled.label.clone(),
515 report,
516 entry_elapsed: entry_started.elapsed(),
517 }
518 })
519 .collect();
520 Ok(CanisterDiagnosticsBatchReport {
521 entries,
522 total: started.elapsed(),
523 })
524 }
525}
526
527fn validate_diagnostics_batch_labels(
528 requests: &[LabeledCanisterDiagnosticsRequest],
529) -> Result<(), CanisterDiagnosticsBatchContractError> {
530 let mut seen = HashMap::new();
531 for (index, request) in requests.iter().enumerate() {
532 let label = request.label.as_str();
533 if label.is_empty() {
534 return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
535 }
536 if let Some(first_index) = seen.insert(label, index) {
537 return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
538 label: label.to_owned(),
539 first_index,
540 duplicate_index: index,
541 });
542 }
543 }
544 Ok(())
545}
546
547impl fmt::Display for CanisterDiagnosticsBatchContractError {
548 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
549 match self {
550 Self::EmptyLabel { index } => {
551 write!(
552 formatter,
553 "diagnostics batch label at index {index} is empty"
554 )
555 }
556 Self::DuplicateLabel {
557 label,
558 first_index,
559 duplicate_index,
560 } => write!(
561 formatter,
562 "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
563 ),
564 }
565 }
566}
567
568impl std::error::Error for CanisterDiagnosticsBatchContractError {}
569
570impl PocketIcDiagnosticsExt for PocketIc {
571 fn collect_canister_diagnostics(
572 &self,
573 request: CanisterDiagnosticsRequest,
574 ) -> CanisterDiagnosticsReport {
575 let status =
576 capture_call(|| self.canister_status(request.canister_id, Some(request.status_sender)));
577 let logs =
578 capture_call(|| self.fetch_canister_logs(request.canister_id, request.log_sender))
579 .map(|records| render_log_records(records, request.log_limits));
580
581 CanisterDiagnosticsReport {
582 request,
583 status,
584 logs,
585 }
586 }
587}
588
589fn render_log_records(
590 records: Vec<CanisterLogRecord>,
591 limits: CanisterLogRenderLimits,
592) -> CanisterDiagnosticLogs {
593 let total_records = records.len();
594 let mut total_content_bytes = 0usize;
595 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
596 let mut retained_bytes = 0usize;
597 let mut omitted_content_bytes = 0usize;
598
599 for record in records {
600 total_content_bytes = total_content_bytes.saturating_add(record.content.len());
601 if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
602 omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
603 continue;
604 }
605
606 let available = limits.byte_limit.saturating_sub(retained_bytes);
607 let retained = record.content.len().min(available);
608 let omitted = record.content.len().saturating_sub(retained);
609 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
610 retained_bytes = retained_bytes.saturating_add(retained);
611 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
612 rendered.push(CanisterDiagnosticLogRecord {
613 index: record.idx,
614 timestamp_nanos: record.timestamp_nanos,
615 content,
616 original_content_bytes: record.content.len(),
617 omitted_content_bytes: omitted,
618 });
619 }
620
621 CanisterDiagnosticLogs {
622 records: rendered,
623 total_records,
624 total_content_bytes,
625 omitted_content_bytes,
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 use std::cell::Cell;
632
633 use candid::Principal;
634 use pocket_ic::CanisterLogRecord;
635
636 use super::{
637 CanisterDiagnosticsBatchContractError, CanisterDiagnosticsReport,
638 CanisterDiagnosticsRequest, CanisterLogRenderLimits, LabeledCanisterDiagnosticsRequest,
639 PocketIcCallFailure, PocketIcDiagnosticsExt, capture_call, render_log_records,
640 };
641
642 #[test]
643 fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
644 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\" }) }";
645 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\" })) }";
646 for message in [REFUSED, RESET, "application worker channel closed"] {
647 let error = capture_call::<()>(|| panic!("{message}"))
648 .expect_err("diagnostic operation should capture the panic");
649 let expected = message != "application worker channel closed";
650 let PocketIcCallFailure::Operation(source) = &error else {
651 panic!("expected the original operation error");
652 };
653 assert_eq!(source.message(), message);
654 assert_eq!(source.is_transport(), expected);
655 let chained = std::error::Error::source(&error)
656 .and_then(|cause| cause.downcast_ref::<super::super::PocketIcOperationError>())
657 .expect("diagnostic error exposes its original typed cause");
658 assert_eq!(chained, source);
659 assert_eq!(
660 super::super::is_dead_pocket_ic_transport_error(&error),
661 expected,
662 );
663 let wrapped = std::io::Error::other(error);
664 assert_eq!(
665 super::super::is_dead_pocket_ic_transport_error(&wrapped),
666 expected,
667 );
668 }
669 }
670
671 struct PanickingThenReporting {
672 calls: Cell<usize>,
673 }
674
675 impl PocketIcDiagnosticsExt for PanickingThenReporting {
676 fn collect_canister_diagnostics(
677 &self,
678 request: CanisterDiagnosticsRequest,
679 ) -> CanisterDiagnosticsReport {
680 let call = self.calls.get();
681 self.calls.set(call + 1);
682 assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
683 CanisterDiagnosticsReport {
684 request,
685 status: Err(PocketIcCallFailure::Operation(
686 super::super::PocketIcOperationError::new("synthetic status failure"),
687 )),
688 logs: Err(PocketIcCallFailure::Operation(
689 super::super::PocketIcOperationError::new("synthetic log failure"),
690 )),
691 }
692 }
693 }
694
695 #[test]
696 fn labeled_batch_retains_order_and_continues_after_entry_panic() {
697 let collector = PanickingThenReporting {
698 calls: Cell::new(0),
699 };
700 let first = CanisterDiagnosticsRequest::new(
701 Principal::from_slice(&[1]),
702 Principal::from_slice(&[2]),
703 Principal::from_slice(&[3]),
704 );
705 let second = CanisterDiagnosticsRequest::new(
706 Principal::from_slice(&[4]),
707 Principal::from_slice(&[5]),
708 Principal::from_slice(&[6]),
709 );
710 let report = collector
711 .collect_canister_diagnostics_batch(&[
712 LabeledCanisterDiagnosticsRequest::new("root", first),
713 LabeledCanisterDiagnosticsRequest::new("worker", second),
714 ])
715 .expect("valid labeled diagnostics batch");
716
717 assert_eq!(collector.calls.get(), 2);
718 assert_eq!(report.entries().len(), 2);
719 assert_eq!(report.entries()[0].label(), "root");
720 assert_eq!(report.entries()[0].report().request(), first);
721 assert_eq!(report.entries()[1].label(), "worker");
722 assert_eq!(report.entries()[1].report().request(), second);
723 assert!(
724 report
725 .entries()
726 .iter()
727 .all(|entry| entry.entry_elapsed() <= report.total())
728 );
729 assert_eq!(report.failures().count(), 2);
730 assert!(!report.is_success());
731 let compact = report.to_string();
732 assert!(compact.contains("label=\"root\""));
733 assert!(compact.contains("label=\"worker\""));
734 assert!(compact.contains("synthetic first-entry diagnostic panic"));
735 }
736
737 #[test]
738 fn diagnostic_batch_rejects_invalid_labels_before_collection() {
739 let collector = PanickingThenReporting {
740 calls: Cell::new(0),
741 };
742 let request = CanisterDiagnosticsRequest::new(
743 Principal::from_slice(&[1]),
744 Principal::from_slice(&[2]),
745 Principal::from_slice(&[3]),
746 );
747 let empty = collector
748 .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
749 "", request,
750 )])
751 .expect_err("empty label must reject diagnostics batch");
752 assert_eq!(
753 empty,
754 CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
755 );
756 assert_eq!(collector.calls.get(), 0);
757
758 let duplicate = collector
759 .collect_canister_diagnostics_batch(&[
760 LabeledCanisterDiagnosticsRequest::new("same", request),
761 LabeledCanisterDiagnosticsRequest::new("same", request),
762 ])
763 .expect_err("duplicate labels must reject diagnostics batch");
764 assert_eq!(
765 duplicate,
766 CanisterDiagnosticsBatchContractError::DuplicateLabel {
767 label: "same".to_owned(),
768 first_index: 0,
769 duplicate_index: 1,
770 }
771 );
772 assert_eq!(collector.calls.get(), 0);
773 }
774
775 #[test]
776 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
777 let logs = render_log_records(
778 vec![
779 CanisterLogRecord {
780 idx: 7,
781 timestamp_nanos: 11,
782 content: vec![b'f', 0x80, b'o'],
783 },
784 CanisterLogRecord {
785 idx: 8,
786 timestamp_nanos: 12,
787 content: b"bar".to_vec(),
788 },
789 ],
790 CanisterLogRenderLimits::new(1, 2),
791 );
792
793 assert_eq!(logs.total_records(), 2);
794 assert_eq!(logs.total_content_bytes(), 6);
795 assert_eq!(logs.omitted_records(), 1);
796 assert_eq!(logs.omitted_content_bytes(), 4);
797 assert!(logs.was_truncated());
798 assert_eq!(logs.records().len(), 1);
799 assert_eq!(logs.records()[0].content(), "f�");
800 assert_eq!(logs.records()[0].original_content_bytes(), 3);
801 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
802 assert!(logs.records()[0].was_truncated());
803 let rendered = logs.to_string();
804 assert!(rendered.contains("f�"));
805 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
806 }
807
808 #[test]
809 fn zero_log_bounds_retain_only_aggregate_truncation() {
810 for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
811 let logs = render_log_records(
812 vec![CanisterLogRecord {
813 idx: 1,
814 timestamp_nanos: 2,
815 content: b"hello".to_vec(),
816 }],
817 CanisterLogRenderLimits::new(record_limit, byte_limit),
818 );
819
820 assert_eq!(logs.records(), []);
821 assert_eq!(logs.total_records(), 1);
822 assert_eq!(logs.total_content_bytes(), 5);
823 assert_eq!(logs.omitted_records(), 1);
824 assert_eq!(logs.omitted_content_bytes(), 5);
825 assert!(logs.was_truncated());
826 assert_eq!(
827 logs.to_string(),
828 "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
829 );
830 }
831 }
832
833 #[test]
834 fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
835 let limits = CanisterLogRenderLimits::new(3, 3);
836 let empty = render_log_records(vec![], limits);
837 assert_eq!(empty.total_records(), 0);
838 assert_eq!(empty.total_content_bytes(), 0);
839 assert_eq!(empty.omitted_records(), 0);
840 assert_eq!(empty.omitted_content_bytes(), 0);
841 assert!(!empty.was_truncated());
842 assert_eq!(empty.to_string(), "<empty>");
843
844 let logs = render_log_records(
845 vec![
846 CanisterLogRecord {
847 idx: 1,
848 timestamp_nanos: 2,
849 content: vec![],
850 },
851 CanisterLogRecord {
852 idx: 3,
853 timestamp_nanos: 4,
854 content: vec![0x80, b'a'],
855 },
856 CanisterLogRecord {
857 idx: 5,
858 timestamp_nanos: 6,
859 content: vec![],
860 },
861 ],
862 limits,
863 );
864 assert_eq!(logs.total_records(), 3);
865 assert_eq!(logs.records().len(), 3);
866 assert_eq!(logs.total_content_bytes(), 2);
867 assert_eq!(logs.records()[0].content(), "");
868 assert_eq!(logs.records()[1].content(), "�a");
869 assert_eq!(logs.records()[2].content(), "");
870 assert_eq!(logs.omitted_records(), 0);
871 assert_eq!(logs.omitted_content_bytes(), 0);
872 assert!(!logs.was_truncated());
873 }
874}