1use std::{
2 borrow::Cow,
3 collections::BTreeMap,
4 sync::{Arc, Mutex},
5 thread,
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 DEFAULT_TEXTFX_RUNTIME_PATH, TEXTFX_PACKAGE_NAME, TEXTFX_PACKAGE_VERSION, TextFxConfig,
12 TextFxDiagnosticVerbosity, TextFxManifestFragment, TextFxOutputReport, TextFxRenderPreference,
13 TextFxRoutePolicy, TextFxSerializationFormat, explain_textfx, textfx_cache_key,
14 textfx_manifest_fragment,
15};
16
17pub const DEFAULT_TEXTFX_PREPAINT_STYLE_ID: &str = "__DXT_TEXTFX_PREPAINT__";
18pub const DEFAULT_TEXTFX_RUNTIME_ASSET_ID: &str = "textfx.runtime";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum TextFxDiagnosticContext {
23 Build,
24 Ssr,
25 Runtime,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct TextFxDiagnosticVerbosityByContext {
31 pub build: TextFxDiagnosticVerbosity,
32 pub ssr: TextFxDiagnosticVerbosity,
33 pub runtime: TextFxDiagnosticVerbosity,
34}
35
36impl Default for TextFxDiagnosticVerbosityByContext {
37 fn default() -> Self {
38 Self {
39 build: TextFxDiagnosticVerbosity::Detailed,
40 ssr: TextFxDiagnosticVerbosity::Summary,
41 runtime: TextFxDiagnosticVerbosity::Off,
42 }
43 }
44}
45
46impl TextFxDiagnosticVerbosityByContext {
47 pub fn for_context(&self, context: TextFxDiagnosticContext) -> TextFxDiagnosticVerbosity {
48 match context {
49 TextFxDiagnosticContext::Build => self.build,
50 TextFxDiagnosticContext::Ssr => self.ssr,
51 TextFxDiagnosticContext::Runtime => self.runtime,
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct TextFxRuntimeIds {
59 pub prepaint_style_id: String,
60 pub runtime_asset_id: String,
61}
62
63impl Default for TextFxRuntimeIds {
64 fn default() -> Self {
65 Self {
66 prepaint_style_id: DEFAULT_TEXTFX_PREPAINT_STYLE_ID.to_string(),
67 runtime_asset_id: DEFAULT_TEXTFX_RUNTIME_ASSET_ID.to_string(),
68 }
69 }
70}
71
72impl TextFxRuntimeIds {
73 pub fn with_prepaint_style_id(mut self, id: impl Into<String>) -> Self {
74 self.prepaint_style_id = id.into();
75 self
76 }
77
78 pub fn with_runtime_asset_id(mut self, id: impl Into<String>) -> Self {
79 self.runtime_asset_id = id.into();
80 self
81 }
82
83 pub fn duplicate_ids(&self) -> Vec<String> {
84 if self.prepaint_style_id == self.runtime_asset_id {
85 vec![self.prepaint_style_id.clone()]
86 } else {
87 Vec::new()
88 }
89 }
90
91 pub fn deduped(mut self) -> Self {
92 if self.prepaint_style_id == self.runtime_asset_id {
93 self.runtime_asset_id.push_str("-2");
94 }
95 self
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct TextFxAssetPaths {
102 pub runtime_base_path: String,
103 pub runtime_asset_name: String,
104}
105
106impl Default for TextFxAssetPaths {
107 fn default() -> Self {
108 Self {
109 runtime_base_path: "/assets".to_string(),
110 runtime_asset_name: "dioxus-textfx.js".to_string(),
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "kebab-case")]
117pub enum TextFxAssetBudgetCategory {
118 RuntimeScript,
119 PrepaintStyle,
120 ConfigJson,
121 StaticHtml,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct TextFxAssetBudgetEntry {
127 pub category: TextFxAssetBudgetCategory,
128 pub bytes: usize,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct TextFxAssetBudgetBridge {
134 pub package: String,
135 pub entries: Vec<TextFxAssetBudgetEntry>,
136}
137
138impl TextFxAssetBudgetBridge {
139 pub fn total_bytes(&self) -> usize {
140 self.entries.iter().map(|entry| entry.bytes).sum()
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct TextFxBatchOptions {
147 pub format: TextFxSerializationFormat,
148 pub deterministic_parallel: bool,
149 pub sort_by_cache_key: bool,
150}
151
152impl Default for TextFxBatchOptions {
153 fn default() -> Self {
154 Self {
155 format: TextFxSerializationFormat::CompactWhenSmaller,
156 deterministic_parallel: false,
157 sort_by_cache_key: true,
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct TextFxBatchPayload {
165 pub id: String,
166 pub route: Option<String>,
167 pub cache_key: String,
168 pub json: String,
169 pub bytes: usize,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct TextFxBatchSerializationReport {
175 pub package: String,
176 pub deterministic_parallel: bool,
177 pub payloads: Vec<TextFxBatchPayload>,
178 pub total_bytes: usize,
179}
180
181#[derive(Debug)]
182pub struct TextFxBorrowedView<'a> {
183 pub config: &'a TextFxConfig,
184 pub policy: Cow<'a, TextFxRoutePolicy>,
185}
186
187impl<'a> TextFxBorrowedView<'a> {
188 pub fn new(config: &'a TextFxConfig, policy: &'a TextFxRoutePolicy) -> Self {
189 Self {
190 config,
191 policy: Cow::Borrowed(policy),
192 }
193 }
194
195 pub fn with_owned_policy(config: &'a TextFxConfig, policy: TextFxRoutePolicy) -> Self {
196 Self {
197 config,
198 policy: Cow::Owned(policy),
199 }
200 }
201
202 pub fn manifest_fragment(&self) -> TextFxManifestFragment {
203 textfx_manifest_fragment([self.config], self.policy.as_ref())
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct TextFxCssFragment {
210 pub id: String,
211 pub css: String,
212 pub bytes: usize,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct TextFxMotionPolicy {
218 pub reduced_motion: String,
219 pub view_transition: String,
220 pub render_lane: String,
221}
222
223impl Default for TextFxMotionPolicy {
224 fn default() -> Self {
225 Self {
226 reduced_motion: "fade-only".to_string(),
227 view_transition: "optional".to_string(),
228 render_lane: "textfx".to_string(),
229 }
230 }
231}
232
233pub trait TextFxMotionPolicyHook {
234 fn apply(&self, policy: TextFxMotionPolicy) -> TextFxMotionPolicy;
235}
236
237#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct TextFxRouteMetadata {
240 pub route: String,
241 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub cache_keys: Vec<String>,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub packages: Vec<String>,
245 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
246 pub labels: BTreeMap<String, String>,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct TextFxOptimizerArtifact {
252 pub id: String,
253 pub content_hash: String,
254 pub bytes: usize,
255 pub minified: bool,
256}
257
258pub trait TextFxOptimizerReuseHook {
259 fn reuse_artifact(&self, artifact: &TextFxOptimizerArtifact) -> Option<String>;
260}
261
262pub trait TextFxCacheBackend {
263 fn get(&self, key: &str) -> Option<String>;
264 fn put(&self, key: String, value: String);
265}
266
267#[derive(Clone, Default)]
268pub struct TextFxMemoryCache {
269 values: Arc<Mutex<BTreeMap<String, String>>>,
270}
271
272impl TextFxCacheBackend for TextFxMemoryCache {
273 fn get(&self, key: &str) -> Option<String> {
274 self.values.lock().ok()?.get(key).cloned()
275 }
276
277 fn put(&self, key: String, value: String) {
278 if let Ok(mut values) = self.values.lock() {
279 values.insert(key, value);
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct TextFxOffloadPlan {
287 pub package: String,
288 pub lane: String,
289 pub serializable: bool,
290 pub renderer: TextFxRenderPreference,
291 pub payload_cache_key: String,
292 pub tasks: Vec<String>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase")]
297pub struct TextFxCompactDictionary {
298 pub version: u8,
299 pub terms: BTreeMap<String, u16>,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "camelCase")]
304pub struct TextFxTraceEvent {
305 pub name: String,
306 pub cache_key: String,
307 pub route: Option<String>,
308 pub message: String,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct TextFxStrataMigrationPlan {
314 pub package: String,
315 pub route: Option<String>,
316 pub steps: Vec<String>,
317}
318
319pub fn textfx_serialize_batch<'a>(
333 configs: impl IntoIterator<Item = &'a TextFxConfig>,
334 policy: TextFxRoutePolicy,
335 options: TextFxBatchOptions,
336) -> serde_json::Result<TextFxBatchSerializationReport> {
337 let configs = configs.into_iter().collect::<Vec<_>>();
338 let mut payloads = if options.deterministic_parallel && configs.len() > 1 {
339 thread::scope(|scope| {
340 let mut handles = Vec::new();
341 for config in configs {
342 let policy = policy.clone();
343 handles.push(scope.spawn(move || serialize_one(config, &policy, options.format)));
344 }
345 handles
346 .into_iter()
347 .map(|handle| handle.join().expect("TextFX batch worker panicked"))
348 .collect::<serde_json::Result<Vec<_>>>()
349 })?
350 } else {
351 configs
352 .into_iter()
353 .map(|config| serialize_one(config, &policy, options.format))
354 .collect::<serde_json::Result<Vec<_>>>()?
355 };
356 if options.sort_by_cache_key {
357 payloads.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
358 }
359 let total_bytes = payloads.iter().map(|payload| payload.bytes).sum();
360 Ok(TextFxBatchSerializationReport {
361 package: TEXTFX_PACKAGE_NAME.to_string(),
362 deterministic_parallel: options.deterministic_parallel,
363 payloads,
364 total_bytes,
365 })
366}
367
368pub fn textfx_asset_budget_bridge<'a>(
369 configs: impl IntoIterator<Item = &'a TextFxConfig>,
370 policy: &TextFxRoutePolicy,
371) -> TextFxAssetBudgetBridge {
372 let output = crate::textfx_output_report(configs, policy);
373 TextFxAssetBudgetBridge {
374 package: TEXTFX_PACKAGE_NAME.to_string(),
375 entries: vec![
376 TextFxAssetBudgetEntry {
377 category: TextFxAssetBudgetCategory::ConfigJson,
378 bytes: output.config_bytes,
379 },
380 TextFxAssetBudgetEntry {
381 category: TextFxAssetBudgetCategory::RuntimeScript,
382 bytes: output.runtime_bytes,
383 },
384 TextFxAssetBudgetEntry {
385 category: TextFxAssetBudgetCategory::StaticHtml,
386 bytes: output.static_html_bytes,
387 },
388 ],
389 }
390}
391
392pub fn textfx_precomputed_css_fragments() -> Vec<TextFxCssFragment> {
393 crate::TextFxEffect::ALL
394 .into_iter()
395 .map(|effect| {
396 let css = format!(
397 ".dxt-effect-{}{{--dxt-effect:\"{}\";}}",
398 effect.as_attr(),
399 effect.as_attr()
400 );
401 TextFxCssFragment {
402 id: effect.as_attr().to_string(),
403 bytes: css.len(),
404 css,
405 }
406 })
407 .collect()
408}
409
410pub fn textfx_apply_motion_hook<H: TextFxMotionPolicyHook>(
411 policy: TextFxMotionPolicy,
412 hook: &H,
413) -> TextFxMotionPolicy {
414 hook.apply(policy)
415}
416
417pub fn textfx_coalesce_route_metadata(
418 route: impl Into<String>,
419 fragments: impl IntoIterator<Item = TextFxManifestFragment>,
420) -> TextFxRouteMetadata {
421 let mut metadata = TextFxRouteMetadata {
422 route: route.into(),
423 ..TextFxRouteMetadata::default()
424 };
425 for fragment in fragments {
426 metadata.cache_keys.push(fragment.cache_key);
427 if !metadata.packages.contains(&fragment.package) {
428 metadata.packages.push(fragment.package);
429 }
430 metadata.labels.extend(fragment.labels);
431 }
432 metadata.cache_keys.sort();
433 metadata.packages.sort();
434 metadata
435}
436
437pub fn textfx_optimizer_artifacts(report: &TextFxOutputReport) -> Vec<TextFxOptimizerArtifact> {
438 [
439 ("textfx.config", report.config_bytes, true),
440 ("textfx.runtime", report.runtime_bytes, true),
441 ("textfx.html", report.static_html_bytes, false),
442 ]
443 .into_iter()
444 .map(|(id, bytes, minified)| TextFxOptimizerArtifact {
445 id: id.to_string(),
446 content_hash: integration_hash_hex([id, &report.cache_key, &bytes.to_string()]),
447 bytes,
448 minified,
449 })
450 .collect()
451}
452
453pub fn textfx_cache_put_report<B: TextFxCacheBackend>(
454 cache: &B,
455 report: &TextFxOutputReport,
456) -> String {
457 let key = format!(
458 "{}:{}:{}",
459 TEXTFX_PACKAGE_NAME, TEXTFX_PACKAGE_VERSION, report.cache_key
460 );
461 cache.put(
462 key.clone(),
463 serde_json::to_string(report).unwrap_or_default(),
464 );
465 key
466}
467
468pub fn textfx_workertown_offload_plan<'a>(
469 configs: impl IntoIterator<Item = &'a TextFxConfig>,
470 policy: &TextFxRoutePolicy,
471) -> TextFxOffloadPlan {
472 let configs = configs.into_iter().collect::<Vec<_>>();
473 TextFxOffloadPlan {
474 package: TEXTFX_PACKAGE_NAME.to_string(),
475 lane: "textfx-render".to_string(),
476 serializable: true,
477 renderer: TextFxRenderPreference::WorkerTownRender,
478 payload_cache_key: textfx_cache_key(
479 configs.iter().copied(),
480 policy.route.as_deref(),
481 Some("workertown"),
482 ),
483 tasks: vec![
484 "split-text".to_string(),
485 "prepare-timeline".to_string(),
486 "reserve-layout".to_string(),
487 ],
488 }
489}
490
491pub fn textfx_compact_dictionary() -> TextFxCompactDictionary {
492 let mut terms = BTreeMap::new();
493 for (index, term) in [
494 "config",
495 "runtime",
496 "style",
497 "route",
498 "profile",
499 "trigger",
500 "split",
501 "workertown",
502 ]
503 .into_iter()
504 .enumerate()
505 {
506 terms.insert(term.to_string(), index as u16);
507 }
508 TextFxCompactDictionary { version: 1, terms }
509}
510
511pub fn textfx_trace_report<'a>(
512 configs: impl IntoIterator<Item = &'a TextFxConfig>,
513 policy: &TextFxRoutePolicy,
514 mut emit: impl FnMut(TextFxTraceEvent),
515) {
516 let configs = configs.into_iter().collect::<Vec<_>>();
517 let explain = explain_textfx(configs.iter().copied(), policy);
518 emit(TextFxTraceEvent {
519 name: "textfx.policy".to_string(),
520 cache_key: explain.cache_key.clone(),
521 route: policy.route.clone(),
522 message: explain.runtime_decision,
523 });
524 emit(TextFxTraceEvent {
525 name: "textfx.output".to_string(),
526 cache_key: explain.cache_key,
527 route: policy.route.clone(),
528 message: format!("{} config bytes", explain.output.config_bytes),
529 });
530}
531
532pub fn textfx_strata_migration_plan<'a>(
533 configs: impl IntoIterator<Item = &'a TextFxConfig>,
534 policy: &TextFxRoutePolicy,
535) -> TextFxStrataMigrationPlan {
536 let configs = configs.into_iter().collect::<Vec<_>>();
537 TextFxStrataMigrationPlan {
538 package: TEXTFX_PACKAGE_NAME.to_string(),
539 route: policy.route.clone(),
540 steps: vec![
541 "collect TextFxConfig values into a route batch".to_string(),
542 format!(
543 "register cache key {}",
544 textfx_cache_key(configs.iter().copied(), policy.route.as_deref(), None)
545 ),
546 "attach TextFxManifestFragment to Strata route metadata".to_string(),
547 "preserve CSS-first and WorkerTown render policy decisions".to_string(),
548 ],
549 }
550}
551
552pub fn textfx_conformance_fixture() -> serde_json::Value {
553 let config = TextFxConfig::new("headline", "Launch ready").scramble();
554 let policy = TextFxRoutePolicy::default().route("/textfx");
555 serde_json::json!({
556 "manifest": textfx_manifest_fragment([&config], &policy),
557 "dictionary": textfx_compact_dictionary(),
558 "cssFragments": textfx_precomputed_css_fragments(),
559 "runtimePath": DEFAULT_TEXTFX_RUNTIME_PATH,
560 })
561}
562
563fn serialize_one(
564 config: &TextFxConfig,
565 policy: &TextFxRoutePolicy,
566 format: TextFxSerializationFormat,
567) -> serde_json::Result<TextFxBatchPayload> {
568 let json = match format {
569 TextFxSerializationFormat::ReadableJson | TextFxSerializationFormat::StableJson => {
570 config.to_json()?
571 }
572 TextFxSerializationFormat::CompactWhenSmaller => {
573 let full = config.to_json()?;
574 let compact = config.to_compact_json()?;
575 if compact.len() < full.len() {
576 compact
577 } else {
578 full
579 }
580 }
581 };
582 Ok(TextFxBatchPayload {
583 id: config.id.clone(),
584 route: policy.route.clone(),
585 cache_key: textfx_cache_key([config], policy.route.as_deref(), Some(format.as_attr())),
586 bytes: json.len(),
587 json,
588 })
589}
590
591fn integration_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
592 let mut hash = 0xcbf29ce484222325u64;
593 for part in parts {
594 for byte in part.as_bytes() {
595 hash ^= u64::from(*byte);
596 hash = hash.wrapping_mul(0x100000001b3);
597 }
598 hash ^= 0xff;
599 hash = hash.wrapping_mul(0x100000001b3);
600 }
601 format!("{hash:016x}")
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use crate::{TextFxOutputBudget, TextFxPresetProfile, TextFxRoutePolicy};
608
609 #[test]
610 fn batch_serialization_parallel_is_deterministic() {
611 let a = TextFxConfig::new("a", "Alpha").scramble();
612 let b = TextFxConfig::new("b", "Beta").typewriter();
613 let policy = TextFxRoutePolicy::default().route("/textfx");
614 let serial =
615 textfx_serialize_batch([&a, &b], policy.clone(), TextFxBatchOptions::default())
616 .unwrap();
617 let parallel = textfx_serialize_batch(
618 [&a, &b],
619 policy,
620 TextFxBatchOptions {
621 deterministic_parallel: true,
622 ..TextFxBatchOptions::default()
623 },
624 )
625 .unwrap();
626
627 assert_eq!(serial.payloads, parallel.payloads);
628 assert!(parallel.deterministic_parallel);
629 }
630
631 #[test]
632 fn budget_duplicate_ids_cache_and_offload_are_reported() {
633 let config = TextFxConfig::new("headline", "Launch ready")
634 .route_profile(TextFxPresetProfile::Expressive);
635 let policy = TextFxRoutePolicy::default()
636 .route("/textfx")
637 .budget(TextFxOutputBudget::new().runtime_bytes(4));
638 let report = crate::textfx_output_report([&config], &policy);
639 let bridge = textfx_asset_budget_bridge([&config], &policy);
640 let ids =
641 TextFxRuntimeIds::default().with_runtime_asset_id(DEFAULT_TEXTFX_PREPAINT_STYLE_ID);
642 let cache = TextFxMemoryCache::default();
643 let key = textfx_cache_put_report(&cache, &report);
644 let offload = textfx_workertown_offload_plan([&config], &policy);
645
646 assert!(bridge.total_bytes() >= report.config_bytes);
647 assert_eq!(
648 ids.duplicate_ids(),
649 vec![DEFAULT_TEXTFX_PREPAINT_STYLE_ID.to_string()]
650 );
651 assert!(ids.deduped().duplicate_ids().is_empty());
652 assert!(cache.get(&key).is_some());
653 assert!(offload.serializable);
654 assert!(offload.tasks.iter().any(|task| task == "split-text"));
655 }
656
657 #[test]
658 fn css_motion_metadata_optimizer_trace_and_migration_are_concrete() {
659 struct ForceStatic;
660
661 impl TextFxMotionPolicyHook for ForceStatic {
662 fn apply(&self, mut policy: TextFxMotionPolicy) -> TextFxMotionPolicy {
663 policy.reduced_motion = "static".to_string();
664 policy
665 }
666 }
667
668 let config = TextFxConfig::new("headline", "Launch ready").scramble();
669 let policy = TextFxRoutePolicy::default().route("/textfx");
670 let manifest = textfx_manifest_fragment([&config], &policy);
671 let metadata = textfx_coalesce_route_metadata("/textfx", [manifest]);
672 let css = textfx_precomputed_css_fragments();
673 let output = crate::textfx_output_report([&config], &policy);
674 let artifacts = textfx_optimizer_artifacts(&output);
675 let motion = textfx_apply_motion_hook(TextFxMotionPolicy::default(), &ForceStatic);
676 let dictionary = textfx_compact_dictionary();
677 let migration = textfx_strata_migration_plan([&config], &policy);
678 let mut traces = Vec::new();
679 textfx_trace_report([&config], &policy, |event| traces.push(event));
680 let fixture = textfx_conformance_fixture();
681
682 assert_eq!(metadata.route, "/textfx");
683 assert!(css.iter().any(|fragment| fragment.id == "scramble"));
684 assert!(
685 artifacts
686 .iter()
687 .any(|artifact| artifact.id == "textfx.runtime")
688 );
689 assert_eq!(motion.reduced_motion, "static");
690 assert_eq!(dictionary.terms["runtime"], 1);
691 assert!(migration.steps.iter().any(|step| step.contains("Strata")));
692 assert_eq!(traces.len(), 2);
693 assert!(fixture.get("manifest").is_some());
694 }
695}