1use std::collections::BTreeSet;
7
8use serde::Serialize;
9
10use crate::{
11 semantic_type_text, ApplicationSemanticModel, FormControlCompatibility, FormIrOperation,
12 ValidationRuleArgument, RUNTIME_FORM_REGISTRY_VERSION,
13};
14
15pub const RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION: u32 = 6;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct RuntimeFormsArtifact {
20 pub schema_version: u32,
21 pub registry_version: u32,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub standard_schema_module: Option<RuntimeFormsArtifactStandardSchemaModule>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub submission_capability_module: Option<RuntimeFormsArtifactSubmissionCapabilityModule>,
26 pub forms: Vec<RuntimeFormsArtifactForm>,
27 pub instances: Vec<RuntimeFormsArtifactInstance>,
28 pub hosts: Vec<RuntimeFormsArtifactHost>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct RuntimeFormsArtifactSubmissionCapabilityModule {
35 pub path: String,
36 pub capabilities: Vec<RuntimeFormsArtifactSubmissionCapability>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
40pub struct RuntimeFormsArtifactSubmissionCapability {
41 pub id: String,
42 pub module_specifier: String,
43 pub package: String,
44 pub version: String,
45 pub integrity: String,
46 pub export: String,
47 pub runtime_module: String,
48 pub resume_policy: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct RuntimeFormsArtifactStandardSchemaModule {
53 pub path: String,
54 pub validators: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58pub struct RuntimeFormsArtifactForm {
59 pub id: String,
60 pub debug_name: String,
62 pub fields: Vec<RuntimeFormsArtifactField>,
63 pub bindings: Vec<RuntimeFormsArtifactBinding>,
64 pub validation_rules: Vec<RuntimeFormsArtifactRule>,
65 pub validation_dependencies: Vec<RuntimeFormsArtifactDependency>,
66 pub submission: Option<RuntimeFormsArtifactSubmission>,
67 pub serialization: RuntimeFormsArtifactSerialization,
68 pub reset: RuntimeFormsArtifactReset,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct RuntimeFormsArtifactField {
73 pub id: String,
74 pub debug_name: String,
76 pub path: Vec<String>,
78 pub semantic_type: String,
79 pub initial_value: crate::SerializableValue,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct RuntimeFormsArtifactBinding {
84 pub id: String,
85 pub control_anchor: String,
86 pub field: String,
87 pub channel: String,
88 pub normalization: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct RuntimeFormsArtifactRule {
93 pub id: String,
94 pub target_field: String,
95 pub kind: String,
96 pub argument: RuntimeFormsArtifactRuleArgument,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub dependency: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102#[serde(tag = "kind", rename_all = "snake_case")]
103pub enum RuntimeFormsArtifactRuleArgument {
104 None,
105 Number { value: String },
106 Length { value: u64 },
107 Pattern { value: String },
108 Field { field: String },
109 StandardSchema { validator: String },
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct RuntimeFormsArtifactDependency {
114 pub id: String,
115 pub source_field: String,
116 pub target_field: String,
117 pub rule: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121pub struct RuntimeFormsArtifactSubmission {
122 pub plan: String,
123 pub action_batch: String,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub capability: Option<String>,
126 pub validation_rules: Vec<String>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct RuntimeFormsArtifactSerialization {
131 pub plan: String,
132 pub format: String,
133 pub fields: Vec<String>,
134 pub status: String,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138pub struct RuntimeFormsArtifactReset {
139 pub plan: String,
140 pub fields: Vec<String>,
141 pub schedule_validation: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct RuntimeFormsArtifactInstance {
146 pub id: String,
147 pub form: String,
148 pub component_instance: String,
149 pub field_slots: Vec<RuntimeFormsArtifactFieldSlots>,
150 pub aggregate_validation_slot: String,
151 pub submission_slot: String,
152 pub programs: RuntimeFormsArtifactPrograms,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
156pub struct RuntimeFormsArtifactHost {
157 pub id: String,
158 pub host_anchor: String,
159 pub form: String,
160 pub form_instance: String,
161 pub submission_plan: String,
162 pub submit_action: String,
163 pub action_batch: String,
164 pub serialization_plan: String,
165 pub event: String,
166 pub prevent_default: bool,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct RuntimeFormsArtifactFieldSlots {
171 pub field: String,
172 pub value: String,
173 pub dirty: String,
174 pub touched: String,
175 pub validation: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct RuntimeFormsArtifactPrograms {
180 pub initialize: Vec<String>,
181 pub input: Vec<RuntimeFormsArtifactFieldProgram>,
182 pub blur: Vec<RuntimeFormsArtifactFieldProgram>,
183 pub reset: Vec<String>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct RuntimeFormsArtifactFieldProgram {
188 pub field: String,
189 pub operations: Vec<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct RuntimeFormsArtifactValidation {
194 pub diagnostics: Vec<String>,
195 pub is_valid: bool,
196}
197
198#[must_use]
207#[allow(clippy::too_many_lines)]
208pub fn build_runtime_forms_artifact(model: &ApplicationSemanticModel) -> RuntimeFormsArtifact {
209 let forms = model
210 .forms
211 .values()
212 .map(|form| {
213 let fields = model
214 .form_fields
215 .values()
216 .filter(|field| field.owner_form == form.id)
217 .map(|field| RuntimeFormsArtifactField {
218 id: field.id.to_string(),
219 debug_name: field.name.clone(),
220 path: field.path.clone(),
221 semantic_type: semantic_type_text(&field.semantic_type),
222 initial_value: field.initial_value.clone(),
223 })
224 .collect::<Vec<_>>();
225 let bindings = model
226 .form_field_bindings
227 .values()
228 .filter(|binding| binding.form == form.id)
229 .map(|binding| RuntimeFormsArtifactBinding {
230 id: binding.id.to_string(),
231 control_anchor: binding.control_entity.to_string(),
232 field: binding.field.to_string(),
233 channel: format!("{:?}", binding.channel),
234 normalization: normalization_name(binding.compatibility),
235 })
236 .collect();
237 let validation_rules = model
238 .validation_rules
239 .values()
240 .filter(|rule| rule.owner_form == form.id)
241 .map(|rule| RuntimeFormsArtifactRule {
242 id: rule.id.to_string(),
243 target_field: rule.target_field.to_string(),
244 kind: format!("{:?}", rule.kind),
245 argument: runtime_rule_argument(&rule.argument),
246 dependency: rule.dependency.as_ref().map(ToString::to_string),
247 })
248 .collect();
249 let validation_dependencies = model
250 .validation_dependency_plans
251 .dependencies
252 .values()
253 .filter(|dependency| dependency.form == form.id)
254 .map(|dependency| RuntimeFormsArtifactDependency {
255 id: dependency.id.to_string(),
256 source_field: dependency.source_field.to_string(),
257 target_field: dependency.target_field.to_string(),
258 rule: dependency.dependent_rule.to_string(),
259 })
260 .collect();
261 let submission =
262 model
263 .submissions
264 .plan(&form.id)
265 .map(|plan| RuntimeFormsArtifactSubmission {
266 plan: plan.id.as_str().to_string(),
267 action_batch: plan.action_batch.to_string(),
268 capability: plan
269 .capability
270 .as_ref()
271 .map(|capability| capability.id.clone()),
272 validation_rules: plan
273 .validation_rules
274 .iter()
275 .map(ToString::to_string)
276 .collect(),
277 });
278 let serialization = model
279 .serialization
280 .plans
281 .get(&crate::SerializationPlanId::for_form(&form.id))
282 .expect("I10 creates one serialization plan per valid Form");
283 let reset = model
284 .reset
285 .plans
286 .get(&crate::ResetPlanId::for_form(&form.id))
287 .expect("I11 creates one reset plan per valid Form");
288 RuntimeFormsArtifactForm {
289 id: form.id.to_string(),
290 debug_name: form.name.clone(),
291 fields,
292 bindings,
293 validation_rules,
294 validation_dependencies,
295 submission,
296 serialization: RuntimeFormsArtifactSerialization {
297 plan: serialization.id.as_str().to_string(),
298 format: format!("{:?}", serialization.format),
299 fields: serialization
300 .fields
301 .iter()
302 .map(|field| field.field.to_string())
303 .collect(),
304 status: format!("{:?}", serialization.status),
305 },
306 reset: RuntimeFormsArtifactReset {
307 plan: reset.id.as_str().to_string(),
308 fields: reset
309 .operations
310 .iter()
311 .map(|operation| operation.field.to_string())
312 .collect(),
313 schedule_validation: reset.schedule_validation,
314 },
315 }
316 })
317 .collect::<Vec<_>>();
318 let standard_schema_validators = forms
319 .iter()
320 .flat_map(|form| &form.validation_rules)
321 .filter_map(|rule| match &rule.argument {
322 RuntimeFormsArtifactRuleArgument::StandardSchema { validator } => {
323 Some(validator.clone())
324 }
325 _ => None,
326 })
327 .collect::<BTreeSet<_>>()
328 .into_iter()
329 .collect::<Vec<_>>();
330 let submission_capabilities = model
331 .submissions
332 .plans
333 .values()
334 .filter_map(|plan| plan.capability.as_ref())
335 .map(|capability| RuntimeFormsArtifactSubmissionCapability {
336 id: capability.id.clone(),
337 module_specifier: capability.module_specifier.clone(),
338 package: capability.package.clone(),
339 version: capability.version.clone(),
340 integrity: capability.integrity.clone(),
341 export: capability.export.clone(),
342 runtime_module: capability.runtime_module.clone(),
343 resume_policy: capability.resume_policy.clone(),
344 })
345 .collect::<BTreeSet<_>>()
346 .into_iter()
347 .collect::<Vec<_>>();
348 let instances = model
349 .optimized_form_ir
350 .optimized
351 .instances
352 .values()
353 .map(|instance| RuntimeFormsArtifactInstance {
354 id: instance.id.to_string(),
355 form: instance.form.to_string(),
356 component_instance: instance.component_instance.to_string(),
357 field_slots: instance
358 .storage
359 .value
360 .iter()
361 .map(|(field, value)| RuntimeFormsArtifactFieldSlots {
362 field: field.to_string(),
363 value: value.as_str().to_string(),
364 dirty: instance.storage.dirty[field].as_str().to_string(),
365 touched: instance.storage.touched[field].as_str().to_string(),
366 validation: instance.storage.validation[field].as_str().to_string(),
367 })
368 .collect(),
369 aggregate_validation_slot: instance.storage.aggregate.as_str().to_string(),
370 submission_slot: instance.storage.submission.as_str().to_string(),
371 programs: RuntimeFormsArtifactPrograms {
372 initialize: operations(&instance.initialize),
373 input: field_programs(&instance.input),
374 blur: field_programs(&instance.blur),
375 reset: operations(&instance.reset),
376 },
377 })
378 .collect();
379 let hosts = model
380 .submission_hosts
381 .values()
382 .flat_map(|host| {
383 model
384 .optimized_form_ir
385 .optimized
386 .instances
387 .values()
388 .filter(move |instance| {
389 instance.form == host.form
390 && model
391 .component_instance_plan
392 .instances
393 .get(&instance.component_instance)
394 .is_some_and(|component| component.component == host.component)
395 })
396 .map(move |instance| RuntimeFormsArtifactHost {
397 id: host.id.to_string(),
398 host_anchor: runtime_anchor_for_element(
399 model,
400 &host.owner_template,
401 &host.owner_template_element,
402 )
403 .expect("valid host has exact template anchor"),
404 form: host.form.to_string(),
405 form_instance: instance.id.to_string(),
406 submission_plan: host.submission_plan.as_str().to_string(),
407 submit_action: host.submit_action.to_string(),
408 action_batch: host.action_batch.to_string(),
409 serialization_plan: host.serialization_plan.as_str().to_string(),
410 event: host.event.to_string(),
411 prevent_default: host.prevent_default,
412 })
413 })
414 .collect();
415 let artifact = RuntimeFormsArtifact {
416 schema_version: RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION,
417 registry_version: RUNTIME_FORM_REGISTRY_VERSION,
418 standard_schema_module: (!standard_schema_validators.is_empty()).then_some(
419 RuntimeFormsArtifactStandardSchemaModule {
420 path: "/presolve.validators.js".into(),
421 validators: standard_schema_validators,
422 },
423 ),
424 submission_capability_module: (!submission_capabilities.is_empty()).then_some(
425 RuntimeFormsArtifactSubmissionCapabilityModule {
426 path: "/presolve.form-submissions.js".into(),
427 capabilities: submission_capabilities,
428 },
429 ),
430 forms,
431 instances,
432 hosts,
433 };
434 debug_assert!(validate_runtime_forms_artifact(&artifact).is_valid);
435 artifact
436}
437
438#[allow(clippy::items_after_statements)]
439fn runtime_anchor_for_element(
440 model: &ApplicationSemanticModel,
441 template_id: &crate::SemanticId,
442 element: &crate::SemanticId,
443) -> Option<String> {
444 let template = model
445 .templates
446 .iter()
447 .find(|template| &template.id == template_id)?;
448 fn find(
449 element: &crate::ElementNode,
450 template: &crate::TemplateNode,
451 path: &str,
452 target: &crate::SemanticId,
453 ) -> Option<String> {
454 if template.id.template_entity("element", path) == *target {
455 return Some(element.id.0.clone());
456 }
457 for (index, child) in element.children.iter().enumerate() {
458 if let crate::TemplateChild::Element(child) = child {
459 if let Some(anchor) = find(child, template, &format!("{path}.{index}"), target) {
460 return Some(anchor);
461 }
462 }
463 }
464 None
465 }
466 template
467 .root
468 .as_ref()
469 .and_then(|root| find(root, template, "root", element))
470}
471
472#[must_use]
473pub fn validate_runtime_forms_artifact(
474 artifact: &RuntimeFormsArtifact,
475) -> RuntimeFormsArtifactValidation {
476 let mut diagnostics = Vec::new();
477 if artifact.schema_version != RUNTIME_FORM_ARTIFACT_SCHEMA_VERSION {
478 diagnostics.push("unsupported Forms artifact schema".to_string());
479 }
480 let forms = artifact
481 .forms
482 .iter()
483 .map(|form| form.id.as_str())
484 .collect::<BTreeSet<_>>();
485 if forms.len() != artifact.forms.len() {
486 diagnostics.push("duplicate Form definition".to_string());
487 }
488 let instances = artifact
489 .instances
490 .iter()
491 .map(|instance| instance.id.as_str())
492 .collect::<BTreeSet<_>>();
493 if instances.len() != artifact.instances.len() {
494 diagnostics.push("duplicate Form instance".to_string());
495 }
496 let expected_standard_schema_validators = artifact
497 .forms
498 .iter()
499 .flat_map(|form| &form.validation_rules)
500 .filter_map(|rule| match &rule.argument {
501 RuntimeFormsArtifactRuleArgument::StandardSchema { validator } => {
502 Some(validator.as_str())
503 }
504 _ => None,
505 })
506 .collect::<BTreeSet<_>>();
507 match &artifact.standard_schema_module {
508 Some(module)
509 if module.path == "/presolve.validators.js"
510 && module.validators.len() == expected_standard_schema_validators.len()
511 && module
512 .validators
513 .iter()
514 .map(String::as_str)
515 .collect::<BTreeSet<_>>()
516 == expected_standard_schema_validators => {}
517 None if expected_standard_schema_validators.is_empty() => {}
518 _ => diagnostics.push("invalid Standard Schema runtime module".to_string()),
519 }
520 let expected_submission_capabilities = artifact
521 .forms
522 .iter()
523 .filter_map(|form| form.submission.as_ref()?.capability.as_deref())
524 .collect::<BTreeSet<_>>();
525 match &artifact.submission_capability_module {
526 Some(module)
527 if module.path == "/presolve.form-submissions.js"
528 && module.capabilities.len() == expected_submission_capabilities.len()
529 && module
530 .capabilities
531 .iter()
532 .map(|capability| capability.id.as_str())
533 .collect::<BTreeSet<_>>()
534 == expected_submission_capabilities
535 && module.capabilities.iter().all(|capability| {
536 !capability.id.is_empty()
537 && !capability.module_specifier.is_empty()
538 && !capability.package.is_empty()
539 && !capability.version.is_empty()
540 && capability.integrity.starts_with("sha256:")
541 && !capability.export.is_empty()
542 && !capability.runtime_module.is_empty()
543 && capability.resume_policy == "cold_fallback"
544 }) => {}
545 None if expected_submission_capabilities.is_empty() => {}
546 _ => diagnostics.push("invalid Form submission capability module".to_string()),
547 }
548 for instance in &artifact.instances {
549 if !forms.contains(instance.form.as_str()) {
550 diagnostics.push(format!("unknown Form for instance {}", instance.id));
551 }
552 }
553 for form in &artifact.forms {
554 let field_ids = form
555 .fields
556 .iter()
557 .map(|field| field.id.as_str())
558 .collect::<BTreeSet<_>>();
559 let paths = form
560 .fields
561 .iter()
562 .map(|field| &field.path)
563 .collect::<Vec<_>>();
564 if paths.iter().any(|path| {
565 !(1..=16).contains(&path.len())
566 || path
567 .iter()
568 .any(|segment| !is_canonical_form_field_path_segment(segment))
569 }) {
570 diagnostics.push(format!("invalid Form Field path for {}", form.id));
571 }
572 if paths.iter().enumerate().any(|(index, path)| {
573 paths
574 .iter()
575 .skip(index + 1)
576 .any(|other| form_field_paths_conflict(path, other))
577 }) {
578 diagnostics.push(format!("conflicting Form Field path for {}", form.id));
579 }
580 for rule in &form.validation_rules {
581 if !field_ids.contains(rule.target_field.as_str())
582 || !runtime_rule_argument_matches(rule)
583 || rule
584 .dependency
585 .as_ref()
586 .is_some_and(|dependency| !field_ids.contains(dependency.as_str()))
587 {
588 diagnostics.push(format!("invalid Form validation rule {}", rule.id));
589 }
590 }
591 }
592 for host in &artifact.hosts {
593 if !instances.contains(host.form_instance.as_str())
594 || !forms.contains(host.form.as_str())
595 || host.event != "submit"
596 {
597 diagnostics.push(format!("invalid submission host {}", host.id));
598 }
599 }
600 RuntimeFormsArtifactValidation {
601 is_valid: diagnostics.is_empty(),
602 diagnostics,
603 }
604}
605
606fn runtime_rule_argument(argument: &ValidationRuleArgument) -> RuntimeFormsArtifactRuleArgument {
607 match argument {
608 ValidationRuleArgument::None => RuntimeFormsArtifactRuleArgument::None,
609 ValidationRuleArgument::Number(value) => RuntimeFormsArtifactRuleArgument::Number {
610 value: value.clone(),
611 },
612 ValidationRuleArgument::Length(value) => {
613 RuntimeFormsArtifactRuleArgument::Length { value: *value }
614 }
615 ValidationRuleArgument::Pattern(value) => RuntimeFormsArtifactRuleArgument::Pattern {
616 value: value.clone(),
617 },
618 ValidationRuleArgument::Field(field) => RuntimeFormsArtifactRuleArgument::Field {
619 field: field.to_string(),
620 },
621 ValidationRuleArgument::StandardSchema { validator } => {
622 RuntimeFormsArtifactRuleArgument::StandardSchema {
623 validator: validator.clone(),
624 }
625 }
626 }
627}
628
629fn runtime_rule_argument_matches(rule: &RuntimeFormsArtifactRule) -> bool {
630 match (&*rule.kind, &rule.argument) {
631 ("Required" | "Email", RuntimeFormsArtifactRuleArgument::None) => rule.dependency.is_none(),
632 ("Min" | "Max", RuntimeFormsArtifactRuleArgument::Number { value }) => {
633 rule.dependency.is_none() && value.parse::<f64>().is_ok_and(|value| value.is_finite())
634 }
635 ("MinLength" | "MaxLength", RuntimeFormsArtifactRuleArgument::Length { .. }) => {
636 rule.dependency.is_none()
637 }
638 ("Pattern", RuntimeFormsArtifactRuleArgument::Pattern { value }) => {
639 rule.dependency.is_none() && presolve_parser::is_valid_ecmascript_pattern(value)
640 }
641 ("Equals" | "NotEquals", RuntimeFormsArtifactRuleArgument::Field { field }) => {
642 rule.dependency.as_ref() == Some(field)
643 }
644 ("StandardSchema", RuntimeFormsArtifactRuleArgument::StandardSchema { validator }) => {
645 rule.dependency.is_none() && !validator.is_empty()
646 }
647 _ => false,
648 }
649}
650
651fn is_canonical_form_field_path_segment(segment: &str) -> bool {
652 let mut characters = segment.chars();
653 matches!(characters.next(), Some(character) if character == '_' || character.is_ascii_alphabetic())
654 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
655}
656
657fn form_field_paths_conflict(left: &[String], right: &[String]) -> bool {
658 (left.len() <= right.len() && left.iter().zip(right).all(|(left, right)| left == right))
659 || (right.len() <= left.len() && right.iter().zip(left).all(|(left, right)| left == right))
660}
661
662#[must_use]
663pub fn runtime_forms_artifact_json(artifact: &RuntimeFormsArtifact) -> String {
668 serde_json::to_string_pretty(artifact).expect("Forms artifact serializes deterministically")
669 + "\n"
670}
671
672fn normalization_name(compatibility: FormControlCompatibility) -> String {
673 match compatibility {
674 FormControlCompatibility::Compatible(normalization) => format!("{normalization:?}"),
675 FormControlCompatibility::Incompatible => "Incompatible".to_string(),
676 }
677}
678
679fn operations(operations: &[FormIrOperation]) -> Vec<String> {
680 operations
681 .iter()
682 .map(|operation| format!("{operation:?}"))
683 .collect()
684}
685
686fn field_programs(
687 programs: &std::collections::BTreeMap<crate::FieldId, Vec<FormIrOperation>>,
688) -> Vec<RuntimeFormsArtifactFieldProgram> {
689 programs
690 .iter()
691 .map(
692 |(field, program_operations)| RuntimeFormsArtifactFieldProgram {
693 field: field.to_string(),
694 operations: operations(program_operations),
695 },
696 )
697 .collect()
698}
699
700#[cfg(test)]
701mod tests {
702 #[test]
703 fn emits_schema_v6_with_typed_validation_arguments_and_canonical_references() {
704 let parsed = presolve_parser::parse_file(
705 "src/X.tsx",
706 r#"@component("x")class X{@form()form!:Form;@validate(minLength(2))@field(this.form)value="";render(){return <input field={this.value}/>;}}"#,
707 );
708 let asm = crate::build_application_semantic_model(&parsed);
709 let artifact = super::build_runtime_forms_artifact(&asm);
710 assert_eq!(artifact.schema_version, 6);
711 assert_eq!(artifact.registry_version, 1);
712 assert_eq!(artifact.forms.len(), 1);
713 assert_eq!(artifact.instances.len(), 1);
714 assert_eq!(
715 artifact.forms[0].validation_rules[0].argument,
716 super::RuntimeFormsArtifactRuleArgument::Length { value: 2 }
717 );
718 assert!(super::validate_runtime_forms_artifact(&artifact).is_valid);
719 assert!(super::runtime_forms_artifact_json(&artifact).contains("field-binding"));
720
721 let mut standard_schema = artifact.clone();
722 let validator = "module:src/X.tsx/validation-rule-candidate:42".to_string();
723 standard_schema.forms[0].validation_rules[0].kind = "StandardSchema".to_string();
724 standard_schema.forms[0].validation_rules[0].argument =
725 super::RuntimeFormsArtifactRuleArgument::StandardSchema {
726 validator: validator.clone(),
727 };
728 standard_schema.standard_schema_module =
729 Some(super::RuntimeFormsArtifactStandardSchemaModule {
730 path: "/presolve.validators.js".to_string(),
731 validators: vec![validator],
732 });
733 assert!(super::validate_runtime_forms_artifact(&standard_schema).is_valid);
734
735 let mut malformed = artifact;
736 malformed.forms[0].validation_rules[0].argument =
737 super::RuntimeFormsArtifactRuleArgument::None;
738 assert!(!super::validate_runtime_forms_artifact(&malformed).is_valid);
739
740 let mut malformed_module = standard_schema;
741 malformed_module
742 .standard_schema_module
743 .as_mut()
744 .expect("Standard Schema module")
745 .path = "/other.js".to_string();
746 assert!(!super::validate_runtime_forms_artifact(&malformed_module).is_valid);
747 }
748
749 #[test]
750 fn rejects_noncanonical_and_prefix_conflicting_field_paths() {
751 let parsed = presolve_parser::parse_file(
752 "src/X.tsx",
753 r#"@component("x")class X{@form()form!:Form;@field(this.form)value="";render(){return <input field={this.value}/>;}}"#,
754 );
755 let asm = crate::build_application_semantic_model(&parsed);
756 let mut artifact = super::build_runtime_forms_artifact(&asm);
757 let field = artifact.forms[0].fields[0].clone();
758 artifact.forms[0].fields[0].path = vec!["invalid-path".to_string()];
759 assert!(!super::validate_runtime_forms_artifact(&artifact).is_valid);
760
761 artifact.forms[0].fields[0].path = vec!["address".to_string()];
762 let mut nested = field;
763 nested.path = vec!["address".to_string(), "street".to_string()];
764 artifact.forms[0].fields.push(nested);
765 assert!(!super::validate_runtime_forms_artifact(&artifact).is_valid);
766 }
767
768 #[test]
769 fn forms_products_are_byte_deterministic_when_input_files_are_reversed() {
770 let a = r#"@component("a") class A { @form() @serialize("json") profile!: Form; @field(this.profile) name = ""; @action() @submit(this.profile) save(): void {} render() { return <form form={this.profile}><input field={this.name}/></form>; } }"#;
771 let b = r#"@component("b") class B { @form() @serialize("url-encoded") search!: Form; @field(this.search) query = ""; @action() @submit(this.search) save(): void {} render() { return <form form={this.search}><input field={this.query}/></form>; } }"#;
772 let first = crate::CompilationUnit::parse_sources([("src/A.tsx", a), ("src/B.tsx", b)]);
773 let second = crate::CompilationUnit::parse_sources([("src/B.tsx", b), ("src/A.tsx", a)]);
774 let first = crate::build_application_semantic_model_for_unit(&first);
775 let second = crate::build_application_semantic_model_for_unit(&second);
776
777 assert_eq!(
778 super::runtime_forms_artifact_json(&super::build_runtime_forms_artifact(&first)),
779 super::runtime_forms_artifact_json(&super::build_runtime_forms_artifact(&second)),
780 );
781 assert_eq!(
782 crate::template_manifest_json(&crate::build_template_manifest_from_asm(&first)),
783 crate::template_manifest_json(&crate::build_template_manifest_from_asm(&second)),
784 );
785 assert_eq!(
786 crate::resume_manifest_json(&crate::build_resume_manifest(&first)),
787 crate::resume_manifest_json(&crate::build_resume_manifest(&second)),
788 );
789 }
790}