byteor_pipeline_wasm/
lib.rs

1#![deny(missing_docs)]
2#![deny(unreachable_pub, rust_2018_idioms)]
3#![forbid(unsafe_code)]
4
5//! WASM bindings for plan validation, lowering, validate, encode/decode, describe, and diff
6//! flows.
7
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10
11use byteor_pipeline_lower::{
12    lower_v2 as lower_plan, LowerDiagnosticCategoryV2, LowerDiagnosticV2, LowerError,
13    LowerOptionsV1, LowerReportV2,
14};
15use byteor_pipeline_plan::{
16    validate_plan_v2 as validate_plan_contract, ExecutionTargetV1, PipelinePlanV2,
17};
18use byteor_pipeline_spec::{
19    canonical_encode_kv_v1, canonical_spec_sha256_hex_v1, decode_kv_v1, describe_v1, dot_v1,
20    validate_v1, OnFullV1, PipelineSpecV1,
21};
22use serde::{Deserialize, Serialize};
23use wasm_bindgen::prelude::*;
24
25extern crate alloc;
26
27/// Lowering output returned to the browser.
28#[derive(Deserialize, Serialize)]
29pub struct CompileResult {
30    ok: bool,
31    spec_json: Option<String>,
32    report_json: Option<String>,
33    diagnostics: Vec<LowerDiagnosticV2>,
34}
35
36/// Validation output returned to the browser.
37#[derive(Deserialize, Serialize)]
38pub struct ValidateResult {
39    ok: bool,
40    errors: Vec<String>,
41}
42
43/// Plan validation output returned to the browser.
44#[derive(Deserialize, Serialize)]
45pub struct ValidatePlanResult {
46    ok: bool,
47    diagnostics: Vec<String>,
48}
49
50/// Diff output returned to the browser.
51#[derive(Deserialize, Serialize)]
52pub struct DiffResult {
53    equal: bool,
54    spec_hash_a: String,
55    spec_hash_b: String,
56    canonical_kv_a: String,
57    canonical_kv_b: String,
58}
59
60/// Validate a plan JSON blob and return structured authoring diagnostics.
61#[wasm_bindgen]
62pub fn validate_plan(plan_json: &str) -> Result<JsValue, JsValue> {
63    to_js_value(&validate_plan_impl(plan_json)?)
64}
65
66/// Lower a plan JSON blob to the requested target and return the lowered spec and report.
67#[wasm_bindgen]
68pub fn lower(plan_json: &str, target: &str) -> Result<JsValue, JsValue> {
69    to_js_value(&lower_impl(plan_json, parse_target(target)?)?)
70}
71
72/// Lower a plan JSON blob to `SingleRing`.
73#[wasm_bindgen]
74pub fn lower_single_ring(plan_json: &str) -> Result<JsValue, JsValue> {
75    to_js_value(&lower_impl(plan_json, ExecutionTargetV1::SingleRing)?)
76}
77
78/// Lower a plan JSON blob to `LaneGraph`.
79#[wasm_bindgen]
80pub fn lower_lane_graph(plan_json: &str) -> Result<JsValue, JsValue> {
81    to_js_value(&lower_impl(plan_json, ExecutionTargetV1::LaneGraph)?)
82}
83
84/// Validate a spec JSON blob and return a structured result.
85#[wasm_bindgen]
86pub fn validate(spec_json: &str) -> Result<JsValue, JsValue> {
87    to_js_value(&validate_impl(spec_json)?)
88}
89
90/// Encode a spec JSON blob into canonical `spec.kv` bytes.
91#[wasm_bindgen]
92pub fn encode_kv(spec_json: &str) -> Result<Vec<u8>, JsValue> {
93    let spec: PipelineSpecV1 = parse_json(spec_json)?;
94    let encoded = canonical_encode_kv_v1(&spec).map_err(to_js_error)?;
95    Ok(encoded.into_bytes())
96}
97
98/// Decode canonical `spec.kv` bytes into spec JSON.
99#[wasm_bindgen]
100pub fn decode_kv(bytes: &[u8]) -> Result<String, JsValue> {
101    let input = core::str::from_utf8(bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
102    let spec = decode_kv_v1(input).map_err(to_js_error)?;
103    to_json_string(&spec)
104}
105
106/// Describe a spec JSON blob.
107#[wasm_bindgen]
108pub fn describe(spec_json: &str) -> Result<String, JsValue> {
109    let spec: PipelineSpecV1 = parse_json(spec_json)?;
110    Ok(describe_v1(&spec))
111}
112
113/// Render a spec JSON blob as DOT.
114#[wasm_bindgen]
115pub fn dot(spec_json: &str) -> Result<String, JsValue> {
116    let spec: PipelineSpecV1 = parse_json(spec_json)?;
117    dot_v1(&spec)
118        .map(|dot| dot.to_string())
119        .map_err(to_js_error)
120}
121
122/// Diff two spec JSON blobs by comparing their canonical bytes and hashes.
123#[wasm_bindgen]
124pub fn diff(spec_a_json: &str, spec_b_json: &str) -> Result<JsValue, JsValue> {
125    to_js_value(&diff_impl(spec_a_json, spec_b_json)?)
126}
127
128fn validate_plan_impl(plan_json: &str) -> Result<ValidatePlanResult, JsValue> {
129    let plan: PipelinePlanV2 = parse_json(plan_json)?;
130    let diagnostics = validate_plan_contract(&plan)
131        .into_iter()
132        .map(|diagnostic| diagnostic.message)
133        .collect::<Vec<_>>();
134    Ok(ValidatePlanResult {
135        ok: diagnostics.is_empty(),
136        diagnostics,
137    })
138}
139
140fn lower_impl(plan_json: &str, target: ExecutionTargetV1) -> Result<CompileResult, JsValue> {
141    let plan: PipelinePlanV2 = parse_json(plan_json)?;
142    match lower_plan(&plan, target, &default_lower_options()) {
143        Ok(report) => compile_result_from_lower_report(report),
144        Err(err) => Ok(compile_result_from_lower_error(target, err)),
145    }
146}
147
148fn validate_impl(spec_json: &str) -> Result<ValidateResult, JsValue> {
149    let spec: PipelineSpecV1 = parse_json(spec_json)?;
150    Ok(match validate_v1(&spec) {
151        Ok(()) => ValidateResult {
152            ok: true,
153            errors: Vec::new(),
154        },
155        Err(err) => ValidateResult {
156            ok: false,
157            errors: Vec::from([err.to_string()]),
158        },
159    })
160}
161
162fn diff_impl(spec_a_json: &str, spec_b_json: &str) -> Result<DiffResult, JsValue> {
163    let spec_a: PipelineSpecV1 = parse_json(spec_a_json)?;
164    let spec_b: PipelineSpecV1 = parse_json(spec_b_json)?;
165
166    let canonical_kv_a = canonical_encode_kv_v1(&spec_a).map_err(to_js_error)?;
167    let canonical_kv_b = canonical_encode_kv_v1(&spec_b).map_err(to_js_error)?;
168    Ok(DiffResult {
169        equal: canonical_kv_a == canonical_kv_b,
170        spec_hash_a: canonical_spec_sha256_hex_v1(&spec_a).map_err(to_js_error)?,
171        spec_hash_b: canonical_spec_sha256_hex_v1(&spec_b).map_err(to_js_error)?,
172        canonical_kv_a,
173        canonical_kv_b,
174    })
175}
176
177fn compile_result_from_lower_report(report: LowerReportV2) -> Result<CompileResult, JsValue> {
178    let diagnostics = report.lower_diagnostics.clone();
179    Ok(CompileResult {
180        ok: true,
181        spec_json: Some(to_json_string(&report.spec)?),
182        report_json: Some(to_json_string(&report)?),
183        diagnostics,
184    })
185}
186
187fn compile_result_from_lower_error(
188    target: ExecutionTargetV1,
189    err: LowerError,
190) -> CompileResult {
191    let diagnostics = match err {
192        LowerError::DiagnosticsV2(diagnostics) => diagnostics,
193        LowerError::Invalid(message) => Vec::from([LowerDiagnosticV2::Rejected {
194            target,
195            category: LowerDiagnosticCategoryV2::InternalInvariant,
196            message: message.to_string(),
197        }]),
198        LowerError::Unsupported(message) => Vec::from([LowerDiagnosticV2::Rejected {
199            target,
200            category: LowerDiagnosticCategoryV2::UnsupportedIntent,
201            message: message.to_string(),
202        }]),
203        _ => Vec::from([LowerDiagnosticV2::Rejected {
204            target,
205            category: LowerDiagnosticCategoryV2::InternalInvariant,
206            message: "unclassified lowering failure".to_string(),
207        }]),
208    };
209
210    CompileResult {
211        ok: false,
212        spec_json: None,
213        report_json: None,
214        diagnostics,
215    }
216}
217
218fn default_lower_options() -> LowerOptionsV1 {
219    LowerOptionsV1 {
220        backing_prefix: "wasm".to_string(),
221        on_full: OnFullV1::Block,
222    }
223}
224
225fn parse_target(target: &str) -> Result<ExecutionTargetV1, JsValue> {
226    match target.trim().to_ascii_lowercase().as_str() {
227        "single_ring" | "singlering" => Ok(ExecutionTargetV1::SingleRing),
228        "lane_graph" | "lanegraph" => Ok(ExecutionTargetV1::LaneGraph),
229        _ => Err(JsValue::from_str(
230            "target must be one of: single_ring, lane_graph",
231        )),
232    }
233}
234
235fn parse_json<T>(json: &str) -> Result<T, JsValue>
236where
237    T: serde::de::DeserializeOwned,
238{
239    serde_json::from_str(json).map_err(|e| JsValue::from_str(&e.to_string()))
240}
241
242fn to_json_string<T>(value: &T) -> Result<String, JsValue>
243where
244    T: Serialize,
245{
246    serde_json::to_string(value).map_err(|e| JsValue::from_str(&e.to_string()))
247}
248
249fn to_js_value<T>(value: &T) -> Result<JsValue, JsValue>
250where
251    T: Serialize,
252{
253    serde_wasm_bindgen::to_value(value).map_err(|e| JsValue::from_str(&e.to_string()))
254}
255
256fn to_js_error<E>(err: E) -> JsValue
257where
258    E: core::fmt::Display,
259{
260    JsValue::from_str(&err.to_string())
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    use byteor_pipeline_plan::{
268        LaneGraphAuthoringV2, PipelinePlanSourceV2, PipelinePlanV2, PlanBoundary,
269        PlanBoundaryKind, PlanConnection, PlanConnectionDestination, PlanConnectionSource,
270        PlanStage, SingleRingAuthoringV2, SingleRingConstraintSetV2,
271        SingleRingStagePlanV2,
272    };
273    use byteor_pipeline_spec::{
274        EndpointCfgV1, EndpointKindV1, LaneCfgV1, LaneGraphV1, LaneKindV1, RoleCfgV1,
275        StageRoleCfgV1,
276    };
277
278    #[test]
279    fn encode_decode_round_trip_matches_canonical_spec() {
280        let spec = PipelineSpecV1::LaneGraph(LaneGraphV1 {
281            on_full: OnFullV1::Block,
282            endpoints: alloc::vec::Vec::from([
283                EndpointCfgV1 {
284                    name: "in".into(),
285                    kind: EndpointKindV1::Ingress,
286                    lane: "a".into(),
287                },
288                EndpointCfgV1 {
289                    name: "out".into(),
290                    kind: EndpointKindV1::Egress,
291                    lane: "b".into(),
292                },
293            ]),
294            lanes: alloc::vec::Vec::from([
295                LaneCfgV1 {
296                    name: "a".into(),
297                    kind: LaneKindV1::Events,
298                },
299                LaneCfgV1 {
300                    name: "b".into(),
301                    kind: LaneKindV1::Events,
302                },
303            ]),
304            roles: alloc::vec::Vec::from([RoleCfgV1::Stage(StageRoleCfgV1 {
305                name: "s0".into(),
306                stage: "identity".into(),
307                rx: "a".into(),
308                tx: "b".into(),
309            })]),
310        });
311
312        let spec_json = serde_json::to_string(&spec).unwrap();
313        let encoded = encode_kv(&spec_json).unwrap();
314        let decoded = decode_kv(&encoded).unwrap();
315
316        assert_eq!(decoded, spec_json);
317    }
318
319    #[test]
320    fn validate_plan_returns_ok_for_valid_plan() {
321        let plan = PipelinePlanV2 {
322            name: "wasm-plan-v2".into(),
323            target: ExecutionTargetV1::SingleRing,
324            stages: alloc::vec![
325                PlanStage {
326                    name: "s0".into(),
327                    stage: "identity".into(),
328                },
329                PlanStage {
330                    name: "s1".into(),
331                    stage: "add_u8".into(),
332                },
333            ],
334            boundaries: alloc::vec![
335                PlanBoundary {
336                    name: "in".into(),
337                    kind: PlanBoundaryKind::Ingress,
338                },
339                PlanBoundary {
340                    name: "out".into(),
341                    kind: PlanBoundaryKind::Egress,
342                },
343            ],
344            source: PipelinePlanSourceV2::SingleRing(SingleRingAuthoringV2 {
345                ingress_boundary: "in".into(),
346                egress_boundary: "out".into(),
347                stages: alloc::vec![
348                    SingleRingStagePlanV2 {
349                        stage: "s0".into(),
350                        depends_on: alloc::vec![],
351                    },
352                    SingleRingStagePlanV2 {
353                        stage: "s1".into(),
354                        depends_on: alloc::vec!["s0".into()],
355                    },
356                ],
357                constraints: SingleRingConstraintSetV2::default(),
358            }),
359        };
360
361        let result = validate_plan_impl(&serde_json::to_string(&plan).unwrap()).unwrap();
362        assert!(result.ok);
363        assert!(result.diagnostics.is_empty());
364    }
365
366    #[test]
367    fn lower_returns_spec_and_report_json() {
368        let plan = PipelinePlanV2 {
369            name: "wasm-smoke-v2".into(),
370            target: ExecutionTargetV1::LaneGraph,
371            stages: alloc::vec![PlanStage {
372                name: "s0".into(),
373                stage: "identity".into(),
374            }],
375            boundaries: alloc::vec![
376                PlanBoundary {
377                    name: "in".into(),
378                    kind: PlanBoundaryKind::Ingress,
379                },
380                PlanBoundary {
381                    name: "out".into(),
382                    kind: PlanBoundaryKind::Egress,
383                },
384            ],
385            source: PipelinePlanSourceV2::LaneGraph(LaneGraphAuthoringV2 {
386                boundary_lane: byteor_pipeline_plan::LaneIntent::Events,
387                connections: alloc::vec![
388                    PlanConnection {
389                        from: PlanConnectionSource::BoundaryIngress {
390                            boundary: "in".into(),
391                        },
392                        to: PlanConnectionDestination::StageIn { stage: "s0".into() },
393                        intent: byteor_pipeline_plan::LaneIntent::Events,
394                    },
395                    PlanConnection {
396                        from: PlanConnectionSource::StageOut { stage: "s0".into() },
397                        to: PlanConnectionDestination::BoundaryEgress {
398                            boundary: "out".into(),
399                        },
400                        intent: byteor_pipeline_plan::LaneIntent::Events,
401                    },
402                ],
403                on_full: OnFullV1::Drop,
404            }),
405        };
406
407        let result = lower_impl(&serde_json::to_string(&plan).unwrap(), ExecutionTargetV1::LaneGraph)
408            .unwrap();
409        assert!(result.ok);
410        let spec: PipelineSpecV1 = serde_json::from_str(result.spec_json.as_ref().unwrap()).unwrap();
411        match spec {
412            PipelineSpecV1::LaneGraph(lg) => assert_eq!(lg.on_full, OnFullV1::Drop),
413            PipelineSpecV1::SingleRing(_) => panic!("expected lane_graph spec"),
414        }
415        assert!(result.report_json.as_ref().unwrap().contains("diagnostics"));
416        assert!(result
417            .diagnostics
418            .iter()
419            .all(|diagnostic| !matches!(diagnostic, LowerDiagnosticV2::Rejected { .. })));
420    }
421
422    #[test]
423    fn lower_returns_structured_diagnostics_on_rejected_input() {
424        let plan = PipelinePlanV2 {
425            name: "wasm-bad-v2".into(),
426            target: ExecutionTargetV1::SingleRing,
427            stages: alloc::vec![
428                PlanStage {
429                    name: "s0".into(),
430                    stage: "identity".into(),
431                },
432                PlanStage {
433                    name: "s1".into(),
434                    stage: "add_u8".into(),
435                },
436            ],
437            boundaries: alloc::vec![
438                PlanBoundary {
439                    name: "in".into(),
440                    kind: PlanBoundaryKind::Ingress,
441                },
442                PlanBoundary {
443                    name: "out".into(),
444                    kind: PlanBoundaryKind::Egress,
445                },
446            ],
447            source: PipelinePlanSourceV2::SingleRing(SingleRingAuthoringV2 {
448                ingress_boundary: "in".into(),
449                egress_boundary: "out".into(),
450                stages: alloc::vec![
451                    SingleRingStagePlanV2 {
452                        stage: "s0".into(),
453                        depends_on: alloc::vec!["s1".into()],
454                    },
455                    SingleRingStagePlanV2 {
456                        stage: "s1".into(),
457                        depends_on: alloc::vec![],
458                    },
459                ],
460                constraints: SingleRingConstraintSetV2::default(),
461            }),
462        };
463
464        let result = lower_impl(
465            &serde_json::to_string(&plan).unwrap(),
466            ExecutionTargetV1::SingleRing,
467        )
468        .unwrap();
469
470        assert!(!result.ok);
471        assert!(result.spec_json.is_none());
472        assert!(result.report_json.is_none());
473        assert_eq!(
474            result.diagnostics,
475            alloc::vec![LowerDiagnosticV2::Rejected {
476                target: ExecutionTargetV1::SingleRing,
477                category: LowerDiagnosticCategoryV2::PlanValidation,
478                message: "single_ring dependencies must reference earlier authored stages".into(),
479            }]
480        );
481    }
482
483    #[test]
484    fn lower_json_shape_is_locked_for_success_and_failure() {
485        let ok_json = serde_json::to_string(&CompileResult {
486            ok: true,
487            spec_json: Some("{\"kind\":\"lane_graph\"}".into()),
488            report_json: Some("{\"lower_diagnostics\":[]}".into()),
489            diagnostics: alloc::vec![],
490        })
491        .unwrap();
492        let err_json = serde_json::to_string(&CompileResult {
493            ok: false,
494            spec_json: None,
495            report_json: None,
496            diagnostics: alloc::vec![LowerDiagnosticV2::Rejected {
497                target: ExecutionTargetV1::LaneGraph,
498                category: LowerDiagnosticCategoryV2::PolicyConflict,
499                message: "out-degree > 1 requires fanout intent on all outgoing edges".into(),
500            }],
501        })
502        .unwrap();
503
504        assert!(ok_json.contains("\"ok\""));
505        assert!(ok_json.contains("\"spec_json\""));
506        assert!(ok_json.contains("\"report_json\""));
507        assert!(ok_json.contains("\"diagnostics\""));
508        assert!(err_json.contains("\"category\":\"policy_conflict\""));
509        assert!(err_json.contains("\"target\":\"lane_graph\""));
510        assert!(err_json.contains("\"message\""));
511    }
512
513    #[test]
514    fn lower_single_ring_returns_single_ring_spec() {
515        let plan = PipelinePlanV2 {
516            name: "ring-plan".into(),
517            target: ExecutionTargetV1::SingleRing,
518            stages: alloc::vec![
519                PlanStage {
520                    name: "s0".into(),
521                    stage: "identity".into(),
522                },
523                PlanStage {
524                    name: "s1".into(),
525                    stage: "add_u8".into(),
526                },
527            ],
528            boundaries: alloc::vec![
529                PlanBoundary {
530                    name: "in".into(),
531                    kind: PlanBoundaryKind::Ingress,
532                },
533                PlanBoundary {
534                    name: "out".into(),
535                    kind: PlanBoundaryKind::Egress,
536                },
537            ],
538            source: PipelinePlanSourceV2::SingleRing(SingleRingAuthoringV2 {
539                ingress_boundary: "in".into(),
540                egress_boundary: "out".into(),
541                stages: alloc::vec![
542                    SingleRingStagePlanV2 {
543                        stage: "s0".into(),
544                        depends_on: alloc::vec![],
545                    },
546                    SingleRingStagePlanV2 {
547                        stage: "s1".into(),
548                        depends_on: alloc::vec!["s0".into()],
549                    },
550                ],
551                constraints: SingleRingConstraintSetV2::default(),
552            }),
553        };
554
555        let result = lower_impl(&serde_json::to_string(&plan).unwrap(), ExecutionTargetV1::SingleRing)
556            .unwrap();
557        let spec: PipelineSpecV1 = serde_json::from_str(result.spec_json.as_ref().unwrap()).unwrap();
558        assert!(result.ok);
559        assert!(matches!(spec, PipelineSpecV1::SingleRing(_)));
560    }
561
562    #[test]
563    fn dot_returns_graphviz_for_valid_spec() {
564        let spec = PipelineSpecV1::LaneGraph(LaneGraphV1 {
565            on_full: OnFullV1::Block,
566            endpoints: alloc::vec::Vec::from([
567                EndpointCfgV1 {
568                    name: "in".into(),
569                    kind: EndpointKindV1::Ingress,
570                    lane: "a".into(),
571                },
572                EndpointCfgV1 {
573                    name: "out".into(),
574                    kind: EndpointKindV1::Egress,
575                    lane: "b".into(),
576                },
577            ]),
578            lanes: alloc::vec::Vec::from([
579                LaneCfgV1 {
580                    name: "a".into(),
581                    kind: LaneKindV1::Events,
582                },
583                LaneCfgV1 {
584                    name: "b".into(),
585                    kind: LaneKindV1::Events,
586                },
587            ]),
588            roles: alloc::vec::Vec::from([RoleCfgV1::Stage(StageRoleCfgV1 {
589                name: "s0".into(),
590                stage: "identity".into(),
591                rx: "a".into(),
592                tx: "b".into(),
593            })]),
594        });
595
596        let dot_text = dot(&serde_json::to_string(&spec).unwrap()).unwrap();
597        assert!(dot_text.contains("digraph"));
598        assert!(dot_text.contains("role_s0"));
599        assert!(dot_text.contains("endpoint_in"));
600        assert!(dot_text.contains("endpoint_out"));
601    }
602}
603
604#[cfg(all(test, target_arch = "wasm32"))]
605mod wasm_tests {
606    use super::*;
607
608    use byteor_pipeline_plan::{
609        LaneGraphAuthoringV2, PipelinePlanSourceV2, PipelinePlanV2, PlanBoundary,
610        PlanBoundaryKind, PlanConnection, PlanConnectionDestination, PlanConnectionSource,
611        PlanStage,
612    };
613    use byteor_pipeline_spec::{
614        EndpointCfgV1, EndpointKindV1, LaneCfgV1, LaneGraphV1, LaneKindV1, RoleCfgV1,
615        StageRoleCfgV1,
616    };
617    use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
618
619    wasm_bindgen_test_configure!(run_in_browser);
620
621    fn sample_plan() -> PipelinePlanV2 {
622        PipelinePlanV2 {
623            name: "browser-smoke".into(),
624            target: ExecutionTargetV1::LaneGraph,
625            stages: alloc::vec![PlanStage {
626                name: "s0".into(),
627                stage: "identity".into(),
628            }],
629            boundaries: alloc::vec![
630                PlanBoundary {
631                    name: "in".into(),
632                    kind: PlanBoundaryKind::Ingress,
633                },
634                PlanBoundary {
635                    name: "out".into(),
636                    kind: PlanBoundaryKind::Egress,
637                },
638            ],
639            source: PipelinePlanSourceV2::LaneGraph(LaneGraphAuthoringV2 {
640                boundary_lane: byteor_pipeline_plan::LaneIntent::Events,
641                connections: alloc::vec![
642                    PlanConnection {
643                        from: PlanConnectionSource::BoundaryIngress {
644                            boundary: "in".into(),
645                        },
646                        to: PlanConnectionDestination::StageIn { stage: "s0".into() },
647                        intent: byteor_pipeline_plan::LaneIntent::Events,
648                    },
649                    PlanConnection {
650                        from: PlanConnectionSource::StageOut { stage: "s0".into() },
651                        to: PlanConnectionDestination::BoundaryEgress {
652                            boundary: "out".into(),
653                        },
654                        intent: byteor_pipeline_plan::LaneIntent::Events,
655                    },
656                ],
657                on_full: OnFullV1::Block,
658            }),
659        }
660    }
661
662    fn sample_spec_json() -> String {
663        let spec = PipelineSpecV1::LaneGraph(LaneGraphV1 {
664            on_full: OnFullV1::Block,
665            endpoints: alloc::vec::Vec::from([
666                EndpointCfgV1 {
667                    name: "in".into(),
668                    kind: EndpointKindV1::Ingress,
669                    lane: "a".into(),
670                },
671                EndpointCfgV1 {
672                    name: "out".into(),
673                    kind: EndpointKindV1::Egress,
674                    lane: "b".into(),
675                },
676            ]),
677            lanes: alloc::vec::Vec::from([
678                LaneCfgV1 {
679                    name: "a".into(),
680                    kind: LaneKindV1::Events,
681                },
682                LaneCfgV1 {
683                    name: "b".into(),
684                    kind: LaneKindV1::Events,
685                },
686            ]),
687            roles: alloc::vec::Vec::from([RoleCfgV1::Stage(StageRoleCfgV1 {
688                name: "s0".into(),
689                stage: "identity".into(),
690                rx: "a".into(),
691                tx: "b".into(),
692            })]),
693        });
694
695        serde_json::to_string(&spec).unwrap()
696    }
697
698    #[wasm_bindgen_test]
699    fn lower_preview_flow_runs_in_browser() {
700        let plan_json = serde_json::to_string(&sample_plan()).unwrap();
701        let compile_result: CompileResult =
702            serde_wasm_bindgen::from_value(lower_lane_graph(&plan_json).unwrap()).unwrap();
703
704        let spec: PipelineSpecV1 = serde_json::from_str(compile_result.spec_json.as_ref().unwrap()).unwrap();
705        let report: LowerReportV2 = serde_json::from_str(compile_result.report_json.as_ref().unwrap()).unwrap();
706        let validate_result: ValidateResult =
707            serde_wasm_bindgen::from_value(validate(compile_result.spec_json.as_ref().unwrap()).unwrap()).unwrap();
708
709        assert!(matches!(spec, PipelineSpecV1::LaneGraph(_)));
710        assert_eq!(report.spec, spec);
711        assert!(compile_result.ok);
712        assert!(validate_result.ok);
713        assert!(validate_result.errors.is_empty());
714        assert!(!describe(compile_result.spec_json.as_ref().unwrap()).unwrap().is_empty());
715    }
716
717    #[wasm_bindgen_test]
718    fn import_export_flow_preserves_canonical_identity() {
719        let spec_json = sample_spec_json();
720
721        let encoded = encode_kv(&spec_json).unwrap();
722        let decoded_json = decode_kv(&encoded).unwrap();
723        let reencoded = encode_kv(&decoded_json).unwrap();
724
725        let validate_result: ValidateResult =
726            serde_wasm_bindgen::from_value(validate(&decoded_json).unwrap()).unwrap();
727        let diff_result: DiffResult =
728            serde_wasm_bindgen::from_value(diff(&spec_json, &decoded_json).unwrap()).unwrap();
729
730        assert!(validate_result.ok);
731        assert!(validate_result.errors.is_empty());
732        assert_eq!(encoded, reencoded);
733        assert!(diff_result.equal);
734        assert_eq!(diff_result.spec_hash_a, diff_result.spec_hash_b);
735        assert_eq!(diff_result.canonical_kv_a, diff_result.canonical_kv_b);
736    }
737}