1use crate::error::WasmError;
7use js_sys::{Function, Object, Reflect, Uint8Array};
8use serde_json::Value;
9use std::sync::Arc;
10use wasm_bindgen::prelude::*;
11use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
12use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
13use webui_handler::plugin::webui::WebUIHydrationPlugin;
14use webui_handler::{
15 BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode, HandlerError,
16 Protocol as HandlerProtocol, RenderOptions, ResponseWriter, SessionOptions,
17 StreamStep as HandlerStreamStep, StreamingSession as HandlerStreamingSession, WebUIHandler,
18};
19#[cfg(test)]
20use webui_protocol::WebUIProtocol;
21
22const STREAM_CHUNK_SIZE: usize = 16 * 1024;
23
24webui_handler::define_string_response_writer!(StringWriter, content);
25
26struct CallbackWriter<'a> {
28 on_chunk: &'a Function,
29 buffer: String,
30}
31
32impl<'a> CallbackWriter<'a> {
33 fn new(on_chunk: &'a Function) -> Self {
34 Self {
35 on_chunk,
36 buffer: String::with_capacity(STREAM_CHUNK_SIZE),
37 }
38 }
39
40 fn flush(&mut self) -> webui_handler::Result<()> {
41 if self.buffer.is_empty() {
42 return Ok(());
43 }
44
45 let chunk = std::mem::replace(&mut self.buffer, String::with_capacity(STREAM_CHUNK_SIZE));
46 self.on_chunk
47 .call1(&JsValue::UNDEFINED, &JsValue::from_str(&chunk))
48 .map(|_| ())
49 .map_err(|error| HandlerError::Writer(format!("{error:?}")))
50 }
51}
52
53impl ResponseWriter for CallbackWriter<'_> {
54 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
55 self.buffer.push_str(content);
56 if self.buffer.len() >= STREAM_CHUNK_SIZE {
57 self.flush()?;
58 }
59 Ok(())
60 }
61
62 fn write_attribute(&mut self, name: &str, value: &str) -> webui_handler::Result<()> {
63 webui_handler::append_attribute_to_string(&mut self.buffer, name, value);
64 if self.buffer.len() >= STREAM_CHUNK_SIZE {
65 self.flush()?;
66 }
67 Ok(())
68 }
69
70 fn write_boolean_attribute(&mut self, name: &str) -> webui_handler::Result<()> {
71 webui_handler::append_boolean_attribute_to_string(&mut self.buffer, name);
72 if self.buffer.len() >= STREAM_CHUNK_SIZE {
73 self.flush()?;
74 }
75 Ok(())
76 }
77
78 fn end(&mut self) -> webui_handler::Result<()> {
79 self.flush()
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub(crate) enum HandlerPluginKind {
85 FastV2,
86 FastV3,
87 WebUI,
88}
89
90impl HandlerPluginKind {
91 fn parse(name: &str) -> Result<Self, WasmError> {
92 match name {
93 "fast" | "fast-v2" => Ok(Self::FastV2),
94 "fast-v3" => Ok(Self::FastV3),
95 "webui" => Ok(Self::WebUI),
96 other => Err(WasmError::UnknownPlugin(other.to_string())),
97 }
98 }
99}
100
101struct WasmRenderOptions {
102 entry: String,
103 request_path: String,
104}
105
106impl Default for WasmRenderOptions {
107 fn default() -> Self {
108 Self {
109 entry: "index.html".to_string(),
110 request_path: "/".to_string(),
111 }
112 }
113}
114
115#[wasm_bindgen]
117pub struct Protocol {
118 inner: Arc<HandlerProtocol>,
119 handler: Arc<WebUIHandler>,
120}
121
122#[wasm_bindgen]
123impl Protocol {
124 #[wasm_bindgen(constructor)]
126 pub fn new(protocol_bytes: &[u8], plugin: Option<String>) -> Result<Protocol, JsValue> {
127 let plugin = parse_optional_plugin(plugin.as_deref())
128 .map_err(|error| JsValue::from_str(&error.to_string()))?;
129 let inner = HandlerProtocol::from_protobuf(protocol_bytes)
130 .map_err(|error| JsValue::from_str(&format!("Protocol error: {error}")))?;
131 Ok(Self {
132 inner: Arc::new(inner),
133 handler: Arc::new(create_handler(plugin)),
134 })
135 }
136
137 #[wasm_bindgen(js_name = render)]
139 pub fn render(&self, state_json: &str, options: Option<Object>) -> Result<String, JsValue> {
140 let options =
141 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
142 let state =
143 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
144 render_protocol_to_string_value(&self.handler, &self.inner, &state, &options)
145 .map_err(|error| JsValue::from_str(&error.to_string()))
146 }
147
148 #[wasm_bindgen(js_name = renderStream)]
150 pub fn render_stream(
151 &self,
152 state_json: &str,
153 on_chunk: &Function,
154 options: Option<Object>,
155 ) -> Result<(), JsValue> {
156 let options =
157 parse_render_options(options).map_err(|error| JsValue::from_str(&error.to_string()))?;
158 let state =
159 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))?;
160 render_protocol_to_callback_value(&self.handler, &self.inner, &state, &options, on_chunk)
161 .map_err(|error| JsValue::from_str(&error.to_string()))
162 }
163
164 #[wasm_bindgen(js_name = renderPartial)]
166 pub fn render_partial(
167 &self,
168 state_json: &str,
169 entry_id: &str,
170 request_path: &str,
171 inventory_hex: &str,
172 ) -> Result<String, JsValue> {
173 self.inner
174 .render_partial(state_json, entry_id, request_path, inventory_hex)
175 .map_err(|error| JsValue::from_str(&format!("render_partial failed: {error}")))
176 }
177
178 #[wasm_bindgen(js_name = renderComponentTemplates)]
180 pub fn render_component_templates(
181 &self,
182 component_tags: JsValue,
183 inventory_hex: &str,
184 ) -> Result<String, JsValue> {
185 let tags: Vec<String> = serde_wasm_bindgen::from_value(component_tags)
186 .map_err(|error| JsValue::from_str(&format!("invalid component tags: {error}")))?;
187 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
188 let result = self
189 .inner
190 .render_component_templates(&tag_refs, inventory_hex)
191 .map_err(|error| {
192 JsValue::from_str(&format!("render_component_templates failed: {error}"))
193 })?;
194 serde_json::to_string(&result)
195 .map_err(|error| JsValue::from_str(&format!("JSON serialize error: {error}")))
196 }
197
198 #[wasm_bindgen(js_name = tokens)]
200 pub fn tokens(&self) -> Result<JsValue, JsValue> {
201 serde_wasm_bindgen::to_value(self.inner.tokens())
202 .map_err(|error| JsValue::from_str(&format!("Serialization error: {error}")))
203 }
204
205 #[wasm_bindgen(js_name = streamResponse)]
211 pub fn stream_response(
212 &self,
213 entry: String,
214 request_path: String,
215 options: Option<Object>,
216 ) -> Result<StreamingSession, JsValue> {
217 let mut session_options = SessionOptions::new(entry, request_path);
218 if let Some(options) = options {
219 session_options.nonce = optional_string_property(&options, "nonce")?;
220 session_options.head_inject = optional_string_property(&options, "headInject")?;
221 session_options.body_inject = optional_string_property(&options, "bodyInject")?;
222 }
223
224 HandlerStreamingSession::new(
225 Arc::clone(&self.handler),
226 Arc::clone(&self.inner),
227 session_options,
228 )
229 .map(|inner| StreamingSession { inner })
230 .map_err(streaming_error)
231 }
232}
233
234#[wasm_bindgen]
256pub struct StreamingSession {
257 inner: HandlerStreamingSession,
258}
259
260#[wasm_bindgen]
261impl StreamingSession {
262 #[wasm_bindgen(js_name = start)]
264 pub fn start(&mut self, state_json: &str) -> Result<Object, JsValue> {
265 let state = session_state(state_json)?;
266 let step = self.inner.start(&state).map_err(streaming_error)?;
267 stream_step_object(step)
268 }
269
270 #[wasm_bindgen(js_name = resume)]
275 pub fn resume(
276 &mut self,
277 instance_id: u32,
278 state_json: &str,
279 mode: Option<String>,
280 ) -> Result<Object, JsValue> {
281 let state = session_state(state_json)?;
282 let mode = parse_boundary_mode(mode.as_deref())?;
283 let step = self
284 .inner
285 .resume(BoundaryInstanceId::from_raw(instance_id), &state, mode)
286 .map_err(streaming_error)?;
287 stream_step_object(step)
288 }
289
290 #[wasm_bindgen(js_name = advance)]
295 pub fn advance(&mut self) -> Result<Object, JsValue> {
296 let step = self.inner.advance().map_err(streaming_error)?;
297 stream_step_object(step)
298 }
299
300 #[wasm_bindgen(js_name = update)]
302 pub fn update(&mut self, instance_id: u32, patch_json: &str) -> Result<Vec<u8>, JsValue> {
303 let patch = session_state(patch_json)?;
304
305 self.inner
306 .update(BoundaryInstanceId::from_raw(instance_id), &patch)
307 .map_err(streaming_error)
308 }
309}
310
311fn stream_step_object(step: HandlerStreamStep) -> Result<Object, JsValue> {
312 let result = Object::new();
313 let bytes = Uint8Array::from(step.bytes.as_slice());
314 set_object_property(&result, "bytes", bytes.as_ref())?;
315 set_object_property(&result, "done", &JsValue::from_bool(step.done))?;
316 if let Some(boundary) = step.boundary {
317 let boundary = boundary_object(boundary)?;
318 set_object_property(&result, "boundary", boundary.as_ref())?;
319 }
320 Ok(result)
321}
322
323fn boundary_object(boundary: BoundaryDescriptor) -> Result<Object, JsValue> {
324 let result = Object::new();
325 set_object_property(
326 &result,
327 "instanceId",
328 &JsValue::from_f64(f64::from(boundary.instance_id.raw())),
329 )?;
330 set_object_property(
331 &result,
332 "declarationId",
333 &JsValue::from_f64(f64::from(boundary.declaration_id)),
334 )?;
335 set_object_property(&result, "owner", &JsValue::from_str(&boundary.owner))?;
336 set_object_property(&result, "name", &JsValue::from_str(&boundary.name))?;
337 if let Some(key) = boundary.key {
338 set_object_property(&result, "key", &boundary_key_value(key)?)?;
339 }
340 Ok(result)
341}
342
343fn boundary_key_value(key: BoundaryKey) -> Result<JsValue, JsValue> {
344 match key {
345 BoundaryKey::String(value) => Ok(JsValue::from_str(&value)),
346 BoundaryKey::Number(value) => value.as_f64().map(JsValue::from_f64).ok_or_else(|| {
347 JsValue::from_str("boundary key cannot be represented as a JavaScript number")
348 }),
349 }
350}
351
352fn set_object_property(object: &Object, key: &str, value: &JsValue) -> Result<(), JsValue> {
353 let written = Reflect::set(object.as_ref(), &JsValue::from_str(key), value)
354 .map_err(|_| JsValue::from_str(&format!("failed to set StreamStep '{key}' property")))?;
355 if written {
356 Ok(())
357 } else {
358 Err(JsValue::from_str(&format!(
359 "failed to set StreamStep '{key}' property"
360 )))
361 }
362}
363
364fn session_state(state_json: &str) -> Result<Value, JsValue> {
365 parse_state_json(state_json).map_err(|error| JsValue::from_str(&error.to_string()))
366}
367
368fn parse_boundary_mode(mode: Option<&str>) -> Result<BoundaryMode, JsValue> {
369 match mode {
370 None | Some("final") => Ok(BoundaryMode::Final),
371 Some("updatable") => Ok(BoundaryMode::Updatable),
372 Some(other) => Err(JsValue::from_str(&format!(
373 "unknown boundary mode '{other}'; expected 'final' or 'updatable'"
374 ))),
375 }
376}
377
378fn streaming_error(error: HandlerError) -> JsValue {
379 JsValue::from_str(&error.to_string())
380}
381
382fn optional_string_property(options: &Object, key: &str) -> Result<Option<String>, JsValue> {
383 let value = Reflect::get(options, &JsValue::from_str(key))
384 .map_err(|_| JsValue::from_str(&format!("failed to read '{key}' option")))?;
385 if value.is_undefined() || value.is_null() {
386 return Ok(None);
387 }
388 value
389 .as_string()
390 .map(Some)
391 .ok_or_else(|| JsValue::from_str(&format!("'{key}' must be a string")))
392}
393
394#[cfg(test)]
395pub(crate) fn render_protocol_to_string(
396 protocol: &WebUIProtocol,
397 state_json: &str,
398 entry: &str,
399 request_path: &str,
400 plugin: Option<HandlerPluginKind>,
401) -> Result<String, WasmError> {
402 let state = parse_state_json(state_json)?;
403 let options = WasmRenderOptions {
404 entry: entry.to_string(),
405 request_path: request_path.to_string(),
406 };
407 let protocol = HandlerProtocol::new(protocol.clone());
408 let handler = create_handler(plugin);
409 render_protocol_to_string_value(&handler, &protocol, &state, &options)
410}
411
412fn parse_state_json(state_json: &str) -> Result<Value, WasmError> {
413 serde_json::from_str(state_json).map_err(WasmError::State)
414}
415
416fn render_protocol_to_string_value(
417 handler: &WebUIHandler,
418 protocol: &HandlerProtocol,
419 state: &Value,
420 options: &WasmRenderOptions,
421) -> Result<String, WasmError> {
422 let mut writer = StringWriter::with_capacity(4096);
423 handler.render(
424 protocol,
425 state,
426 &RenderOptions::new(&options.entry, &options.request_path),
427 &mut writer,
428 )?;
429 Ok(writer.content)
430}
431
432fn render_protocol_to_callback_value(
433 handler: &WebUIHandler,
434 protocol: &HandlerProtocol,
435 state: &Value,
436 options: &WasmRenderOptions,
437 on_chunk: &Function,
438) -> Result<(), WasmError> {
439 let mut writer = CallbackWriter::new(on_chunk);
440 handler.render(
441 protocol,
442 state,
443 &RenderOptions::new(&options.entry, &options.request_path),
444 &mut writer,
445 )?;
446 writer.flush()?;
447 Ok(())
448}
449
450pub(crate) fn parse_optional_plugin(
451 plugin: Option<&str>,
452) -> Result<Option<HandlerPluginKind>, WasmError> {
453 plugin.map(HandlerPluginKind::parse).transpose()
454}
455
456fn parse_render_options(options: Option<Object>) -> Result<WasmRenderOptions, WasmError> {
457 let mut parsed = WasmRenderOptions::default();
458 let Some(options) = options else {
459 return Ok(parsed);
460 };
461
462 if let Some(entry) = optional_string_field(options.as_ref(), "entry")? {
463 parsed.entry = entry;
464 }
465 if let Some(request_path) = optional_string_field(options.as_ref(), "requestPath")? {
466 parsed.request_path = request_path;
467 }
468 Ok(parsed)
469}
470
471fn optional_string_field(options: &JsValue, field: &str) -> Result<Option<String>, WasmError> {
472 let value = Reflect::get(options, &JsValue::from_str(field)).map_err(|_| {
473 WasmError::InvalidOptions(format!("failed to read `{field}` from options object"))
474 })?;
475 if value.is_null() || value.is_undefined() {
476 return Ok(None);
477 }
478 value.as_string().map(Some).ok_or_else(|| {
479 WasmError::InvalidOptions(format!("`{field}` must be a string when provided"))
480 })
481}
482
483fn create_handler(plugin: Option<HandlerPluginKind>) -> WebUIHandler {
484 match plugin {
485 Some(HandlerPluginKind::FastV2) => {
486 WebUIHandler::with_plugin(|| Box::new(FastV2HydrationPlugin::new()))
487 }
488 Some(HandlerPluginKind::FastV3) => {
489 WebUIHandler::with_plugin(|| Box::new(FastV3HydrationPlugin::new()))
490 }
491 Some(HandlerPluginKind::WebUI) => {
492 WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()))
493 }
494 None => WebUIHandler::new(),
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 fn structural_fragment(value: &str) -> webui_protocol::WebUIFragment {
503 let mut token = String::with_capacity("}}}webui:".len() + value.len());
504 token.push_str("}}}webui:");
505 token.push_str(value);
506 webui_protocol::WebUIFragment::signal(token, true)
507 }
508
509 #[test]
510 fn parse_plugin_keeps_fast_aliases_parser_free() {
511 assert_eq!(
512 parse_optional_plugin(Some("fast")).unwrap(),
513 Some(HandlerPluginKind::FastV2)
514 );
515 assert_eq!(
516 parse_optional_plugin(Some("fast-v2")).unwrap(),
517 Some(HandlerPluginKind::FastV2)
518 );
519 assert_eq!(
520 parse_optional_plugin(Some("fast-v3")).unwrap(),
521 Some(HandlerPluginKind::FastV3)
522 );
523 assert_eq!(
524 parse_optional_plugin(Some("webui")).unwrap(),
525 Some(HandlerPluginKind::WebUI)
526 );
527 }
528
529 #[test]
530 fn parse_plugin_rejects_unknown_names() {
531 let err = parse_optional_plugin(Some("unknown")).unwrap_err();
532 assert_eq!(
533 err.to_string(),
534 "Unknown plugin: unknown. Use \"webui\", \"fast-v3\", \"fast-v2\", or \"fast\"."
535 );
536 }
537
538 #[test]
539 fn protocol_reuses_decoded_protocol() {
540 use std::collections::HashMap;
541 use webui_protocol::{FragmentList, WebUIFragment};
542
543 let mut fragments = HashMap::new();
544 fragments.insert(
545 "index.html".to_string(),
546 FragmentList {
547 fragments: vec![WebUIFragment::signal("name".to_string(), true)],
548 contains_boundary: false,
549 },
550 );
551 let bytes = WebUIProtocol::new(fragments)
552 .to_protobuf()
553 .expect("protocol should serialize");
554 let protocol = Protocol::new(&bytes, None).expect("protocol should load");
555
556 let first = protocol
557 .render(r#"{"name":"first"}"#, None)
558 .expect("first render should succeed");
559 let second = protocol
560 .render(r#"{"name":"second"}"#, None)
561 .expect("second render should succeed");
562
563 assert_eq!(first, "first");
564 assert_eq!(second, "second");
565 }
566
567 #[test]
568 fn render_projects_state_to_component_hydration_keys() {
569 use std::collections::HashMap;
570 use webui_protocol::{
571 ComponentData, FragmentList, InitialStateStrategy, StateProjectionMode, WebUIFragment,
572 };
573
574 let mut fragments = HashMap::new();
575 fragments.insert(
576 "index.html".to_string(),
577 FragmentList {
578 fragments: vec![
579 WebUIFragment::raw("<html><head>"),
580 structural_fragment("head_end"),
581 WebUIFragment::raw("</head><body>"),
582 WebUIFragment::component("client-card"),
583 structural_fragment("body_end"),
584 WebUIFragment::raw("</body></html>"),
585 ],
586 contains_boundary: false,
587 },
588 );
589 fragments.insert(
590 "client-card".to_string(),
591 FragmentList {
592 fragments: vec![WebUIFragment::raw("<p>client</p>")],
593 contains_boundary: false,
594 },
595 );
596 let mut protocol = WebUIProtocol::new(fragments);
597 protocol.initial_state_strategy = InitialStateStrategy::Components as i32;
598 protocol.components.insert(
599 "client-card".to_string(),
600 ComponentData {
601 hydration_mode: StateProjectionMode::Keys as i32,
602 hydration_keys: vec!["kept".to_string()],
603 ..Default::default()
604 },
605 );
606
607 let rendered = render_protocol_to_string(
608 &protocol,
609 r#"{"kept":"KEPT_VALUE_WASM","dropped":"DROPPED_VALUE_WASM"}"#,
610 "index.html",
611 "/",
612 Some(HandlerPluginKind::WebUI),
613 )
614 .expect("render should succeed");
615
616 assert!(
618 rendered.contains(r#""kept":"KEPT_VALUE_WASM""#),
619 "hydratable key missing from bootstrap state:\n{rendered}"
620 );
621 assert!(
623 !rendered.contains("DROPPED_VALUE_WASM"),
624 "server-only value leaked into render:\n{rendered}"
625 );
626 assert!(
627 !rendered.contains("dropped"),
628 "server-only key name leaked into render:\n{rendered}"
629 );
630 }
631
632 #[cfg(feature = "parser")]
633 mod streaming_tests {
634 use std::collections::HashMap;
635
636 use super::*;
637
638 fn session(html: &str) -> HandlerStreamingSession {
639 let files = HashMap::from([("index.html".to_string(), html.to_string())]);
640 let protocol = crate::parser::parse_to_protocol(&files, "index.html", &[])
641 .expect("protocol should parse");
642 HandlerStreamingSession::new(
643 Arc::new(WebUIHandler::new()),
644 Arc::new(HandlerProtocol::new(protocol)),
645 SessionOptions::new("index.html", "/"),
646 )
647 .expect("session should open")
648 }
649
650 fn state(json: &str) -> Value {
651 parse_state_json(json).expect("state should parse")
652 }
653
654 #[test]
655 fn streaming_steps_preserve_key_types_and_checkpoint_segments() {
656 let mut session = session(concat!(
657 "<html><head></head><body>",
658 r#"<boundary name="first" key="{{firstId}}"><p>{{firstLabel}}</p></boundary>"#,
659 "<span>between</span>",
660 r#"<boundary name="second" key="{{secondId}}"><p>{{secondLabel}}</p></boundary>"#,
661 "<footer>tail</footer>",
662 "</body></html>",
663 ));
664 let state =
665 state(r#"{"firstId":"alpha","firstLabel":"a","secondId":20,"secondLabel":"b"}"#);
666
667 let first = session
668 .start(&state)
669 .expect("start should discover first boundary");
670 assert!(!first.done);
671 assert!(!first.bytes.is_empty());
672 let first_boundary = first.boundary.expect("first boundary should be returned");
673 assert_eq!(first_boundary.instance_id.raw(), 0);
674 assert_eq!(first_boundary.declaration_id, 0);
675 assert_eq!(first_boundary.owner.as_ref(), "index.html");
676 assert_eq!(first_boundary.name.as_ref(), "first");
677 assert_eq!(
678 first_boundary.key,
679 Some(BoundaryKey::String("alpha".to_string()))
680 );
681
682 let resumed = session
683 .resume(first_boundary.instance_id, &state, BoundaryMode::Final)
684 .expect("resume should commit first boundary");
685 assert!(!resumed.done);
686 assert!(resumed.boundary.is_none());
687 let resumed_text =
688 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
689 assert!(resumed_text.contains(">a<"));
690 assert!(!resumed_text.contains("between"));
691
692 let next = session
693 .advance()
694 .expect("advance should discover second boundary");
695 assert!(!next.done);
696 let next_text =
697 std::str::from_utf8(&next.bytes).expect("advance output should be UTF-8");
698 assert!(next_text.contains("between"));
699 assert!(!next_text.contains(">b<"));
700 let second_boundary = next.boundary.expect("second boundary should be returned");
701 assert_eq!(second_boundary.instance_id.raw(), 1);
702 assert_eq!(second_boundary.declaration_id, 1);
703 assert_eq!(second_boundary.name.as_ref(), "second");
704 assert_eq!(second_boundary.key, Some(BoundaryKey::Number(20.into())));
705
706 let resumed = session
707 .resume(second_boundary.instance_id, &state, BoundaryMode::Final)
708 .expect("resume should commit second boundary");
709 assert!(!resumed.done);
710 assert!(resumed.boundary.is_none());
711 let resumed_text =
712 std::str::from_utf8(&resumed.bytes).expect("resume output should be UTF-8");
713 assert!(resumed_text.contains(">b<"));
714 assert!(!resumed_text.contains("tail"));
715
716 let done = session.advance().expect("final advance should complete");
717 assert!(done.done);
718 assert!(done.boundary.is_none());
719 assert!(std::str::from_utf8(&done.bytes)
720 .expect("advance output should be UTF-8")
721 .contains("tail"));
722 }
723
724 #[test]
725 fn streaming_update_returns_bytes_for_updatable_occurrence() {
726 let mut session = session(concat!(
727 "<html><head></head><body>",
728 r#"<boundary name="first"><p>{{count}}</p></boundary>"#,
729 r#"<boundary name="second"><p>done</p></boundary>"#,
730 "</body></html>",
731 ));
732 let initial = state(r#"{"count":1}"#);
733 let first = session
734 .start(&initial)
735 .expect("start should discover first boundary")
736 .boundary
737 .expect("first boundary should be returned");
738 let resumed = session
739 .resume(first.instance_id, &initial, BoundaryMode::Updatable)
740 .expect("resume should commit updatable boundary");
741 assert!(!resumed.done);
742 assert!(resumed.boundary.is_none());
743
744 let update = session
745 .update(first.instance_id, &state(r#"{"count":2}"#))
746 .expect("update should render");
747 assert!(!update.is_empty());
748 assert!(std::str::from_utf8(&update)
749 .expect("update should be UTF-8")
750 .contains(r#""count":2"#));
751
752 let second = session
753 .advance()
754 .expect("advance should discover second boundary")
755 .boundary
756 .expect("second boundary should be returned");
757 let resumed = session
758 .resume(second.instance_id, &state("{}"), BoundaryMode::Final)
759 .expect("second resume should commit boundary");
760 assert!(!resumed.done);
761 assert!(resumed.boundary.is_none());
762 let done = session.advance().expect("final advance should complete");
763 assert!(done.done);
764 }
765
766 #[test]
767 fn streaming_advance_rejects_out_of_order_calls() {
768 let mut session = session(concat!(
769 "<html><head></head><body>",
770 r#"<boundary name="first"><p>first</p></boundary>"#,
771 "</body></html>",
772 ));
773
774 let before_start = session
775 .advance()
776 .expect_err("advance before start should fail");
777 assert!(before_start
778 .to_string()
779 .contains("start must be called before this operation"));
780
781 let start = session.start(&state("{}")).expect("start should succeed");
782 let before_resume = session
783 .advance()
784 .expect_err("advance before resume should fail");
785 assert!(before_resume
786 .to_string()
787 .contains("there is no committed boundary to advance past"));
788
789 let boundary = start.boundary.expect("first boundary should be returned");
790 session
791 .resume(boundary.instance_id, &state("{}"), BoundaryMode::Final)
792 .expect("resume should still succeed after rejected advance");
793 assert!(session.advance().expect("advance should complete").done);
794 }
795
796 #[test]
797 fn streaming_start_returns_done_for_boundary_free_document() {
798 let mut session = session("<html><head></head><body><p>done</p></body></html>");
799
800 let step = session
801 .start(&state("{}"))
802 .expect("boundary-free start should complete");
803 assert!(step.done);
804 assert!(step.boundary.is_none());
805 assert!(std::str::from_utf8(&step.bytes)
806 .expect("output should be UTF-8")
807 .contains("<p>done</p>"));
808 }
809 }
810}