forma-server 0.2.2

Axum middleware for Forma SSR: page rendering, asset serving, CSP, service worker
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
//! The 0.2.2 render: a page with its counts, or a coded error the handler
//! answers.
//!
//! [`try_render`] is the bridge to 3.0.0's `render` on a bound page handle:
//! it takes today's [`PageConfig`] and returns what that `render`
//! will return, `Result<PageResponse, RenderError>`. It sits beside the
//! deprecated `render_page`, which keeps its Phase 1 fallback.

use crate::csp::{self, CspNonce, NonceError, PolicyError, PolicyExtensions};
use crate::head::{HeadLink, ModulePreloads};
use crate::response::{PageResponse, RenderCounts};
use crate::types::PageConfig;
use forma_ir::{IrError, WalkLimits};
use std::fmt;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::time::Instant;

/// What a render takes beyond [`PageConfig`]: the policy's typed extensions,
/// the walk's budgets, and the typed homes for what applications used to put
/// in `body_prefix`: the route's `modulepreload` links from the
/// build's manifest, and head links. Never a nonce: the render draws one per
/// response. Data goes through [`PageConfig::config_json`].
///
/// ```
/// use forma_server::{HeadLink, PolicyExtensions, RenderCtx, WalkLimits};
///
/// let ctx = RenderCtx::new()
///     .with_policy(PolicyExtensions::new().img_src("https:"))
///     .with_limits(WalkLimits { max_list_iterations: 100_000, ..WalkLimits::default() })
///     .with_head_link(HeadLink::new("icon", "/favicon.svg")?);
/// # let _ = ctx;
/// # Ok::<(), forma_server::HeadLinkError>(())
/// ```
#[derive(Debug, Clone, Default)]
pub struct RenderCtx {
    policy: PolicyExtensions,
    limits: WalkLimits,
    module_preloads: Option<ModulePreloads>,
    head_links: Vec<HeadLink>,
}

impl RenderCtx {
    /// The strict policy and the walker's default budgets (8 MiB of output,
    /// 50,000 list rows).
    pub fn new() -> Self {
        Self::default()
    }

    /// Extend this page's policy (see [`PolicyExtensions`]).
    #[must_use]
    pub fn with_policy(mut self, policy: PolicyExtensions) -> Self {
        self.policy = policy;
        self
    }

    /// Budget this page's walk differently from the defaults.
    #[must_use]
    pub fn with_limits(mut self, limits: WalkLimits) -> Self {
        self.limits = limits;
        self
    }

    /// Write the route's `modulepreload` links from the build's manifest,
    /// each with the page's nonce, after the route's own modules. The
    /// typed home for a preload workaround in `body_prefix`, such as
    /// Ledger's. The table covers every route; the render takes
    /// `config.route_pattern`'s list. Like every typed home, each call site
    /// belongs on the application's raw-entry allowlist (`forma-raw-lint`).
    ///
    /// ```no_run
    /// use forma_server::{assets, ModulePreloads, RenderCtx};
    ///
    /// // At start, beside the manifest; refuse to start on its error.
    /// fn start<Assets: rust_embed::Embed>() -> Result<ModulePreloads, String> {
    ///     assets::try_load_module_preloads::<Assets>()
    /// }
    ///
    /// // Per request.
    /// fn ctx(preloads: &ModulePreloads) -> RenderCtx {
    ///     RenderCtx::new().with_module_preloads(preloads)
    /// }
    /// ```
    ///
    /// Verified by: crates/forma-server/tests/typed_homes.rs > "try_render_writes_the_routes_manifest_modulepreload_links_nonced"
    #[must_use]
    pub fn with_module_preloads(mut self, preloads: &ModulePreloads) -> Self {
        self.module_preloads = Some(preloads.clone());
        self
    }

    /// Add a `<link>` to the page's head (see [`HeadLink`]), after any added
    /// before it. The typed home for links spliced in before `</head>`; each
    /// call site belongs on the application's raw-entry allowlist
    /// (`forma-raw-lint`).
    ///
    /// Verified by: crates/forma-server/tests/typed_homes.rs > "head_links_are_written_escaped_in_the_head_in_the_order_added"
    #[must_use]
    pub fn with_head_link(mut self, link: HeadLink) -> Self {
        self.head_links.push(link);
        self
    }

    /// The route's manifest preloads, empty without a table.
    pub(crate) fn module_preloads(&self, route_pattern: &str) -> &[String] {
        self.module_preloads
            .as_ref()
            .map_or(&[], |table| table.route(route_pattern))
    }

    /// The head links, in the order added.
    pub(crate) fn head_links(&self) -> &[HeadLink] {
        &self.head_links
    }
}

/// Why a page was not rendered. Nothing reaches the browser unless the
/// handler sends it: answer with the application's own error page.
///
/// Every error has a stable [`code`](RenderError::code), the
/// [`route`](RenderError::route) it was rendering, and the
/// [`component_path`](RenderError::component_path) where it stopped (empty
/// when the failure is not inside a component). The codes, by family:
///
/// | Code | Variant | Cause |
/// |---|---|---|
/// | RENDER-101 | [`MissingModule`](RenderError::MissingModule) | The config has no module or no slots to render |
/// | RENDER-102 | [`Walk`](RenderError::Walk) | The walk refused the module or its data |
/// | RENDER-201 | [`Policy`](RenderError::Policy) | A policy extension is not a source a page may add |
/// | RENDER-202 | [`Nonce`](RenderError::Nonce) | The system random source failed to draw a nonce |
/// | RENDER-301 | [`Budget`](RenderError::Budget) | The walk's output passed `max_output_bytes` |
/// | RENDER-302 | [`Budget`](RenderError::Budget) | The walk's list rows passed `max_list_iterations` |
/// | RENDER-303 | [`Budget`](RenderError::Budget) | Lists or control flow nested past the walker's depth |
/// | RENDER-399 | [`Fault`](RenderError::Fault) | The render panicked; a defect, not an input |
///
/// A code, once published, keeps its meaning; new causes get new codes.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RenderError {
    /// RENDER-101: [`try_render`] renders on the server, and the config
    /// carries no module or no slots for it. A browser-rendered route is
    /// served by [`render_browser_shell`], by declaration.
    #[non_exhaustive]
    MissingModule {
        /// The route being rendered.
        route: String,
    },
    /// RENDER-102: the walk refused the module or the data it was given.
    #[non_exhaustive]
    Walk {
        /// The route being rendered.
        route: String,
        /// The islands open where the walk stopped, outermost first.
        component_path: Vec<String>,
        /// How far the walk got.
        counts: RenderCounts,
        /// The walker's error.
        cause: IrError,
    },
    /// RENDER-201: a policy extension was refused; there is no page to send
    /// without its policy.
    #[non_exhaustive]
    Policy {
        /// The route being rendered.
        route: String,
        /// The refused extension.
        cause: PolicyError,
    },
    /// RENDER-202: no nonce could be drawn, so no policy could bless the page.
    #[non_exhaustive]
    Nonce {
        /// The route being rendered.
        route: String,
        /// The failed draw.
        cause: NonceError,
    },
    /// RENDER-301, RENDER-302, RENDER-303: the walk reached one of its
    /// budgets, and stopped there instead of running on.
    #[non_exhaustive]
    Budget {
        /// The route being rendered.
        route: String,
        /// The islands open where the walk stopped, outermost first.
        component_path: Vec<String>,
        /// How far the walk got before the budget stopped it.
        counts: RenderCounts,
        /// The budget the walker reports.
        cause: IrError,
    },
    /// RENDER-399: the render panicked and the panic was caught. The
    /// process and every other request carry on, while the application
    /// unwinds on panic; under `panic = "abort"` the process ends.
    #[non_exhaustive]
    Fault {
        /// The route being rendered.
        route: String,
        /// The islands open where the walk panicked, when it was in the walk.
        component_path: Vec<String>,
        /// How far the walk got, when it was in the walk.
        counts: RenderCounts,
        /// The panic's message.
        message: String,
    },
}

impl RenderError {
    /// The stable code, such as `RENDER-301`.
    pub fn code(&self) -> &'static str {
        match self {
            RenderError::MissingModule { .. } => "RENDER-101",
            RenderError::Walk { .. } => "RENDER-102",
            RenderError::Policy { .. } => "RENDER-201",
            RenderError::Nonce { .. } => "RENDER-202",
            RenderError::Budget { cause, .. } => match cause {
                IrError::OutputLimitExceeded { .. } => "RENDER-301",
                IrError::ListBudgetExceeded { .. } => "RENDER-302",
                _ => "RENDER-303",
            },
            RenderError::Fault { .. } => "RENDER-399",
        }
    }

    /// The route pattern the render was for.
    pub fn route(&self) -> &str {
        match self {
            RenderError::MissingModule { route }
            | RenderError::Walk { route, .. }
            | RenderError::Policy { route, .. }
            | RenderError::Nonce { route, .. }
            | RenderError::Budget { route, .. }
            | RenderError::Fault { route, .. } => route,
        }
    }

    /// The component names of the islands open where the render stopped,
    /// outermost first; empty when it stopped outside every island or before
    /// the walk. A component the compiler inlined into its parent has no name
    /// until FMIR v3's source positions.
    pub fn component_path(&self) -> &[String] {
        match self {
            RenderError::Walk { component_path, .. }
            | RenderError::Budget { component_path, .. }
            | RenderError::Fault { component_path, .. } => component_path,
            _ => &[],
        }
    }

    /// How far the walk got, for an error raised during it.
    pub fn counts(&self) -> Option<RenderCounts> {
        match self {
            RenderError::Walk { counts, .. }
            | RenderError::Budget { counts, .. }
            | RenderError::Fault { counts, .. } => Some(*counts),
            _ => None,
        }
    }
}

impl fmt::Display for RenderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} {}", self.code(), self.route())?;
        let path = self.component_path();
        if !path.is_empty() {
            write!(f, " in {}", path.join(" > "))?;
        }
        match self {
            RenderError::MissingModule { .. } => {
                write!(f, ": no module or slots to render on the server")
            }
            RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => {
                write!(f, ": {cause}")
            }
            RenderError::Policy { cause, .. } => write!(f, ": {cause}"),
            RenderError::Nonce { cause, .. } => write!(f, ": {cause}"),
            RenderError::Fault { message, .. } => write!(f, ": render panicked: {message}"),
        }?;
        if let Some(c) = self.counts() {
            write!(
                f,
                " (after {} nodes, {} bytes, {} list rows)",
                c.nodes, c.bytes, c.list_iterations
            )?;
        }
        Ok(())
    }
}

impl std::error::Error for RenderError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            RenderError::Walk { cause, .. } | RenderError::Budget { cause, .. } => Some(cause),
            RenderError::Policy { cause, .. } => Some(cause),
            RenderError::Nonce { cause, .. } => Some(cause),
            _ => None,
        }
    }
}

/// Render a page on the server: the page with its counts, or why not.
///
/// The bridge to 3.0.0's `render`. Unlike the deprecated
/// `render_page`, it never substitutes an empty shell for a failure: a
/// missing module, a walk the module or data fails, an exceeded budget, an
/// invalid policy extension, a failed nonce draw and a panic each come back
/// as a [`RenderError`] with a stable code, the route and the component
/// path. The page is written into its own buffer and returned whole, or not
/// at all, so no partial page leaves. Forma logs none of these: the handler
/// chooses the response. (A caught panic still runs the process's panic hook
/// first: the default hook prints it to stderr, and a reporting hook, such as
/// an error tracker's, records it.)
///
/// It always renders on the server: `config.render_mode` steers only the
/// deprecated `render_page`. A route the build declares `ssr: false` is
/// served by [`render_browser_shell`].
///
/// ```no_run
/// use axum::http::StatusCode;
/// use axum::response::{IntoResponse, Response};
/// use forma_server::{try_render, PageConfig, RenderCtx};
///
/// fn home(config: &PageConfig) -> Response {
///     match try_render(config, &RenderCtx::new()) {
///         Ok(page) => page.into_response(), // no-store, policy, headers
///         Err(error) => {
///             // Count it by code and route; serve the application's error page.
///             eprintln!("{error}");
///             StatusCode::INTERNAL_SERVER_ERROR.into_response()
///         }
///     }
/// }
/// ```
///
/// # Threads and panics
///
/// The walk is synchronous work on the caller's thread, and forma-server
/// starts nothing and depends on no async runtime. At the measured 12.5
/// million nodes a second, a page past about 12,500 nodes holds the thread
/// past a millisecond; [`RenderCounts`] reports each page's nodes and walk
/// time, so a handler whose counts show that can move the render into tokio's
/// blocking pool itself (owned inputs, since the closure must be `'static`):
///
/// ```no_run
/// # use std::sync::Arc;
/// # use forma_server::{try_render, AssetManifest, PageConfig, RenderCtx, RenderMode, PageResponse, RenderError};
/// # use forma_ir::{IrModule, SlotData};
/// # #[allow(deprecated)]
/// async fn heavy(
///     manifest: Arc<AssetManifest>,
///     module: Arc<IrModule>,
///     slots: SlotData,
/// ) -> Result<PageResponse, RenderError> {
///     tokio::task::spawn_blocking(move || {
///         # #[allow(deprecated)]
///         let config = PageConfig {
///             title: "Ledger",
///             route_pattern: "/ledger",
///             manifest: &manifest,
///             config_script: None,
///             config_json: None,
///             body_class: None,
///             personality_css: None,
///             body_prefix: None,
///             render_mode: RenderMode::Phase2SsrReconcile,
///             ir_module: Some(&module),
///             slots: Some(&slots),
///         };
///         try_render(&config, &RenderCtx::new())
///     })
///     .await
///     .expect("the blocking task ran")
/// }
/// ```
///
/// A panic inside the render is caught and returned as
/// [`RenderError::Fault`], which holds only while the application
/// unwinds on panic, as both templates leave it. Under `panic = "abort"` a
/// fault still ends the process with every request in flight.
///
/// Verified by: crates/forma-server/tests/try_render.rs > "a_panicking_component_returns_fault_the_handler_answers_500_and_the_next_request_renders"
pub fn try_render(config: &PageConfig, ctx: &RenderCtx) -> Result<PageResponse, RenderError> {
    render_guarded(config, ctx, CspNonce::try_generate, Root::Walk)
}

/// Serve a route the build declares browser-rendered (`ssr: false`): the
/// nonced bundle and the same headers as any page, around an empty root.
///
/// By declaration, never in place of a failure: it does not walk,
/// even when the config carries a module. Fails only as a page fails before
/// its walk: an invalid policy extension, a failed nonce draw, or a fault.
///
/// Verified by: crates/forma-server/tests/try_render.rs > "render_browser_shell_serves_the_bundle_around_an_empty_root_with_the_page_headers"
pub fn render_browser_shell(
    config: &PageConfig,
    ctx: &RenderCtx,
) -> Result<PageResponse, RenderError> {
    render_guarded(config, ctx, CspNonce::try_generate, Root::Shell)
}

/// What goes in the page's root.
#[derive(Debug, Clone, Copy)]
enum Root {
    /// The walk's HTML.
    Walk,
    /// Nothing: the browser renders.
    Shell,
}

/// The render, with the nonce source a parameter so a test can make it fail,
/// inside the outer `catch_unwind`.
///
/// The walk catches its own panic, with the component path
/// (`forma_ir::walk_to_html_counted`); this guard covers the rest of the
/// render, which carries no `unwrap`, `expect` or `panic!` (the crate denies
/// them) and so has no known panic to catch.
fn render_guarded(
    config: &PageConfig,
    ctx: &RenderCtx,
    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
    root: Root,
) -> Result<PageResponse, RenderError> {
    match catch_unwind(AssertUnwindSafe(|| render(config, ctx, draw, root))) {
        Ok(result) => result,
        Err(payload) => Err(RenderError::Fault {
            route: config.route_pattern.to_string(),
            component_path: Vec::new(),
            counts: RenderCounts::default(),
            message: panic_message(payload.as_ref()),
        }),
    }
}

fn render(
    config: &PageConfig,
    ctx: &RenderCtx,
    draw: impl FnOnce() -> Result<CspNonce, NonceError>,
    root: Root,
) -> Result<PageResponse, RenderError> {
    let route = || config.route_pattern.to_string();
    let nonce = draw().map_err(|cause| RenderError::Nonce {
        route: route(),
        cause,
    })?;
    // The policy before the walk: an invalid extension costs no walk.
    let policy = csp::page_policy(&nonce, &ctx.policy).map_err(|cause| RenderError::Policy {
        route: route(),
        cause,
    })?;

    let (ssr_body, counts) = match root {
        Root::Shell => (None, RenderCounts::default()),
        Root::Walk => {
            let (Some(module), Some(slots)) = (config.ir_module, config.slots) else {
                return Err(RenderError::MissingModule { route: route() });
            };
            let started = Instant::now();
            let walked = forma_ir::walk_to_html_counted(module, slots, ctx.limits);
            let walk_time = started.elapsed();
            match walked {
                Ok(out) => (
                    Some(out.html),
                    RenderCounts::from_walk(out.counts, walk_time),
                ),
                Err(failure) => {
                    let route = route();
                    let component_path = failure.component_path;
                    let counts = RenderCounts::from_walk(failure.counts, walk_time);
                    let cause = failure.error;
                    return Err(match cause {
                        IrError::OutputLimitExceeded { .. }
                        | IrError::ListBudgetExceeded { .. }
                        | IrError::ListDepthExceeded { .. }
                        | IrError::RecursionLimitExceeded => RenderError::Budget {
                            route,
                            component_path,
                            counts,
                            cause,
                        },
                        IrError::Panicked { message } => RenderError::Fault {
                            route,
                            component_path,
                            counts,
                            message,
                        },
                        cause => RenderError::Walk {
                            route,
                            component_path,
                            counts,
                            cause,
                        },
                    });
                }
            }
        }
    };

    let html = crate::template::write_document(config, &nonce, ssr_body.as_deref(), ctx);
    if let Some(reason) = policy.relaxed() {
        csp::log_relaxation(Some(config.route_pattern), reason);
    }
    Ok(PageResponse::with_policy(html, nonce, policy, counts))
}

/// A panic payload's message: `panic!` with a literal gives a `&str`, with
/// formatting a `String`.
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
    if let Some(message) = payload.downcast_ref::<&str>() {
        (*message).to_string()
    } else if let Some(message) = payload.downcast_ref::<String>() {
        message.clone()
    } else {
        "a panic with no message".to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{AssetManifest, RenderMode};
    use std::collections::HashMap;

    #[allow(deprecated)] // `wasm` is named by every 0.2.x `AssetManifest` literal (DR-23)
    fn manifest() -> AssetManifest {
        AssetManifest {
            version: 1,
            build_hash: "test".to_string(),
            assets: HashMap::new(),
            routes: HashMap::new(),
            wasm: None,
        }
    }

    #[allow(deprecated)]
    fn shell_config(manifest: &AssetManifest) -> PageConfig<'_> {
        PageConfig {
            title: "Seam",
            route_pattern: "/seam",
            manifest,
            config_script: None,
            config_json: None,
            body_class: None,
            personality_css: None,
            body_prefix: None,
            render_mode: RenderMode::Phase1ClientMount,
            ir_module: None,
            slots: None,
        }
    }

    #[test]
    fn render_202_a_failed_nonce_draw() {
        // The OS random source cannot be made to fail from a test, so the
        // render takes its draw as a parameter and this test hands it a
        // failing one. `try_render` passes `CspNonce::try_generate`.
        let manifest = manifest();
        for root in [Root::Walk, Root::Shell] {
            let error = render_guarded(
                &shell_config(&manifest),
                &RenderCtx::new(),
                || Err(NonceError),
                root,
            )
            .unwrap_err();
            assert_eq!(error.code(), "RENDER-202");
            assert_eq!(error.route(), "/seam");
            assert!(error.component_path().is_empty());
            assert_eq!(error.counts(), None);
        }
        // The same seam with a real draw renders: the failure is the draw's.
        let page = render_guarded(
            &shell_config(&manifest),
            &RenderCtx::new(),
            CspNonce::try_generate,
            Root::Shell,
        );
        assert!(page.is_ok());
    }

    #[test]
    fn a_panic_outside_the_walk_is_still_a_fault() {
        // The outer guard: a panic before the walk (here, in the draw) comes
        // back as RENDER-399 too, with no component path.
        let manifest = manifest();
        let error = render_guarded(
            &shell_config(&manifest),
            &RenderCtx::new(),
            || -> Result<CspNonce, NonceError> { panic!("draw blew up") },
            Root::Shell,
        )
        .unwrap_err();
        assert_eq!(error.code(), "RENDER-399");
        assert_eq!(error.route(), "/seam");
        assert!(error.component_path().is_empty());
        match error {
            RenderError::Fault { message, .. } => assert_eq!(message, "draw blew up"),
            other => panic!("expected Fault, got {other:?}"),
        }
    }

    #[test]
    fn an_invalid_extension_fails_before_the_walk() {
        // No module at all: if the walk ran first this would be RENDER-101.
        let manifest = manifest();
        let ctx = RenderCtx::new().with_policy(PolicyExtensions::new().form_action("'none'"));
        let error = try_render(&shell_config(&manifest), &ctx).unwrap_err();
        assert_eq!(error.code(), "RENDER-201");
    }

    #[test]
    fn display_names_code_route_component_and_counts() {
        let error = RenderError::Budget {
            route: "/t/:tenant".to_string(),
            component_path: vec!["Shell".to_string(), "Branding".to_string()],
            counts: RenderCounts {
                nodes: 102,
                bytes: 2048,
                list_iterations: 50,
                ..RenderCounts::default()
            },
            cause: IrError::ListBudgetExceeded { max: 50 },
        };
        let text = error.to_string();
        assert!(
            text.starts_with("RENDER-302 /t/:tenant in Shell > Branding: "),
            "{text}"
        );
        assert!(
            text.ends_with("(after 102 nodes, 2048 bytes, 50 list rows)"),
            "{text}"
        );
    }
}