Skip to main content

bee_tui/components/
api_health.rs

1//! S8 — RPC / API health screen (`docs/PLAN.md` § 8.S8).
2//!
3//! PLAN's framing was Gnosis-RPC latency + remote block height, but
4//! Bee doesn't expose its eth RPC URL nor a remote chain-tip
5//! reference. Pivoting the screen to what we *can* measure:
6//!
7//! - **Bee API call stats** — latency p50 / p99 + error rate computed
8//!   from the same `tracing` capture that powers the S10 command-log
9//!   pane. This is the more operator-relevant metric anyway: a slow
10//!   Bee API tells you the local node is sluggish, regardless of the
11//!   underlying RPC.
12//! - **Chain state** — `block` / `chain_tip` / their delta from
13//!   `/chainstate`. Bee's own view of the chain.
14//! - **Pending operator transactions** — `/transactions` with hash,
15//!   nonce, and creation timestamp so a stuck postage-topup or
16//!   stake-deposit doesn't disappear into the void.
17//!
18//! The "Bee doesn't expose its eth RPC URL or remote block height"
19//! gap is acknowledged inline so operators see what *isn't*
20//! measured rather than assuming silence equals success.
21//!
22//! Render delegates to the pure [`ApiHealth::view_for`] so the
23//! snapshot tests in `tests/s8_api_health_view.rs` pin every
24//! statistical edge (empty samples, all-success, mixed errors)
25//! without launching a TUI.
26
27use std::sync::Arc;
28
29use color_eyre::Result;
30use ratatui::{
31    Frame,
32    layout::{Constraint, Layout, Rect},
33    style::{Color, Modifier, Style},
34    text::{Line, Span},
35    widgets::{Block, Borders, Paragraph},
36};
37use tokio::sync::watch;
38
39use super::Component;
40use crate::action::Action;
41use crate::api::ApiClient;
42use crate::log_capture::{LogCapture, LogEntry};
43use crate::theme;
44use crate::watch::{HealthSnapshot, TransactionsSnapshot};
45
46/// Window of recent calls considered for the latency / error-rate
47/// summary. Tracks the LogCapture's own ring-buffer capacity (200 in
48/// `log_capture::install`) — lifting the cap above that just yields
49/// the same numbers since older entries are gone.
50pub const STATS_WINDOW: usize = 100;
51
52/// Aggregated call statistics over a window of [`LogEntry`] records.
53#[derive(Debug, Clone, PartialEq)]
54pub struct CallStats {
55    /// Number of entries that contributed (had `elapsed_ms` set).
56    pub sample_size: usize,
57    /// Median latency in milliseconds. `None` if `sample_size == 0`.
58    pub p50_ms: Option<u64>,
59    /// 99th-percentile latency in milliseconds. `None` if
60    /// `sample_size == 0`.
61    pub p99_ms: Option<u64>,
62    /// Percentage of entries with `status >= 400`. `0.0` when no
63    /// entries have a status code attached.
64    pub error_rate_pct: f64,
65}
66
67/// Bee's view of the chain.
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct ChainStateView {
70    pub block: Option<u64>,
71    pub chain_tip: Option<u64>,
72    /// `chain_tip - block`, surfaced separately so the renderer can
73    /// colour-code it without re-doing the subtraction. Negative
74    /// values shouldn't happen on a healthy node but are technically
75    /// possible during chain reorgs — the field is signed for that.
76    pub delta: Option<i64>,
77    pub total_amount: Option<String>,
78    pub current_price: Option<String>,
79}
80
81/// One row of the pending-transactions table.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PendingTxRow {
84    pub nonce: u64,
85    pub hash_short: String,
86    pub to_short: String,
87    /// Full transaction hash (with the `0x` prefix stripped). Rendered
88    /// on the row's continuation line so operators can click-drag to
89    /// copy without losing the table layout.
90    pub hash_full: String,
91    /// Full destination address (`0x` stripped). Same rationale as
92    /// `hash_full`.
93    pub to_full: String,
94    /// RFC 3339 creation timestamp, rendered verbatim. Empty if Bee
95    /// didn't supply one (very early Bee builds).
96    pub created: String,
97    /// Operator-supplied description from the `description` field.
98    /// Empty for system-issued txs.
99    pub description: String,
100    /// Seconds elapsed since `created`. `None` when the timestamp
101    /// failed to parse (or was empty). The renderer humanises this
102    /// into `5s` / `2m 30s` / `8h 15m` and colour-codes by threshold:
103    /// stuck transactions are the most operator-relevant signal in
104    /// this whole pane (a 10-minute-old pending topup is almost
105    /// always under-priced gas, not Bee being slow).
106    pub age_seconds: Option<i64>,
107}
108
109/// Pending-tx age threshold above which the row colours warn-yellow.
110/// 5 minutes — short enough that operators still see colour during a
111/// normal Gnosis confirmation cycle (~10s/block, 6+ blocks for
112/// finality), long enough that the threshold doesn't fire on every
113/// healthy submission.
114pub const PENDING_TX_WARN_AGE_SECS: i64 = 300;
115/// Above this the row colours fail-red — at this point the operator
116/// almost certainly needs to bump gas / cancel.
117pub const PENDING_TX_FAIL_AGE_SECS: i64 = 1800;
118
119/// Aggregated view fed to renderer and snapshot tests.
120#[derive(Debug, Clone, PartialEq)]
121pub struct ApiHealthView {
122    pub bee_endpoint: String,
123    pub call_stats: CallStats,
124    pub chain: ChainStateView,
125    pub pending: Vec<PendingTxRow>,
126}
127
128pub struct ApiHealth {
129    api: Arc<ApiClient>,
130    health_rx: watch::Receiver<HealthSnapshot>,
131    transactions_rx: watch::Receiver<TransactionsSnapshot>,
132    health: HealthSnapshot,
133    transactions: TransactionsSnapshot,
134    log_capture: Option<LogCapture>,
135}
136
137impl ApiHealth {
138    pub fn new(
139        api: Arc<ApiClient>,
140        health_rx: watch::Receiver<HealthSnapshot>,
141        transactions_rx: watch::Receiver<TransactionsSnapshot>,
142        log_capture: Option<LogCapture>,
143    ) -> Self {
144        let health = health_rx.borrow().clone();
145        let transactions = transactions_rx.borrow().clone();
146        Self {
147            api,
148            health_rx,
149            transactions_rx,
150            health,
151            transactions,
152            log_capture,
153        }
154    }
155
156    fn pull_latest(&mut self) {
157        self.health = self.health_rx.borrow().clone();
158        self.transactions = self.transactions_rx.borrow().clone();
159    }
160
161    /// Pure view computation. The log entries arrive as a slice rather
162    /// than a `LogCapture` handle so tests can stub deterministic
163    /// samples without spinning up the global tracing layer.
164    pub fn view_for(
165        bee_endpoint: &str,
166        recent_calls: &[LogEntry],
167        health: &HealthSnapshot,
168        transactions: &TransactionsSnapshot,
169    ) -> ApiHealthView {
170        ApiHealthView {
171            bee_endpoint: bee_endpoint.to_string(),
172            call_stats: call_stats_for(recent_calls),
173            chain: chain_state_view(health),
174            pending: pending_rows(transactions),
175        }
176    }
177}
178
179/// Compute call stats over the last [`STATS_WINDOW`] entries that
180/// have `elapsed_ms` populated. Latency percentiles are computed via
181/// nearest-rank on the sorted sample.
182pub fn call_stats_for(entries: &[LogEntry]) -> CallStats {
183    let recent: Vec<&LogEntry> = entries.iter().rev().take(STATS_WINDOW).collect();
184    let total = recent.len();
185    if total == 0 {
186        return CallStats {
187            sample_size: 0,
188            p50_ms: None,
189            p99_ms: None,
190            error_rate_pct: 0.0,
191        };
192    }
193    let mut latencies: Vec<u64> = recent.iter().filter_map(|e| e.elapsed_ms).collect();
194    latencies.sort_unstable();
195    let with_latency = latencies.len();
196    let p50_ms = percentile(&latencies, 50);
197    let p99_ms = percentile(&latencies, 99);
198    // Error rate is computed against entries that *do* carry a
199    // status — entries without one (in-flight or non-HTTP events)
200    // shouldn't pull the rate down.
201    let with_status: Vec<u16> = recent.iter().filter_map(|e| e.status).collect();
202    let errors = with_status.iter().filter(|s| **s >= 400).count();
203    let error_rate_pct = if with_status.is_empty() {
204        0.0
205    } else {
206        (errors as f64) * 100.0 / (with_status.len() as f64)
207    };
208    CallStats {
209        sample_size: with_latency,
210        p50_ms,
211        p99_ms,
212        error_rate_pct,
213    }
214}
215
216/// Nearest-rank percentile on a pre-sorted slice. Returns `None` for
217/// the empty slice. `pct` is in `0..=100`.
218fn percentile(sorted: &[u64], pct: u32) -> Option<u64> {
219    if sorted.is_empty() {
220        return None;
221    }
222    let n = sorted.len();
223    // Nearest-rank: ceil(pct/100 * n) - 1 (clamped to a valid index).
224    let rank = (pct as usize * n).div_ceil(100);
225    let idx = rank.saturating_sub(1).min(n - 1);
226    Some(sorted[idx])
227}
228
229fn chain_state_view(health: &HealthSnapshot) -> ChainStateView {
230    let Some(cs) = &health.chain_state else {
231        return ChainStateView::default();
232    };
233    let delta = (cs.chain_tip as i64) - (cs.block as i64);
234    ChainStateView {
235        block: Some(cs.block),
236        chain_tip: Some(cs.chain_tip),
237        delta: Some(delta),
238        total_amount: Some(cs.total_amount.to_string()),
239        current_price: Some(cs.current_price.to_string()),
240    }
241}
242
243fn pending_rows(transactions: &TransactionsSnapshot) -> Vec<PendingTxRow> {
244    let now_unix = std::time::SystemTime::now()
245        .duration_since(std::time::UNIX_EPOCH)
246        .map(|d| d.as_secs() as i64)
247        .unwrap_or(0);
248    transactions
249        .pending
250        .iter()
251        .map(|t| {
252            let age_seconds = parse_rfc3339_to_unix(&t.created).map(|ts| now_unix - ts);
253            PendingTxRow {
254                nonce: t.nonce,
255                hash_short: short_hex(&t.transaction_hash),
256                to_short: short_hex(&t.to),
257                hash_full: t.transaction_hash.trim_start_matches("0x").to_string(),
258                to_full: t.to.trim_start_matches("0x").to_string(),
259                created: t.created.clone(),
260                description: t.description.clone(),
261                age_seconds,
262            }
263        })
264        .collect()
265}
266
267/// Parse Bee's RFC 3339 timestamp (`"2026-05-07T08:12:03Z"` or
268/// `"2026-05-07T08:12:03+00:00"`) into seconds-since-Unix-epoch.
269/// Returns `None` for malformed / empty input — the caller falls
270/// back to a `—` in the age column rather than guessing.
271pub fn parse_rfc3339_to_unix(s: &str) -> Option<i64> {
272    if s.is_empty() {
273        return None;
274    }
275    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
276        .ok()
277        .map(|odt| odt.unix_timestamp())
278}
279
280/// Humanise `age_seconds` into `5s` / `2m 30s` / `8h 15m`. Negative
281/// values (clock skew on the host) collapse to `now`. Returns `—`
282/// for `None` so the renderer doesn't have to special-case the
283/// missing-timestamp path.
284pub fn format_age_humanised(age_seconds: Option<i64>) -> String {
285    match age_seconds {
286        None => "—".into(),
287        Some(s) if s < 0 => "now".into(),
288        Some(s) if s < 60 => format!("{s}s"),
289        Some(s) if s < 3_600 => {
290            let m = s / 60;
291            let r = s % 60;
292            format!("{m}m {r:>2}s")
293        }
294        Some(s) => {
295            let h = s / 3_600;
296            let m = (s % 3_600) / 60;
297            format!("{h}h {m:>2}m")
298        }
299    }
300}
301
302fn short_hex(s: &str) -> String {
303    let trimmed = s.trim_start_matches("0x");
304    if trimmed.len() > 12 {
305        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
306    } else {
307        trimmed.to_string()
308    }
309}
310
311impl Component for ApiHealth {
312    fn update(&mut self, action: Action) -> Result<Option<Action>> {
313        if matches!(action, Action::Tick) {
314            self.pull_latest();
315        }
316        Ok(None)
317    }
318
319    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
320        let chunks = Layout::vertical([
321            Constraint::Length(3), // header
322            Constraint::Length(7), // call stats
323            Constraint::Length(5), // chain state
324            Constraint::Min(0),    // pending tx table
325            Constraint::Length(1), // footer
326        ])
327        .split(area);
328
329        let recent: Vec<LogEntry> = self
330            .log_capture
331            .as_ref()
332            .map(|c| c.snapshot())
333            .unwrap_or_default();
334        let view = Self::view_for(&self.api.url, &recent, &self.health, &self.transactions);
335        let t = theme::active();
336
337        // Header
338        let header_l1 = Line::from(vec![
339            Span::styled(
340                "RPC / API HEALTH",
341                Style::default().add_modifier(Modifier::BOLD),
342            ),
343            Span::raw("   endpoint  "),
344            Span::styled(view.bee_endpoint.clone(), Style::default().fg(t.info)),
345        ]);
346        let header_l2 = Line::from(Span::styled(
347            "  Bee doesn't expose its eth RPC URL or remote chain tip; this view measures the local Bee API instead.",
348            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
349        ));
350        frame.render_widget(
351            Paragraph::new(vec![header_l1, header_l2])
352                .block(Block::default().borders(Borders::BOTTOM)),
353            chunks[0],
354        );
355
356        // Call stats
357        let cs = &view.call_stats;
358        let p50 = cs
359            .p50_ms
360            .map(|v| format!("{v} ms"))
361            .unwrap_or_else(|| "—".into());
362        let p99 = cs
363            .p99_ms
364            .map(|v| format!("{v} ms"))
365            .unwrap_or_else(|| "—".into());
366        let err_color = if cs.error_rate_pct >= 5.0 {
367            t.fail
368        } else if cs.error_rate_pct >= 1.0 {
369            t.warn
370        } else {
371            t.pass
372        };
373        let stats_lines = vec![
374            Line::from(vec![Span::styled(
375                "  CALL STATS",
376                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
377            )]),
378            Line::from(vec![
379                Span::raw("    p50 latency   "),
380                Span::styled(p50, Style::default().fg(t.pass)),
381            ]),
382            Line::from(vec![
383                Span::raw("    p99 latency   "),
384                Span::styled(p99, Style::default().fg(t.warn)),
385            ]),
386            Line::from(vec![
387                Span::raw("    error rate    "),
388                Span::styled(
389                    format!("{:.2}%", cs.error_rate_pct),
390                    Style::default().fg(err_color).add_modifier(Modifier::BOLD),
391                ),
392            ]),
393            Line::from(vec![
394                Span::raw("    sample size   "),
395                Span::styled(
396                    format!("{} call(s) (last {STATS_WINDOW})", cs.sample_size),
397                    Style::default().fg(t.dim),
398                ),
399            ]),
400        ];
401        frame.render_widget(
402            Paragraph::new(stats_lines).block(Block::default().borders(Borders::BOTTOM)),
403            chunks[1],
404        );
405
406        // Chain state
407        let block_str = view
408            .chain
409            .block
410            .map(|b| b.to_string())
411            .unwrap_or_else(|| "—".into());
412        let tip_str = view
413            .chain
414            .chain_tip
415            .map(|b| b.to_string())
416            .unwrap_or_else(|| "—".into());
417        let delta_str = view
418            .chain
419            .delta
420            .map(|d| format!("{d:+}"))
421            .unwrap_or_else(|| "—".into());
422        let chain_lines = vec![
423            Line::from(vec![Span::styled(
424                "  CHAIN STATE  (Bee's view, not the wider network)",
425                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
426            )]),
427            Line::from(vec![
428                Span::raw("    block "),
429                Span::styled(block_str, Style::default().fg(t.pass)),
430                Span::raw("   chain tip "),
431                Span::styled(tip_str, Style::default().fg(t.pass)),
432                Span::raw("   Δ "),
433                Span::styled(delta_str, Style::default().fg(t.warn)),
434            ]),
435        ];
436        frame.render_widget(
437            Paragraph::new(chain_lines).block(Block::default().borders(Borders::BOTTOM)),
438            chunks[2],
439        );
440
441        // Pending tx table
442        let mut pending_lines = vec![Line::from(Span::styled(
443            format!("  PENDING TRANSACTIONS  ({})", view.pending.len()),
444            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
445        ))];
446        if view.pending.is_empty() {
447            pending_lines.push(Line::from(Span::styled(
448                "  (no pending operator transactions — all confirmed)",
449                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
450            )));
451        } else {
452            pending_lines.push(Line::from(Span::styled(
453                "  NONCE  HASH           TO              AGE        DESCRIPTION",
454                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
455            )));
456            for r in &view.pending {
457                let age_str = format_age_humanised(r.age_seconds);
458                let age_style = match r.age_seconds {
459                    Some(s) if s >= PENDING_TX_FAIL_AGE_SECS => {
460                        Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
461                    }
462                    Some(s) if s >= PENDING_TX_WARN_AGE_SECS => Style::default().fg(t.warn),
463                    _ => Style::default(),
464                };
465                pending_lines.push(Line::from(vec![
466                    Span::raw("  "),
467                    Span::raw(format!("{:<6} ", r.nonce)),
468                    Span::styled(
469                        format!("{:<14} ", r.hash_short),
470                        Style::default().fg(t.info),
471                    ),
472                    Span::raw(format!("{:<15} ", r.to_short)),
473                    Span::styled(format!("{age_str:<10} "), age_style),
474                    Span::styled(truncate(&r.description, 30), Style::default().fg(t.dim)),
475                ]));
476                // Continuation line with full hash + to-address so
477                // operators can click-drag to copy. The columns above
478                // stay short to preserve the table layout.
479                pending_lines.push(Line::from(vec![
480                    Span::styled("        hash 0x", Style::default().fg(t.dim)),
481                    Span::styled(r.hash_full.clone(), Style::default().fg(t.info)),
482                    Span::styled("  to 0x", Style::default().fg(t.dim)),
483                    Span::styled(r.to_full.clone(), Style::default().fg(t.info)),
484                ]));
485            }
486            // Tooltip line — operators new to the screen don't know
487            // what the colour means or where the threshold sits.
488            pending_lines.push(Line::from(Span::styled(
489                format!(
490                    "  └─ age >= {}m colours warn; >= {}m colours fail (likely under-priced gas)",
491                    PENDING_TX_WARN_AGE_SECS / 60,
492                    PENDING_TX_FAIL_AGE_SECS / 60
493                ),
494                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
495            )));
496        }
497        frame.render_widget(Paragraph::new(pending_lines), chunks[3]);
498
499        // Footer
500        frame.render_widget(
501            Paragraph::new(Line::from(vec![
502                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
503                Span::raw(" switch screen  "),
504                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
505                Span::raw(" help  "),
506                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
507                Span::raw(" quit  "),
508                Span::styled(
509                    "stats live-update from S10's command-log capture",
510                    Style::default().fg(t.dim),
511                ),
512            ])),
513            chunks[4],
514        );
515
516        Ok(())
517    }
518}
519
520fn truncate(s: &str, max: usize) -> String {
521    if s.chars().count() <= max {
522        s.to_string()
523    } else {
524        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
525        out.push('…');
526        out
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn entry(method: &str, status: Option<u16>, elapsed_ms: Option<u64>) -> LogEntry {
535        LogEntry {
536            ts: String::new(),
537            method: method.into(),
538            url: "http://localhost:1633/".into(),
539            status,
540            elapsed_ms,
541            message: String::new(),
542        }
543    }
544
545    #[test]
546    fn parse_rfc3339_z_form() {
547        // Bee's most common format — Z suffix, second precision.
548        let ts = parse_rfc3339_to_unix("2026-05-07T08:12:03Z").expect("must parse");
549        assert!(ts > 1_700_000_000); // sanity: way past 2023
550    }
551
552    #[test]
553    fn parse_rfc3339_offset_form() {
554        let ts = parse_rfc3339_to_unix("2026-05-07T08:12:03+00:00").expect("must parse");
555        assert!(ts > 1_700_000_000);
556    }
557
558    #[test]
559    fn parse_rfc3339_returns_none_on_garbage() {
560        assert_eq!(parse_rfc3339_to_unix(""), None);
561        assert_eq!(parse_rfc3339_to_unix("not a date"), None);
562        assert_eq!(parse_rfc3339_to_unix("2026"), None);
563    }
564
565    #[test]
566    fn format_age_humanised_seconds() {
567        assert_eq!(format_age_humanised(Some(0)), "0s");
568        assert_eq!(format_age_humanised(Some(45)), "45s");
569        assert_eq!(format_age_humanised(Some(59)), "59s");
570    }
571
572    #[test]
573    fn format_age_humanised_minutes() {
574        assert_eq!(format_age_humanised(Some(60)), "1m  0s");
575        assert_eq!(format_age_humanised(Some(125)), "2m  5s");
576        assert_eq!(format_age_humanised(Some(3_599)), "59m 59s");
577    }
578
579    #[test]
580    fn format_age_humanised_hours() {
581        assert_eq!(format_age_humanised(Some(3_600)), "1h  0m");
582        assert_eq!(format_age_humanised(Some(8 * 3_600 + 15 * 60)), "8h 15m");
583    }
584
585    #[test]
586    fn format_age_humanised_special_cases() {
587        assert_eq!(format_age_humanised(None), "—");
588        // Negative = clock skew (host's clock is ahead of Bee's).
589        // Treat as "now" rather than render "-3s".
590        assert_eq!(format_age_humanised(Some(-3)), "now");
591    }
592
593    #[test]
594    fn call_stats_empty_sample() {
595        let stats = call_stats_for(&[]);
596        assert_eq!(stats.sample_size, 0);
597        assert_eq!(stats.p50_ms, None);
598        assert_eq!(stats.p99_ms, None);
599        assert_eq!(stats.error_rate_pct, 0.0);
600    }
601
602    #[test]
603    fn call_stats_all_successful() {
604        let entries: Vec<LogEntry> = (1..=100)
605            .map(|i| entry("GET", Some(200), Some(i)))
606            .collect();
607        let stats = call_stats_for(&entries);
608        assert_eq!(stats.sample_size, 100);
609        assert_eq!(stats.p50_ms, Some(50));
610        assert_eq!(stats.p99_ms, Some(99));
611        assert_eq!(stats.error_rate_pct, 0.0);
612    }
613
614    #[test]
615    fn call_stats_mixed_errors() {
616        let mut entries: Vec<LogEntry> = (1..=10)
617            .map(|i| entry("GET", Some(200), Some(i * 10)))
618            .collect();
619        entries.push(entry("POST", Some(500), Some(50)));
620        entries.push(entry("POST", Some(404), Some(15)));
621        let stats = call_stats_for(&entries);
622        // 12 entries, 2 errors → 16.67%.
623        assert!((stats.error_rate_pct - 16.666_666_666_666_668).abs() < 1e-9);
624    }
625
626    #[test]
627    fn percentile_single_element() {
628        assert_eq!(percentile(&[42], 50), Some(42));
629        assert_eq!(percentile(&[42], 99), Some(42));
630    }
631
632    #[test]
633    fn percentile_empty_returns_none() {
634        assert_eq!(percentile(&[], 50), None);
635    }
636
637    #[test]
638    fn short_hex_truncates_long_address() {
639        let s = short_hex("0xabcdef0123456789abcdef0123456789");
640        assert!(s.contains('…'));
641        assert!(s.starts_with("abcdef"));
642    }
643}