diff --git a/.repository-projection.json b/.repository-projection.json index d08342415..7a4ec0d1f 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "03ccf8fea13e310f4d2fd6dd92736749692f3344", + "sourceSha": "44398bc78a6101246b54c93a5c14fdf51b592a05", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "9c490dd81e4c467066295a5f88d01d2f05366a08", + "priorProjectedBase": "b845d90c5168779978aa9579a9d6b837c0870eab", "definitionDigest": "064b6b9745d392dde277afd7dfdf028cff32b6178a8b9c99c518343219bab429", "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", - "contentDigest": "d54a206dc5baa9988e5cb4e69f4027b92b950195e9267c6562a98b15e9d198e4", + "contentDigest": "f2f670aaf2deaad783ec6d930f42e640de1e7ffb5ddb1b35339b9a2e0db37700", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index c8af69ca2..8aed9ad77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4202,6 +4202,7 @@ name = "maestro-ui-preview" version = "0.1.0" dependencies = [ "crossterm", + "dunce", "maestro-interaction", "maestro-presentation", "maestro-ui", diff --git a/packages/tui-rs/examples/onboarding-preview.rs b/packages/tui-rs/examples/onboarding-preview.rs index bbf88f852..c3246b0e4 100644 --- a/packages/tui-rs/examples/onboarding-preview.rs +++ b/packages/tui-rs/examples/onboarding-preview.rs @@ -12,6 +12,10 @@ use maestro_ui_preview::{ Scene, authoring::{MenuRecipe, menu_recipe_fixtures, menu_studio}, review::{self, Capture}, + schema::{ + RECIPE_SCHEMA, RECIPE_SCHEMA_VERSION, THEME_REPLAY_SCHEMA, THEME_REPLAY_SCHEMA_VERSION, + WireEnvelope, migrate_recipe, migrate_theme_replay, + }, }; const STATES: &[(&str, SetupPage)] = &[ @@ -168,19 +172,44 @@ fn captures() -> Result, String> { for capture in &mut captures { capture.source = "products/maestro/packages/tui-rs/examples/onboarding-preview.rs".into(); } - captures.extend(support::theme_selector_story::captures()?); + captures.extend(support::ui_stories::captures()?); captures.extend(maestro_ui_preview::registry()?.captures()?); Ok(captures) } +fn profiled_captures(profile_id: &str) -> Result, String> { + let profile = maestro_ui_preview::schema::builtin_profile(profile_id)?; + let mut selected = captures()?; + selected.retain(|capture| { + profile + .geometries + .contains(&(capture.scene.width, capture.scene.height)) + && (profile.terminal.motion || capture.semantic.is_some() || capture.scene.time_ms == 0) + }); + if selected.is_empty() { + return Err(format!( + "coverage profile {profile_id} selected no captures" + )); + } + for capture in &mut selected { + capture.metadata = maestro_ui_preview::schema::CaptureMetadata::for_profile(&profile); + } + Ok(selected) +} fn run() -> Result<(), String> { let args: Vec<_> = std::env::args().skip(1).collect(); if args == ["--sequences"] { println!( "{}", serde_json::to_string(&serde_json::json!({ - "fixtures": support::theme_selector_story::fixture_requests(), - "menu_starter": MenuRecipe::starter(), - "menu_fixtures": menu_recipe_fixtures(), + "fixtures": support::theme_selector_story::fixture_requests() + .into_iter() + .map(|request| WireEnvelope::new(THEME_REPLAY_SCHEMA, THEME_REPLAY_SCHEMA_VERSION, request)) + .collect::>(), + "menu_starter": WireEnvelope::new(RECIPE_SCHEMA, RECIPE_SCHEMA_VERSION, MenuRecipe::starter()), + "menu_fixtures": menu_recipe_fixtures() + .into_iter() + .map(|recipe| WireEnvelope::new(RECIPE_SCHEMA, RECIPE_SCHEMA_VERSION, recipe)) + .collect::>(), "presets": support::theme_selector_story::presets() .into_iter() .zip([ @@ -191,7 +220,7 @@ fn run() -> Result<(), String> { ]) .map(|(request, label)| serde_json::json!({ "label": label, - "request": request, + "request": WireEnvelope::new(THEME_REPLAY_SCHEMA, THEME_REPLAY_SCHEMA_VERSION, request), })) .collect::>(), })) @@ -209,7 +238,10 @@ fn run() -> Result<(), String> { if body.len() > 16_384 { return Err("menu recipe exceeds 16384 bytes".into()); } - let recipe: MenuRecipe = serde_json::from_str(&body).map_err(|error| error.to_string())?; + let value = serde_json::from_str(&body).map_err(|error| error.to_string())?; + let recipe = migrate_recipe(value) + .map_err(|error| error.to_string())? + .value; println!( "{}", serde_json::to_string(&menu_studio(recipe)?).map_err(|error| error.to_string())? @@ -226,7 +258,11 @@ fn run() -> Result<(), String> { if body.len() > 16_384 { return Err("replay request exceeds 16384 bytes".into()); } - let request = serde_json::from_str(&body).map_err(|error| error.to_string())?; + let value = serde_json::from_str(&body).map_err(|error| error.to_string())?; + let request = + migrate_theme_replay::(value) + .map_err(|error| error.to_string())? + .value; let response = support::theme_selector_story::replay(request)?; println!( "{}", @@ -234,14 +270,28 @@ fn run() -> Result<(), String> { ); return Ok(()); } - if args.len() > 1 || args.first().is_some_and(|a| a != "--html" && a != "--json") { - return Err( - "usage: onboarding-preview [--html|--json|--sequences|--replay-stdin|--studio-stdin]" - .into(), - ); + let mut format = None; + let mut profile = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--html" | "--json" if format.is_none() => format = Some(args[index].as_str()), + "--profile" if profile.is_none() => { + index += 1; + profile = Some( + args.get(index) + .ok_or("missing value for --profile")? + .as_str(), + ); + } + _ => { + return Err("usage: onboarding-preview [--html|--json] [--profile pr-v1|scheduled-v1] | [--sequences|--replay-stdin|--studio-stdin]".into()); + } + } + index += 1; } - let captures = captures()?; - let output = if args.first().is_some_and(|a| a == "--html") { + let captures = profile.map_or_else(captures, profiled_captures)?; + let output = if format == Some("--html") { review::html(&captures)? } else { review::json(&captures)? @@ -296,4 +346,78 @@ mod tests { ); } } + + #[test] + fn retry_and_cancel_presets_have_passing_behavior_contracts() { + let presets = support::theme_selector_story::presets(); + for index in [1, 3] { + let response = support::theme_selector_story::replay(presets[index].clone()).unwrap(); + assert!( + response.semantic.passed(), + "{:?}", + response.semantic.failures() + ); + } + let cancel = support::theme_selector_story::replay(presets[1].clone()).unwrap(); + let observed = cancel + .semantic + .observations + .iter() + .map(|observation| (observation.name(), observation.display_value())) + .collect::>(); + assert_eq!(observed.get("state").map(String::as_str), Some("ready")); + assert_eq!( + observed.get("focus-region").map(String::as_str), + Some("closed") + ); + let response = support::theme_selector_story::replay(presets[3].clone()).unwrap(); + let failed = maestro_ui_preview::contract::evaluate_step( + response.semantic.step, + response.semantic.observations, + response.semantic.effects, + &[maestro_ui_preview::contract::StoryExpectation::effect_count("retry-themes", 2)], + ) + .unwrap(); + assert_eq!( + failed.failures(), + ["expected retry-themes exactly 2 times; observed 1"] + ); + } + + #[test] + fn named_profiles_select_bounded_captures_and_stamp_identity() { + let pr = profiled_captures("pr-v1").unwrap(); + let scheduled = profiled_captures("scheduled-v1").unwrap(); + assert!(!pr.is_empty()); + assert!(pr.len() < scheduled.len()); + for (id, captures) in [("pr-v1", pr), ("scheduled-v1", scheduled)] { + assert!( + captures + .iter() + .all(|capture| capture.metadata.profile.id == id) + ); + assert!( + captures + .iter() + .all(|capture| capture.metadata.profile.version == 1) + ); + for (story_id, width) in [ + ("theme-selector-interaction-3", 30), + ("theme-selector-narrow", 28), + ] { + let final_capture = captures + .iter() + .filter(|capture| capture.scene.id == story_id && capture.scene.width == width) + .max_by_key(|capture| capture.scene.time_ms) + .unwrap_or_else(|| panic!("{id} omitted {story_id} at {width} columns")); + let semantic = final_capture + .semantic + .as_ref() + .unwrap_or_else(|| panic!("{id} omitted final semantics for {story_id}")); + assert!(semantic.passed(), "{id} failed {story_id} semantics"); + assert!(!semantic.expectations.is_empty()); + } + } + assert!(profiled_captures("unknown").is_err()); + } } diff --git a/packages/tui-rs/examples/support/mod.rs b/packages/tui-rs/examples/support/mod.rs index d3abec409..531f9f2db 100644 --- a/packages/tui-rs/examples/support/mod.rs +++ b/packages/tui-rs/examples/support/mod.rs @@ -1 +1,2 @@ pub mod theme_selector_story; +pub mod ui_stories; diff --git a/packages/tui-rs/examples/support/theme_selector_story.rs b/packages/tui-rs/examples/support/theme_selector_story.rs index d390ef6bd..996a9eb41 100644 --- a/packages/tui-rs/examples/support/theme_selector_story.rs +++ b/packages/tui-rs/examples/support/theme_selector_story.rs @@ -4,6 +4,10 @@ use maestro_ui::{PickerOutcome, PickerStatus}; use maestro_ui_preview::{ Scene, authoring::{REPLAY_VERSION, StoryInput, StoryKey, StorySequence}, + contract::{ + StoryContract, StoryEffect, StoryExpectation, StoryObservation, StoryStepResult, + evaluate_step, + }, review::{self, Capture}, }; use serde::{Deserialize, Serialize}; @@ -102,6 +106,7 @@ impl ThemeReplayRequest { pub struct ThemeReplayResponse { pub capture: Capture, pub effects: Vec, + pub semantic: StoryStepResult, pub sequence: ThemeReplayRequest, pub effect_policy: &'static str, } @@ -188,6 +193,39 @@ impl ThemeStory { capture.source = SOURCE.into(); Ok(capture) } + fn semantic( + &self, + step: usize, + expectations: &[StoryExpectation], + ) -> Result { + let selected_action = self.selector.selected_theme().unwrap_or("none"); + let observations = vec![ + StoryObservation::id("state", self.fixture.id()), + StoryObservation::text("selected-action", selected_action), + StoryObservation::text("query", self.selector.query()), + StoryObservation::id( + "focus-region", + if self.selector.is_visible() { + "theme-list" + } else { + "closed" + }, + ), + ]; + let effects = self + .effects + .iter() + .map(|effect| { + StoryEffect::new(match effect { + SimulatedEffect::PreviewTheme(_) => "preview-theme", + SimulatedEffect::CommitTheme(_) => "commit-theme", + SimulatedEffect::RestoreOpeningTheme => "restore-opening-theme", + SimulatedEffect::RetryThemes => "retry-themes", + }) + }) + .collect(); + evaluate_step(step, observations, effects, expectations) + } } fn items(fixture: ThemeFixture) -> Vec { @@ -227,6 +265,8 @@ pub fn replay(request: ThemeReplayRequest) -> Result Result Result { + let mut contract = StoryContract::new("theme-selector-live", "maestro-tui").expect( + StoryExpectation::no_unexpected_effects([ + "preview-theme", + "commit-theme", + "restore-opening-theme", + "retry-themes", + ]), + ); + let has_retry = request + .inputs + .iter() + .any(|input| matches!(input, StoryInput::Retry)); + let has_cancel = request.inputs.iter().any(|input| { + matches!( + input, + StoryInput::Key { + key: StoryKey::Escape, + .. + } + ) + }); + if has_retry || has_cancel { + contract = contract.expect(StoryExpectation::observation("state", "ready")); + } + if has_retry { + contract = contract.expect(StoryExpectation::effect_count("retry-themes", 1)); + } + if has_cancel { + contract = contract + .expect(StoryExpectation::observation("focus-region", "closed")) + .expect(StoryExpectation::effect_count("restore-opening-theme", 1)); + } + contract.validate()?; + Ok(contract) +} + pub fn presets() -> Vec { vec![ ThemeReplayRequest { @@ -333,11 +411,16 @@ pub fn captures() -> Result, String> { let mut captures = Vec::new(); for fixture in FIXTURES { let (width, height) = fixture.dimensions(); - captures.push(ThemeStory::new(fixture, width, height)?.capture( - &format!("theme-selector-{}", fixture.id()), - fixture.label(), - 0, - )?); + let story_id = format!("theme-selector-{}", fixture.id()); + let story = ThemeStory::new(fixture, width, height)?; + let contract = StoryContract::new(&story_id, "maestro-tui") + .expect(StoryExpectation::observation("state", fixture.id())) + .expect(StoryExpectation::no_unexpected_effects(Vec::::new())); + contract.validate()?; + let semantic = story.semantic(0, &contract.expectations)?; + let mut capture = story.capture(&story_id, fixture.label(), 0)?; + capture.semantic = Some(semantic); + captures.push(capture); } for (index, request) in presets().into_iter().enumerate() { let id = format!("theme-selector-interaction-{}", index + 1); @@ -352,7 +435,20 @@ pub fn captures() -> Result, String> { for input in &request.inputs[..step] { story.apply(input)?; } - captures.push(story.capture(&id, label, step)?); + let contract = if step == request.inputs.len() { + Some(journey_contract(&request)?) + } else { + None + }; + let semantic = story.semantic( + step, + contract + .as_ref() + .map_or(&[][..], |contract| &contract.expectations), + )?; + let mut capture = story.capture(&id, label, step)?; + capture.semantic = Some(semantic); + captures.push(capture); } } Ok(captures) @@ -362,6 +458,33 @@ pub fn captures() -> Result, String> { mod tests { use super::*; + #[test] + fn combined_retry_and_cancel_has_one_final_state_expectation() { + let request = ThemeReplayRequest { + version: REPLAY_VERSION, + fixture: ThemeFixture::Error, + width: 72, + height: 22, + inputs: vec![ + StoryInput::Retry, + StoryInput::Key { + key: StoryKey::Escape, + ctrl: false, + }, + ], + }; + let contract = journey_contract(&request).unwrap(); + assert_eq!( + contract + .expectations + .iter() + .filter(|expectation| matches!(expectation, StoryExpectation::ObservationEquals { name, .. } if name == "state")) + .count(), + 1 + ); + assert!(replay(request).unwrap().semantic.passed()); + } + #[test] fn fixtures_and_interactions_are_deterministic_and_bounded() { let captures = captures().unwrap(); @@ -431,4 +554,40 @@ mod tests { assert_eq!(ready[0], "auto"); assert_eq!(ready.last().unwrap(), "vscode-light-modern"); } + + #[test] + fn semantic_query_and_selection_follow_controller_cursor_edits() { + let request = ThemeReplayRequest { + version: REPLAY_VERSION, + fixture: ThemeFixture::Ready, + width: 72, + height: 22, + inputs: vec![ + StoryInput::Text { + text: "daxk".into(), + }, + StoryInput::Key { + key: StoryKey::Left, + ctrl: false, + }, + StoryInput::Key { + key: StoryKey::Backspace, + ctrl: false, + }, + StoryInput::Text { text: "r".into() }, + ], + }; + let response = replay(request).unwrap(); + let observed = response + .semantic + .observations + .iter() + .map(|observation| (observation.name(), observation.display_value())) + .collect::>(); + assert_eq!(observed.get("query").map(String::as_str), Some("dark")); + assert_eq!( + observed.get("selected-action").map(String::as_str), + Some("dark") + ); + } } diff --git a/packages/tui-rs/examples/support/ui_stories.rs b/packages/tui-rs/examples/support/ui_stories.rs new file mode 100644 index 000000000..42f7b7e3a --- /dev/null +++ b/packages/tui-rs/examples/support/ui_stories.rs @@ -0,0 +1,30 @@ +//! ThemeSelector adapter-local story registration. +use maestro_ui_preview::review::Capture; + +macro_rules! generated_stories { + () => { + fn generated_captures(_captures: &mut [Capture]) -> Result<(), String> { + Ok(()) + } + }; + ($first:ident $(, $story:ident)* $(,)?) => { + pub mod $first; + $(pub mod $story;)* + fn generated_captures(captures: &mut Vec) -> Result<(), String> { + captures.extend($first::captures()?); + $(captures.extend($story::captures()?);)* + Ok(()) + } + }; +} + +generated_stories! { +// maestro-ui-stories:start +// maestro-ui-stories:end +} + +pub fn captures() -> Result, String> { + let mut captures = super::theme_selector_story::captures()?; + generated_captures(&mut captures)?; + Ok(captures) +} diff --git a/packages/tui-rs/src/components/theme_selector.rs b/packages/tui-rs/src/components/theme_selector.rs index 01b347da0..93e099755 100644 --- a/packages/tui-rs/src/components/theme_selector.rs +++ b/packages/tui-rs/src/components/theme_selector.rs @@ -64,6 +64,16 @@ impl ThemeSelector { pub fn is_visible(&self) -> bool { self.picker.is_open() } + /// Current production picker query after typing, paste, cursor movement, and edits. + #[must_use] + pub fn query(&self) -> &str { + self.picker.query() + } + /// Stable selected theme from the production picker controller. + #[must_use] + pub fn selected_theme(&self) -> Option<&str> { + self.picker.selected().map(String::as_str) + } pub fn insert_str(&mut self, text: &str) -> PickerOutcome { self.picker.insert_str(text) } diff --git a/packages/ui-preview-rs/Cargo.toml b/packages/ui-preview-rs/Cargo.toml index ea8c262f5..de7824187 100644 --- a/packages/ui-preview-rs/Cargo.toml +++ b/packages/ui-preview-rs/Cargo.toml @@ -12,6 +12,7 @@ maestro-ui.workspace = true maestro-interaction.workspace = true ratatui.workspace = true crossterm.workspace = true +dunce.workspace = true serde.workspace = true serde_json.workspace = true unicode-width.workspace = true diff --git a/packages/ui-preview-rs/README.md b/packages/ui-preview-rs/README.md index 80d558916..40e7d06f8 100644 --- a/packages/ui-preview-rs/README.md +++ b/packages/ui-preview-rs/README.md @@ -86,6 +86,42 @@ comparisons and the native terminal suite for interaction regressions. ## Author a menu or a scene +Studio keeps story creation inside an adapter-owned fixture directory and +updates only its marker-bounded registration block. Preview a plan before any +write, then verify the registered story through the same result stream used by +the gallery and PR evidence: + +```sh +cargo run --locked -p maestro-ui-preview -- studio new --adapter shared-menu workspace-picker --check +cargo run --locked -p maestro-ui-preview -- studio new --adapter shared-menu workspace-picker +cargo run --locked -p maestro-ui-preview -- studio verify workspace-picker +cargo run --locked -p maestro-ui-preview -- studio promote --adapter shared-menu path/to/menu-recipe.json --check +cargo run --locked -p maestro-ui-preview -- studio coverage +``` + +Promotion validates the recipe before writing, refuses existing fixtures, +runs a fixed adapter verifier, and restores the invocation-owned fixture and +registration edit if verification fails. Adapter manifests publish the owner, +supported story kinds, coverage profiles, source directory, and lifecycle. + +The complete authoring loop is: + +1. Run `studio new ... --check` and inspect the two owned paths. +2. Run `studio new`, edit the bounded fixture data, and run `studio verify ID`. +3. For a browser-authored menu recipe, save the envelope inside the repository, + run `studio promote ... --check`, then run the same command without `--check`. +4. Run `studio coverage` and inspect the selected profile, declared inventory, + final story states, adapter owner, and lifecycle diagnostics. + +Generated modules compile both as registered modules and standalone examples. +Their registry entry carries the adapter ID, so coverage and contribution +checks cannot silently classify the story as unowned. Creation and promotion +use create-new writes: repeating either command refuses the existing fixture. +Promotion removes only its own fixture and marker entry if its fixed behavior +verifier fails, while preserving concurrent unrelated registration edits. +Record tree-keyed verification receipts only after committing the story and +documentation changes. Rerun them after any fix; stale receipts fail closed. + Generate a minimal runnable menu story without overwriting an existing file: ```sh @@ -207,3 +243,17 @@ Failed builds retain the previous page and show an error; they never update accepted baselines. It uses the configured `CARGO_TARGET_DIR` or a user cache outside the checkout. In Mono it runs the existing build-capacity check before each build. Ctrl+C stops serving. + +## Coverage and browser modules + +`studio coverage` emits the same registry-derived declared, visited, asserted, +passed, failed, skipped, and unavailable states embedded in the browser report, +along with adapter owner diagnostics. Legacy ownership is visible but is not an +authoring target. Browser helpers live in `src/web/`; Rust bundles them into the +single self-contained HTML artifact, while `node src/web/tests.mjs` exercises +their pure behavior without adding a JavaScript runtime to Maestro. + +Before proposing a story change, run the crate tests, the onboarding story +tests, the workbench tests, and the browser module tests. PR evidence then +validates the same versioned captures independently; a gallery render alone is +never proof that controller expectations passed. diff --git a/packages/ui-preview-rs/src/adapters.rs b/packages/ui-preview-rs/src/adapters.rs new file mode 100644 index 000000000..db3deb9bc --- /dev/null +++ b/packages/ui-preview-rs/src/adapters.rs @@ -0,0 +1,625 @@ +//! Compiled ownership manifests and transaction-like story promotion. +use crate::{ + authoring::{MenuRecipe, validate_id}, + schema::migrate_recipe, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + fs::OpenOptions, + io::Write, + path::{Component, Path, PathBuf}, +}; + +pub const REGISTRATION_START: &str = "// maestro-ui-stories:start"; +pub const REGISTRATION_END: &str = "// maestro-ui-stories:end"; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum StoryTemplate { + MenuRecipe, + ThemeSelector, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum StoryKind { + Static, + Replay, + Menu, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AdapterVerifier { + UiPreview, + OnboardingPreview, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "state", rename_all = "kebab-case")] +pub enum StoryLifecycle { + Active, + Deprecated { replacement: String }, + Retired { reason: String }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterManifest { + pub id: String, + pub owner: String, + pub fixture_dir: PathBuf, + pub registration_file: PathBuf, + pub template: StoryTemplate, + pub story_kinds: Vec, + pub profiles: Vec, + pub lifecycle: StoryLifecycle, + pub verifier: AdapterVerifier, + pub authoring: bool, +} +impl AdapterManifest { + #[must_use] + pub fn active( + id: &str, + owner: &str, + fixture_dir: impl Into, + registration_file: impl Into, + template: StoryTemplate, + ) -> Self { + Self { + id: id.into(), + owner: owner.into(), + fixture_dir: fixture_dir.into(), + registration_file: registration_file.into(), + template, + story_kinds: vec![StoryKind::Static, StoryKind::Replay], + profiles: vec!["pr-v1".into(), "scheduled-v1".into()], + lifecycle: StoryLifecycle::Active, + verifier: AdapterVerifier::UiPreview, + authoring: true, + } + } + pub fn validate(&self, repo_root: &Path) -> Result<(), String> { + validate_id(&self.id)?; + validate_id(&self.owner).map_err(|_| "adapter owner must be a stable ID".to_owned())?; + validate_repo_path(repo_root, &self.fixture_dir, "adapter fixture path")?; + validate_repo_path( + repo_root, + &self.registration_file, + "adapter registration path", + )?; + if self.story_kinds.is_empty() { + return Err("adapter needs a story kind".into()); + } + if self.profiles.is_empty() { + return Err("adapter needs a coverage profile".into()); + } + for profile in &self.profiles { + validate_id(profile)?; + if !matches!(profile.as_str(), "pr-v1" | "scheduled-v1") { + return Err(format!("unsupported coverage profile: {profile}")); + } + } + match &self.lifecycle { + StoryLifecycle::Deprecated { replacement } => validate_id(replacement)?, + StoryLifecycle::Retired { reason } if reason.trim().is_empty() => { + return Err("retired adapter needs a reason".into()); + } + StoryLifecycle::Active | StoryLifecycle::Retired { .. } => {} + } + Ok(()) + } +} + +fn validate_repo_path(root: &Path, path: &Path, label: &str) -> Result<(), String> { + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|part| { + matches!( + part, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(format!("{label} escapes repository")); + } + let canonical_root = + dunce::canonicalize(root).map_err(|error| format!("canonicalize repository: {error}"))?; + let mut existing = root.join(path); + while !existing.exists() { + if !existing.pop() || existing == root.parent().unwrap_or(root) { + return Err(format!("{label} escapes repository")); + } + } + let canonical_existing = + dunce::canonicalize(&existing).map_err(|error| format!("canonicalize {label}: {error}"))?; + if !canonical_existing.starts_with(&canonical_root) { + return Err(format!("{label} escapes repository")); + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub struct AdapterRegistry { + manifests: BTreeMap, +} +impl AdapterRegistry { + pub fn new(manifests: impl IntoIterator) -> Result { + let mut by_id = BTreeMap::new(); + for manifest in manifests { + let id = manifest.id.clone(); + if by_id.insert(id.clone(), manifest).is_some() { + return Err(format!("duplicate adapter: {id}")); + } + } + Ok(Self { manifests: by_id }) + } + pub fn builtins() -> Result { + Self::new([ + AdapterManifest { + authoring: false, + profiles: vec!["scheduled-v1".into()], + ..AdapterManifest::active( + "legacy", + "maestro-presentation", + "products/maestro/packages/ui-preview-rs/src", + "products/maestro/packages/ui-preview-rs/src/lib.rs", + StoryTemplate::ThemeSelector, + ) + }, + AdapterManifest { + story_kinds: vec![StoryKind::Static, StoryKind::Replay, StoryKind::Menu], + ..AdapterManifest::active( + "shared-menu", + "maestro-ui", + "products/maestro/packages/ui-preview-rs/src/stories", + "products/maestro/packages/ui-preview-rs/src/stories/mod.rs", + StoryTemplate::MenuRecipe, + ) + }, + AdapterManifest { + story_kinds: vec![StoryKind::Static, StoryKind::Replay], + verifier: AdapterVerifier::OnboardingPreview, + ..AdapterManifest::active( + "theme-selector", + "maestro-tui", + "products/maestro/packages/tui-rs/examples/support/ui_stories", + "products/maestro/packages/tui-rs/examples/support/ui_stories.rs", + StoryTemplate::ThemeSelector, + ) + }, + ]) + } + pub fn get(&self, id: &str) -> Result<&AdapterManifest, String> { + self.manifests + .get(id) + .ok_or_else(|| format!("unknown adapter: {id}")) + } + #[must_use] + pub fn manifests(&self) -> Vec<&AdapterManifest> { + self.manifests.values().collect() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct PromotionPlan { + pub adapter: String, + pub story_id: String, + pub fixture: PathBuf, + pub registration: PathBuf, + pub writes: Vec, +} + +pub fn plan_new( + root: &Path, + adapter: &AdapterManifest, + story_id: &str, +) -> Result { + if !adapter.authoring { + return Err(format!("adapter {} does not support authoring", adapter.id)); + } + adapter.validate(root)?; + validate_id(story_id)?; + let fixture = adapter + .fixture_dir + .join(format!("{}.rs", story_id.replace('-', "_"))); + validate_repo_path(root, &fixture, "story fixture path")?; + Ok(PromotionPlan { + adapter: adapter.id.clone(), + story_id: story_id.into(), + fixture: fixture.clone(), + registration: adapter.registration_file.clone(), + writes: vec![fixture, adapter.registration_file.clone()], + }) +} + +fn template_source(adapter: &AdapterManifest, story_id: &str) -> Result { + match adapter.template { + StoryTemplate::MenuRecipe => { + let mut recipe = MenuRecipe::starter(); + recipe.id = story_id.into(); + recipe.label = story_id.replace('-', " "); + recipe.fixture_source_for_adapter(&adapter.id) + } + StoryTemplate::ThemeSelector => Ok(format!( + r#"//! Generated ThemeSelector fixture using the production controller and renderer. +use maestro_tui::components::ThemeSelector; +use maestro_ui_preview::{{Scene, review::{{self, Capture}}}}; + +pub fn captures() -> Result, String> {{ + let mut selector = ThemeSelector::with_themes(vec!["auto".into(), "dark".into(), "light".into()]) + .map_err(|error| error.to_string())?; + selector.show(); + let scene = Scene {{ id: {story_id:?}.into(), label: {story_id:?}.replace('-', " "), width: 72, height: 22, time_ms: 0 }}; + let mut capture = review::capture(scene, |frame| selector.render(frame, frame.area()))?; + capture.source = file!().into(); + Ok(vec![capture]) +}} +"# + )), + } +} + +fn registration_with_story(before: &str, module: &str) -> Result { + let start = before + .find(REGISTRATION_START) + .ok_or("registration file is missing start marker")?; + let end = before + .find(REGISTRATION_END) + .ok_or("registration file is missing end marker")?; + if start >= end { + return Err("registration markers are out of order".into()); + } + let body_start = start + REGISTRATION_START.len(); + let body = &before[body_start..end]; + let line = format!("\n {module},\n"); + if body + .lines() + .any(|existing| existing.trim() == format!("{module},")) + { + return Err(format!("story is already registered: {module}")); + } + Ok(format!( + "{}{}{}{}", + &before[..body_start], + body, + line, + &before[end..] + )) +} + +fn registration_without_story(current: &str, module: &str) -> Result { + let owned = format!(" {module},"); + let mut removed = false; + let mut out = String::with_capacity(current.len()); + for line in current.lines() { + if !removed && line == owned { + removed = true; + continue; + } + out.push_str(line); + out.push('\n'); + } + if removed { + Ok(out) + } else { + Err(format!( + "story registration disappeared during rollback: {module}" + )) + } +} + +fn create_new_file(path: &Path, source: &str) -> Result<(), String> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| format!("create {}: {error}", path.display()))?; + file.write_all(source.as_bytes()) + .map_err(|error| error.to_string()) +} + +fn write_registration_if_unchanged(path: &Path, before: &str, after: &str) -> Result<(), String> { + let current = fs::read_to_string(path).map_err(|error| error.to_string())?; + if current != before { + return Err("registration changed concurrently".into()); + } + fs::write(path, after).map_err(|error| error.to_string()) +} + +pub fn create_story( + root: &Path, + adapter: &AdapterManifest, + story_id: &str, + check: bool, +) -> Result { + let plan = plan_new(root, adapter, story_id)?; + let fixture = root.join(&plan.fixture); + let registration = root.join(&plan.registration); + if fixture.exists() { + return Err(format!( + "story fixture already exists: {}", + plan.fixture.display() + )); + } + let before = fs::read_to_string(®istration) + .map_err(|error| format!("read {}: {error}", plan.registration.display()))?; + let module = story_id.replace('-', "_"); + let after = registration_with_story(&before, &module)?; + let source = template_source(adapter, story_id)?; + if check { + return Ok(plan); + } + if let Some(parent) = fixture.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + create_new_file(&fixture, &source)?; + if let Err(error) = write_registration_if_unchanged(®istration, &before, &after) { + let _ = fs::remove_file(&fixture); + return Err(error.to_string()); + } + Ok(plan) +} + +pub fn promote_recipe( + root: &Path, + adapter: &AdapterManifest, + recipe_path: &Path, + check: bool, + verify: impl FnOnce(&PromotionPlan) -> Result<(), String>, +) -> Result { + validate_repo_path(root, recipe_path, "recipe path")?; + let bytes = fs::read(root.join(recipe_path)).map_err(|error| error.to_string())?; + let value = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + let recipe: MenuRecipe = migrate_recipe(value) + .map_err(|error| error.to_string())? + .value; + recipe.validate()?; + let plan = plan_new(root, adapter, &recipe.id)?; + let fixture = root.join(&plan.fixture); + let registration = root.join(&plan.registration); + if fixture.exists() { + return Err(format!( + "story fixture already exists: {}", + plan.fixture.display() + )); + } + let before = fs::read_to_string(®istration).map_err(|error| error.to_string())?; + let after = registration_with_story(&before, &recipe.id.replace('-', "_"))?; + let source = recipe.fixture_source_for_adapter(&adapter.id)?; + if check { + return Ok(plan); + } + if let Some(parent) = fixture.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + create_new_file(&fixture, &source)?; + if let Err(error) = write_registration_if_unchanged(®istration, &before, &after) { + let _ = fs::remove_file(&fixture); + return Err(error.to_string()); + } + if let Err(error) = verify(&plan) { + let _ = fs::remove_file(&fixture); + let current = fs::read_to_string(®istration) + .map_err(|restore| format!("{error}; read for restore failed: {restore}"))?; + let restored = registration_without_story(¤t, &recipe.id.replace('-', "_")) + .map_err(|restore| format!("{error}; restore failed: {restore}"))?; + fs::write(®istration, restored) + .map_err(|restore| format!("{error}; restore failed: {restore}"))?; + return Err(error); + } + Ok(plan) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + static NEXT: AtomicUsize = AtomicUsize::new(0); + fn temp_root() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "maestro-adapter-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(root.join("fixtures")).unwrap(); + fs::write( + root.join("register.rs"), + format!("before\n{REGISTRATION_START}\n{REGISTRATION_END}\nafter\n"), + ) + .unwrap(); + root + } + fn fixture_adapter(id: &str) -> AdapterManifest { + AdapterManifest::active( + id, + "maestro-ui", + "fixtures", + "register.rs", + StoryTemplate::MenuRecipe, + ) + } + #[test] + fn adapter_registry_rejects_ambiguous_and_escaping_paths() { + assert_eq!( + AdapterRegistry::new([fixture_adapter("menu"), fixture_adapter("menu")]).unwrap_err(), + "duplicate adapter: menu" + ); + let mut invalid = fixture_adapter("menu"); + invalid.fixture_dir = PathBuf::from("../outside"); + assert_eq!( + invalid.validate(Path::new("repo")).unwrap_err(), + "adapter fixture path escapes repository" + ); + } + #[test] + fn create_is_dry_runnable_and_never_overwrites() { + let root = temp_root(); + let adapter = fixture_adapter("menu"); + let plan = create_story(&root, &adapter, "workspace-picker", true).unwrap(); + assert!(!root.join(&plan.fixture).exists()); + create_story(&root, &adapter, "workspace-picker", false).unwrap(); + assert!(root.join(&plan.fixture).exists()); + assert!( + fs::read_to_string(root.join("register.rs")) + .unwrap() + .contains("workspace_picker,") + ); + assert!( + fs::read_to_string(root.join(&plan.fixture)) + .unwrap() + .contains("pub fn register(registry: &mut Registry)") + ); + assert!(create_story(&root, &adapter, "workspace-picker", false).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn promotion_restores_owned_writes_when_verification_fails() { + let root = temp_root(); + let adapter = fixture_adapter("menu"); + let recipe = MenuRecipe::starter(); + let recipe_path = PathBuf::from("recipe.json"); + fs::write( + root.join(&recipe_path), + serde_json::to_vec(&recipe).unwrap(), + ) + .unwrap(); + let registration = root.join("register.rs"); + let error = promote_recipe(&root, &adapter, &recipe_path, false, |_| { + let mut current = fs::read_to_string(®istration).unwrap(); + current.push_str("concurrent unrelated edit\n"); + fs::write(®istration, current).unwrap(); + Err("focused verification failed".into()) + }) + .unwrap_err(); + assert_eq!(error, "focused verification failed"); + assert!(!root.join("fixtures/new_menu.rs").exists()); + let after = fs::read_to_string(root.join("register.rs")).unwrap(); + assert!(after.contains("before\n")); + assert!(after.contains("after\n")); + assert!(after.ends_with("concurrent unrelated edit\n")); + assert!(!after.contains("new_menu,")); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn promotion_validates_before_mutation_and_preserves_unrelated_registration() { + let root = temp_root(); + let adapter = fixture_adapter("menu"); + let recipe_path = PathBuf::from("recipe.json"); + let before = fs::read_to_string(root.join("register.rs")).unwrap(); + fs::write(root.join(&recipe_path), b"{\"version\":99}").unwrap(); + assert!(promote_recipe(&root, &adapter, &recipe_path, false, |_| Ok(())).is_err()); + assert_eq!( + fs::read_to_string(root.join("register.rs")).unwrap(), + before + ); + assert!(!root.join("fixtures/new_menu.rs").exists()); + + let recipe = MenuRecipe::starter(); + fs::write( + root.join(&recipe_path), + serde_json::to_vec(&recipe).unwrap(), + ) + .unwrap(); + let plan = promote_recipe(&root, &adapter, &recipe_path, false, |_| Ok(())).unwrap(); + assert!(root.join(plan.fixture).exists()); + let after = fs::read_to_string(root.join("register.rs")).unwrap(); + assert!(after.starts_with("before\n")); + assert!(after.ends_with("after\n")); + assert!(after.contains("new_menu,")); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn profiles_are_unique_and_bounded() { + let registry = AdapterRegistry::builtins().unwrap(); + let ids: std::collections::BTreeSet<_> = registry + .manifests() + .iter() + .map(|manifest| manifest.id.as_str()) + .collect(); + assert_eq!( + ids, + std::collections::BTreeSet::from(["legacy", "shared-menu", "theme-selector"]) + ); + assert!(!registry.get("legacy").unwrap().authoring); + assert!( + plan_new(Path::new("."), registry.get("legacy").unwrap(), "new-story") + .unwrap_err() + .contains("does not support authoring") + ); + let root = temp_root(); + let mut invalid = fixture_adapter("menu"); + invalid.profiles = vec!["unknown-profile".into()]; + assert!(invalid.validate(&root).unwrap_err().contains("unsupported")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn typed_verifier_and_theme_template_are_adapter_owned() { + let registry = AdapterRegistry::builtins().unwrap(); + let theme = registry.get("theme-selector").unwrap(); + assert_eq!(theme.verifier, AdapterVerifier::OnboardingPreview); + assert_eq!( + theme.fixture_dir, + PathBuf::from("products/maestro/packages/tui-rs/examples/support/ui_stories") + ); + let source = template_source(theme, "workspace-picker").unwrap(); + assert!(source.contains("ThemeSelector::with_themes")); + assert!(source.contains("pub fn captures()")); + } + + #[test] + fn create_new_file_closes_the_overwrite_race() { + let root = temp_root(); + let path = root.join("fixtures/existing.rs"); + fs::write(&path, "owned by another writer").unwrap(); + assert!(create_new_file(&path, "replacement").is_err()); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "owned by another writer" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn conditional_registration_write_refuses_concurrent_edits() { + let root = temp_root(); + let path = root.join("register.rs"); + let before = fs::read_to_string(&path).unwrap(); + fs::write(&path, format!("{before}concurrent\n")).unwrap(); + assert_eq!( + write_registration_if_unchanged(&path, &before, "replacement").unwrap_err(), + "registration changed concurrently" + ); + assert!(fs::read_to_string(&path).unwrap().ends_with("concurrent\n")); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn canonical_path_validation_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + let root = temp_root(); + let outside = std::env::temp_dir().join(format!("maestro-outside-{}", std::process::id())); + let _ = fs::remove_dir_all(&outside); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, root.join("linked")).unwrap(); + let mut adapter = fixture_adapter("menu"); + adapter.fixture_dir = PathBuf::from("linked"); + assert_eq!( + adapter.validate(&root).unwrap_err(), + "adapter fixture path escapes repository" + ); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(outside).unwrap(); + } +} diff --git a/packages/ui-preview-rs/src/authoring.rs b/packages/ui-preview-rs/src/authoring.rs index d2338e516..b0c51ebdc 100644 --- a/packages/ui-preview-rs/src/authoring.rs +++ b/packages/ui-preview-rs/src/authoring.rs @@ -304,7 +304,12 @@ impl MenuRecipe { } pub fn fixture_source(&self) -> Result { + self.fixture_source_for_adapter("shared-menu") + } + + pub fn fixture_source_for_adapter(&self, adapter: &str) -> Result { self.validate()?; + validate_id(adapter)?; let state = match self.state { MenuRecipeState::Ready => "Ready", MenuRecipeState::Empty => "Empty", @@ -361,7 +366,7 @@ pub fn recipe() -> MenuRecipe {{ /// Add this recipe to the ordinary capture and PR-evidence registry. pub fn register(registry: &mut Registry) -> Result<(), String> {{ - registry.add(recipe().story(file!())?) + registry.add(recipe().story(file!())?.adapter({adapter:?})) }} pub fn main() -> Result<(), String> {{ @@ -379,6 +384,7 @@ pub fn main() -> Result<(), String> {{ status_message = self.status_message, width = self.width, height = self.height, + adapter = adapter, )) } } @@ -770,7 +776,11 @@ pub fn register_menu_recipes(registry: &mut Registry) -> Result<(), String> { .into_iter() .chain(menu_recipe_interactions()) { - registry.add(recipe.story("products/maestro/packages/ui-preview-rs/src/authoring.rs")?)?; + registry.add( + recipe + .story("products/maestro/packages/ui-preview-rs/src/authoring.rs")? + .adapter("shared-menu"), + )?; } Ok(()) } @@ -924,6 +934,7 @@ mod tests { .fixture_source .contains("pub fn register(registry: &mut Registry)") ); + assert!(first.fixture_source.contains(".adapter(\"shared-menu\")")); } #[test] diff --git a/packages/ui-preview-rs/src/contract.rs b/packages/ui-preview-rs/src/contract.rs new file mode 100644 index 000000000..2006ffadc --- /dev/null +++ b/packages/ui-preview-rs/src/contract.rs @@ -0,0 +1,440 @@ +//! Bounded, serializable behavior contracts for deterministic UI stories. +use crate::authoring::validate_id; +use serde::{Deserialize, Serialize, Serializer, ser::SerializeMap}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const STORY_CONTRACT_VERSION: u8 = 1; +pub const MAX_OBSERVATIONS: usize = 32; +pub const MAX_EFFECTS: usize = 32; +pub const MAX_OBSERVATION_BYTES: usize = 512; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum StoryObservation { + Text { + name: String, + value: String, + hidden: bool, + }, + Id { + name: String, + value: String, + }, + Flag { + name: String, + value: bool, + }, +} + +impl Serialize for StoryObservation { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Text { + name, + value, + hidden, + } => { + let mut state = serializer.serialize_map(Some(4))?; + state.serialize_entry("kind", "text")?; + state.serialize_entry("name", name)?; + state.serialize_entry("value", if *hidden { "[hidden]" } else { value })?; + state.serialize_entry("hidden", hidden)?; + state.end() + } + Self::Id { name, value } => { + let mut state = serializer.serialize_map(Some(3))?; + state.serialize_entry("kind", "id")?; + state.serialize_entry("name", name)?; + state.serialize_entry("value", value)?; + state.end() + } + Self::Flag { name, value } => { + let mut state = serializer.serialize_map(Some(3))?; + state.serialize_entry("kind", "flag")?; + state.serialize_entry("name", name)?; + state.serialize_entry("value", value)?; + state.end() + } + } + } +} + +impl StoryObservation { + #[must_use] + pub fn text(name: impl Into, value: impl Into) -> Self { + Self::Text { + name: name.into(), + value: value.into(), + hidden: false, + } + } + #[must_use] + pub fn hidden_text(name: impl Into, value: impl Into) -> Self { + Self::Text { + name: name.into(), + value: value.into(), + hidden: true, + } + } + #[must_use] + pub fn id(name: impl Into, value: impl Into) -> Self { + Self::Id { + name: name.into(), + value: value.into(), + } + } + #[must_use] + pub fn flag(name: impl Into, value: bool) -> Self { + Self::Flag { + name: name.into(), + value, + } + } + #[must_use] + pub fn name(&self) -> &str { + match self { + Self::Text { name, .. } | Self::Id { name, .. } | Self::Flag { name, .. } => name, + } + } + #[must_use] + pub fn display_value(&self) -> String { + match self { + Self::Text { value, hidden, .. } => { + if *hidden { + "[hidden]".into() + } else { + value.clone() + } + } + Self::Id { value, .. } => value.clone(), + Self::Flag { value, .. } => value.to_string(), + } + } + pub fn validate(&self) -> Result<(), String> { + validate_id(self.name()).map_err(|_| "observation name must be a stable ID".to_owned())?; + let value_bytes = match self { + Self::Text { value, .. } | Self::Id { value, .. } => value.len(), + Self::Flag { .. } => 0, + }; + if value_bytes > MAX_OBSERVATION_BYTES { + return Err(format!( + "observation value exceeds {MAX_OBSERVATION_BYTES} bytes" + )); + } + if let Self::Id { value, .. } = self { + validate_id(value) + .map_err(|_| "observation ID value must be a stable ID".to_owned())?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StoryEffect { + pub id: String, +} +impl StoryEffect { + #[must_use] + pub fn new(id: impl Into) -> Self { + Self { id: id.into() } + } + pub fn validate(&self) -> Result<(), String> { + validate_id(&self.id) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum StoryExpectation { + ObservationEquals { name: String, value: String }, + EffectCount { id: String, count: u16 }, + NoUnexpectedEffects { allowed: Vec }, +} +impl StoryExpectation { + #[must_use] + pub fn observation(name: impl Into, value: impl Into) -> Self { + Self::ObservationEquals { + name: name.into(), + value: value.into(), + } + } + #[must_use] + pub fn effect_count(id: impl Into, count: u16) -> Self { + Self::EffectCount { + id: id.into(), + count, + } + } + #[must_use] + pub fn no_unexpected_effects(allowed: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::NoUnexpectedEffects { + allowed: allowed.into_iter().map(Into::into).collect(), + } + } + fn key(&self) -> String { + match self { + Self::ObservationEquals { name, .. } => format!("observation:{name}"), + Self::EffectCount { id, .. } => format!("effect:{id}"), + Self::NoUnexpectedEffects { .. } => "effects:allowed".into(), + } + } + fn validate(&self) -> Result<(), String> { + match self { + Self::ObservationEquals { name, value } => { + validate_id(name)?; + if value.len() > MAX_OBSERVATION_BYTES { + return Err("expectation value is too large".into()); + } + } + Self::EffectCount { id, count } => { + validate_id(id)?; + if usize::from(*count) > MAX_EFFECTS { + return Err("expected effect count is too large".into()); + } + } + Self::NoUnexpectedEffects { allowed } => { + if allowed.len() > MAX_EFFECTS { + return Err("too many allowed effects".into()); + } + let mut seen = BTreeSet::new(); + for id in allowed { + validate_id(id)?; + if !seen.insert(id) { + return Err(format!("duplicate allowed effect: {id}")); + } + } + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StoryContract { + pub version: u8, + pub story_id: String, + pub owner: String, + pub expectations: Vec, +} +impl StoryContract { + #[must_use] + pub fn new(story_id: impl Into, owner: impl Into) -> Self { + Self { + version: STORY_CONTRACT_VERSION, + story_id: story_id.into(), + owner: owner.into(), + expectations: Vec::new(), + } + } + #[must_use] + pub fn expect(mut self, expectation: StoryExpectation) -> Self { + self.expectations.push(expectation); + self + } + pub fn validate(&self) -> Result<(), String> { + if self.version != STORY_CONTRACT_VERSION { + return Err(format!( + "unsupported story contract version: {}", + self.version + )); + } + validate_id(&self.story_id)?; + validate_id(&self.owner) + .map_err(|_| "story contract owner must be a stable ID".to_owned())?; + if self.expectations.len() > MAX_OBSERVATIONS + MAX_EFFECTS { + return Err("too many story expectations".into()); + } + let mut keys = BTreeSet::new(); + for expectation in &self.expectations { + expectation.validate()?; + let key = expectation.key(); + if !keys.insert(key.clone()) { + return Err(format!("duplicate expectation: {key}")); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectationResult { + pub expectation: StoryExpectation, + pub passed: bool, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StoryStepResult { + pub schema_version: u8, + pub step: usize, + pub observations: Vec, + pub effects: Vec, + pub expectations: Vec, +} +impl StoryStepResult { + #[must_use] + pub fn passed(&self) -> bool { + self.expectations.iter().all(|result| result.passed) + } + #[must_use] + pub fn failures(&self) -> Vec { + self.expectations + .iter() + .filter(|r| !r.passed) + .map(|r| r.message.clone()) + .collect() + } +} + +pub fn evaluate_step( + step: usize, + observations: Vec, + effects: Vec, + expectations: &[StoryExpectation], +) -> Result { + if observations.len() > MAX_OBSERVATIONS { + return Err(format!( + "story step exceeds {MAX_OBSERVATIONS} observations" + )); + } + if effects.len() > MAX_EFFECTS { + return Err(format!("story step exceeds {MAX_EFFECTS} effects")); + } + let mut names = BTreeSet::new(); + for observation in &observations { + observation.validate()?; + if !names.insert(observation.name()) { + return Err(format!("duplicate observation: {}", observation.name())); + } + } + for effect in &effects { + effect.validate()?; + } + let values: BTreeMap<_, _> = observations + .iter() + .map(|o| (o.name(), o.display_value())) + .collect(); + let counts = effects + .iter() + .fold(BTreeMap::<&str, usize>::new(), |mut out, effect| { + *out.entry(&effect.id).or_default() += 1; + out + }); + let mut results = Vec::with_capacity(expectations.len()); + for expectation in expectations { + expectation.validate()?; + let (passed, message) = match expectation { + StoryExpectation::ObservationEquals { name, value } => { + let actual = values.get(name.as_str()).map(String::as_str); + ( + actual == Some(value), + format!( + "expected {name} to equal {value}; observed {}", + actual.unwrap_or("") + ), + ) + } + StoryExpectation::EffectCount { id, count } => { + let actual = *counts.get(id.as_str()).unwrap_or(&0); + ( + actual == usize::from(*count), + format!( + "expected {id} exactly {count} time{}; observed {actual}", + if *count == 1 { "" } else { "s" } + ), + ) + } + StoryExpectation::NoUnexpectedEffects { allowed } => { + let unexpected: Vec<_> = effects + .iter() + .filter(|effect| !allowed.contains(&effect.id)) + .map(|effect| effect.id.clone()) + .collect(); + ( + unexpected.is_empty(), + if unexpected.is_empty() { + "observed only allowed effects".into() + } else { + format!("unexpected effects: {}", unexpected.join(", ")) + }, + ) + } + }; + results.push(ExpectationResult { + expectation: expectation.clone(), + passed, + message, + }); + } + Ok(StoryStepResult { + schema_version: STORY_CONTRACT_VERSION, + step, + observations, + effects, + expectations: results, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn contract_rejects_duplicate_names_and_unbounded_values() { + let duplicate = StoryContract::new("theme-selector", "maestro-tui") + .expect(StoryExpectation::observation("state", "error")) + .expect(StoryExpectation::observation("state", "ready")); + assert_eq!( + duplicate.validate().unwrap_err(), + "duplicate expectation: observation:state" + ); + assert!( + StoryObservation::text("message", "x".repeat(MAX_OBSERVATION_BYTES + 1)) + .validate() + .is_err() + ); + } + #[test] + fn replay_emits_result_and_fails_wrong_effect_count() { + let result = evaluate_step( + 1, + vec![StoryObservation::id("state", "ready")], + vec![StoryEffect::new("retry-themes")], + &[StoryExpectation::effect_count("retry-themes", 1)], + ) + .unwrap(); + assert!(result.passed()); + assert_eq!(result.step, 1); + let failed = evaluate_step( + 1, + vec![], + vec![], + &[StoryExpectation::effect_count("retry-themes", 1)], + ) + .unwrap(); + assert_eq!( + failed.failures(), + ["expected retry-themes exactly 1 time; observed 0"] + ); + } + #[test] + fn hidden_observations_serialize_redacted() { + let json = + serde_json::to_string(&StoryObservation::hidden_text("query", "secret glyph")).unwrap(); + assert!(!json.contains("secret glyph")); + assert!(json.contains("[hidden]")); + assert!(json.contains("\"kind\":\"text\"")); + let decoded: StoryObservation = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.display_value(), "[hidden]"); + } +} diff --git a/packages/ui-preview-rs/src/lib.rs b/packages/ui-preview-rs/src/lib.rs index 4b8be42f5..8680ab03c 100644 --- a/packages/ui-preview-rs/src/lib.rs +++ b/packages/ui-preview-rs/src/lib.rs @@ -1,9 +1,15 @@ //! Deterministic component scenes using the widgets linked by the native TUI. +extern crate self as maestro_ui_preview; + +pub mod adapters; pub mod authoring; +pub mod contract; mod conversation; mod menus; pub mod registry; pub mod review; +pub mod schema; +mod stories; use maestro_presentation::{ appearance::{Appearance, LOOKS}, @@ -20,6 +26,13 @@ use ratatui::{prelude::*, widgets::Paragraph}; use serde::Serialize; use std::time::Duration; +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", content = "value", rename_all = "kebab-case")] +pub enum StoryFilter { + Story(String), + Adapter(String), +} + #[derive(Clone, Debug, Serialize)] pub struct Scene { pub id: String, @@ -281,7 +294,7 @@ pub fn registry() -> Result { "products/maestro/packages/ui-preview-rs/src/lib.rs", legacy_render, )?; - menus::register(&mut registry)?; + stories::register(&mut registry)?; authoring::register_menu_recipes(&mut registry)?; Ok(registry) } @@ -295,6 +308,12 @@ pub fn render(scene: &Scene) -> Result { #[cfg(test)] mod tests { use super::*; + #[test] + fn generated_story_modules_can_use_the_public_crate_name() { + let registry = maestro_ui_preview::registry::Registry::default(); + assert!(registry.scenes().is_empty()); + } + #[test] fn catalog_renders_and_all_appearance_ids_are_present() { for scene in catalog() { diff --git a/packages/ui-preview-rs/src/main.rs b/packages/ui-preview-rs/src/main.rs index 13204892d..889f9ebd5 100644 --- a/packages/ui-preview-rs/src/main.rs +++ b/packages/ui-preview-rs/src/main.rs @@ -1,8 +1,268 @@ //! Headless component rendering; no terminal session or agent runtime. -use maestro_ui_preview::{Scene, ansi, catalog, render}; -use std::path::PathBuf; +use maestro_ui_preview::{ + Scene, StoryFilter, + adapters::{AdapterRegistry, AdapterVerifier, PromotionPlan, create_story, promote_recipe}, + ansi, catalog, render, +}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +fn repo_root() -> Result { + let current = std::env::current_dir().map_err(|error| error.to_string())?; + for directory in current.ancestors() { + if directory.join("products/maestro").is_dir() { + return Ok(directory.to_path_buf()); + } + } + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .find(|directory| directory.join("products/maestro").is_dir()) + .map(Path::to_path_buf) + .ok_or("run Studio inside the Mono repository".into()) +} + +fn verify_adapter( + root: &Path, + verifier: &AdapterVerifier, + plan: Option<&PromotionPlan>, +) -> Result<(), String> { + if let Some(plan) = plan { + let status = Command::new("rustfmt") + .current_dir(root) + .args(["--edition", "2024"]) + .arg(root.join(&plan.fixture)) + .status() + .map_err(|error| format!("start rustfmt: {error}"))?; + if !status.success() { + return Err("generated fixture formatting failed".into()); + } + } + let maestro = root.join("products/maestro"); + let mut cargo = Command::new("cargo"); + cargo.current_dir(maestro).args(["test", "--locked"]); + match verifier { + AdapterVerifier::UiPreview => { + cargo.args(["-p", "maestro-ui-preview"]); + } + AdapterVerifier::OnboardingPreview => { + cargo.args(["-p", "maestro-tui", "--example", "onboarding-preview"]); + } + } + let status = cargo + .status() + .map_err(|error| format!("start focused verifier: {error}"))?; + if status.success() { + Ok(()) + } else { + Err("focused behavior verification failed".into()) + } +} + +fn onboarding_story(root: &Path, story_id: &str) -> Result>, String> { + let output = Command::new("cargo") + .current_dir(root.join("products/maestro")) + .args([ + "run", + "--quiet", + "--locked", + "-p", + "maestro-tui", + "--example", + "onboarding-preview", + "--", + "--json", + ]) + .output() + .map_err(|error| format!("start onboarding verifier: {error}"))?; + if !output.status.success() { + return Err(format!( + "onboarding verifier failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let captures: Vec = + serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?; + validate_onboarding_captures(captures, story_id) +} + +fn validate_onboarding_captures( + captures: Vec, + story_id: &str, +) -> Result>, String> { + let matching: Vec<_> = captures + .into_iter() + .filter(|capture| { + capture + .pointer("/scene/id") + .and_then(serde_json::Value::as_str) + == Some(story_id) + }) + .collect(); + if matching.is_empty() { + return Ok(None); + } + let failures: Vec<_> = matching + .iter() + .flat_map(|capture| { + capture + .pointer("/semantic/expectations") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + }) + .filter(|expectation| { + expectation + .get("passed") + .and_then(serde_json::Value::as_bool) + == Some(false) + }) + .filter_map(|expectation| { + expectation + .get("message") + .and_then(serde_json::Value::as_str) + }) + .collect(); + if !failures.is_empty() { + return Err(format!( + "story expectations failed: {}", + failures.join("; ") + )); + } + Ok(Some(matching)) +} + +fn studio(args: &[String]) -> Result<(), String> { + let (command, tail) = args + .split_first() + .ok_or("usage: studio ")?; + if command == "manifests" { + if !tail.is_empty() { + return Err("usage: studio manifests".into()); + } + println!( + "{}", + serde_json::to_string_pretty(&AdapterRegistry::builtins()?.manifests()) + .map_err(|error| error.to_string())? + ); + return Ok(()); + } + if command == "coverage" { + if !tail.is_empty() { + return Err("usage: studio coverage".into()); + } + let registry = maestro_ui_preview::registry()?; + let adapters = AdapterRegistry::builtins()?; + let profiles = maestro_ui_preview::schema::builtin_profiles(); + let coverage = profiles + .iter() + .map(|profile| registry.coverage_for_profile(&adapters, profile)) + .collect::, _>>()?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "coverage": coverage, + "contributions": registry.contribution_diagnostics(&adapters)?, + })) + .map_err(|error| error.to_string())? + ); + return Ok(()); + } + if !matches!(command.as_str(), "new" | "verify" | "promote") { + return Err(format!("unknown studio command: {command}")); + } + if command == "verify" { + if tail.len() != 1 { + return Err("usage: studio verify ".into()); + } + let story_id = &tail[0]; + if story_id.starts_with('-') { + return Err(format!("unknown argument: {story_id}")); + } + let captures: Vec<_> = maestro_ui_preview::registry()? + .results()? + .into_iter() + .filter(|capture| capture.scene.id == *story_id) + .collect(); + if captures.is_empty() { + let root = repo_root()?; + if let Some(capture) = onboarding_story(&root, story_id)? { + println!( + "{}", + serde_json::to_string_pretty(&capture).map_err(|error| error.to_string())? + ); + return Ok(()); + } + return Err(format!("unknown story: {story_id}")); + } + let failures: Vec<_> = captures + .iter() + .filter_map(|capture| capture.semantic.as_ref()) + .flat_map(|result| result.failures()) + .collect(); + if !failures.is_empty() { + return Err(format!( + "story expectations failed: {}", + failures.join("; ") + )); + } + println!( + "{}", + serde_json::to_string_pretty(&captures).map_err(|error| error.to_string())? + ); + return Ok(()); + } + let mut adapter_id = None; + let mut positional = None; + let mut check = false; + let mut index = 0; + while index < tail.len() { + match tail[index].as_str() { + "--adapter" => { + if adapter_id.is_some() { + return Err("--adapter may be specified once".into()); + } + index += 1; + adapter_id = Some( + tail.get(index) + .ok_or("missing value for --adapter")? + .clone(), + ); + } + "--check" => check = true, + arg if arg.starts_with('-') => return Err(format!("unknown argument: {arg}")), + arg if positional.is_none() => positional = Some(arg.to_owned()), + _ => return Err("studio accepts exactly one story or recipe path".into()), + } + index += 1; + } + let adapter_id = adapter_id.ok_or("studio command requires --adapter")?; + let value = + positional.ok_or_else(|| format!("studio {command} requires a story or recipe path"))?; + let adapters = AdapterRegistry::builtins()?; + let adapter = adapters.get(&adapter_id)?; + let root = repo_root()?; + let plan = if command == "new" { + create_story(&root, adapter, &value, check)? + } else { + promote_recipe(&root, adapter, Path::new(&value), check, |plan| { + verify_adapter(&root, &adapter.verifier, Some(plan)) + })? + }; + println!( + "{}", + serde_json::to_string_pretty(&plan).map_err(|error| error.to_string())? + ); + Ok(()) +} fn run() -> Result<(), String> { - let mut args = std::env::args().skip(1); + let raw: Vec<_> = std::env::args().skip(1).collect(); + if raw.first().is_some_and(|arg| arg == "studio") { + return studio(&raw[1..]); + } + let mut args = raw.into_iter(); let mut scene = Scene { id: "startup".into(), label: String::new(), @@ -16,6 +276,9 @@ fn run() -> Result<(), String> { let mut identity = false; let mut scaffold = None; let mut output = None; + let mut filter = None; + let mut profile_id = None; + let mut sequences = false; while let Some(arg) = args.next() { if arg == "--html" || arg == "--json" { format = arg.trim_start_matches("--").into(); @@ -29,6 +292,31 @@ fn run() -> Result<(), String> { list = true; continue; } + if arg == "--sequences" { + sequences = true; + continue; + } + if arg == "--story" || arg == "--adapter" { + if filter.is_some() { + return Err("only one --story or --adapter filter is allowed".into()); + } + let value = args + .next() + .ok_or_else(|| format!("missing value for {arg}"))?; + filter = Some(if arg == "--story" { + StoryFilter::Story(value) + } else { + StoryFilter::Adapter(value) + }); + continue; + } + if arg == "--profile" { + if profile_id.is_some() { + return Err("--profile may be specified once".into()); + } + profile_id = Some(args.next().ok_or("missing value for --profile")?); + continue; + } if arg == "--scaffold" { scaffold = Some(args.next().ok_or("missing value for --scaffold")?); continue; @@ -52,7 +340,7 @@ fn run() -> Result<(), String> { } } if let Some(scaffold) = scaffold { - if selectors || list || identity || format != "ansi" { + if selectors || list || sequences || identity || filter.is_some() || format != "ansi" { return Err("--scaffold cannot be combined with rendering options".into()); } let output = output.ok_or("--scaffold requires --output")?; @@ -63,20 +351,73 @@ fn run() -> Result<(), String> { if output.is_some() { return Err("--output requires --scaffold".into()); } - if format != "ansi" && (selectors || list || identity) { + if profile_id.is_some() + && (format == "ansi" || list || sequences || identity || filter.is_some()) + { + return Err("--profile requires an unfiltered --html or --json export".into()); + } + if format != "ansi" && (selectors || list || sequences || identity) { return Err("--html and --json export the full catalog; use them without scene selectors, --list, or --identity".into()); } + if filter.is_some() && format == "ansi" && !list && !sequences { + return Err("--story and --adapter require --html, --json, --list, or --sequences".into()); + } + let registry = maestro_ui_preview::registry()?; if identity { + if filter.is_some() { + return Err("filters cannot be combined with --identity".into()); + } println!("{}", env!("MAESTRO_PREVIEW_SOURCE_DIGEST")); } else if list { - println!( - "{}", - serde_json::to_string_pretty(&catalog()).map_err(|e| e.to_string())? - ); + if let Some(filter) = filter { + let selected = registry.filtered(filter.clone())?; + println!( + "{}", + serde_json::to_string_pretty( + &serde_json::json!({"filter": filter, "scenes": selected.scenes()}) + ) + .map_err(|e| e.to_string())? + ); + } else { + println!( + "{}", + serde_json::to_string_pretty(&catalog()).map_err(|e| e.to_string())? + ); + } + } else if sequences { + if let Some(filter) = filter { + let selected = registry.filtered(filter.clone())?; + println!( + "{}", + serde_json::to_string_pretty( + &serde_json::json!({"filter": filter, "sequences": selected.sequences()}) + ) + .map_err(|e| e.to_string())? + ); + } else { + println!( + "{}", + serde_json::to_string_pretty(®istry.sequences()).map_err(|e| e.to_string())? + ); + } } else if format != "ansi" { - let captures = maestro_ui_preview::registry()?.captures()?; + let adapters = AdapterRegistry::builtins()?; + let profile = profile_id + .as_deref() + .map(maestro_ui_preview::schema::builtin_profile) + .transpose()?; + let captures = if let Some(profile) = &profile { + registry.results_for_profile(&adapters, profile)? + } else if let Some(filter) = filter { + registry.filtered(filter)?.captures()? + } else { + registry.captures()? + }; let output = if format == "html" { - maestro_ui_preview::review::html(&captures)? + let profile = profile + .unwrap_or_else(|| maestro_ui_preview::schema::builtin_profiles()[0].clone()); + let coverage = registry.coverage_for_profile(&adapters, &profile)?; + maestro_ui_preview::review::html_with_coverage(&captures, &coverage)? } else { maestro_ui_preview::review::json(&captures)? }; @@ -92,3 +433,20 @@ fn main() { std::process::exit(2); } } + +#[cfg(test)] +mod studio_tests { + use super::*; + + #[test] + fn controller_capture_verification_rejects_failed_expectations() { + let captures = vec![serde_json::json!({ + "scene": {"id": "theme-selector-interaction"}, + "semantic": {"expectations": [{"passed": false, "message": "retry missing"}]} + })]; + assert_eq!( + validate_onboarding_captures(captures, "theme-selector-interaction").unwrap_err(), + "story expectations failed: retry missing" + ); + } +} diff --git a/packages/ui-preview-rs/src/menus.rs b/packages/ui-preview-rs/src/menus.rs index 68044900c..1b1598222 100644 --- a/packages/ui-preview-rs/src/menus.rs +++ b/packages/ui-preview-rs/src/menus.rs @@ -51,7 +51,8 @@ pub fn register(registry: &mut Registry) -> Result<(), String> { ); }, ) - .matrix(&[(40, 20), (60, 24), (100, 30)], &[0]), + .matrix(&[(40, 20), (60, 24), (100, 30)], &[0]) + .adapter("shared-menu"), )?; } Ok(()) diff --git a/packages/ui-preview-rs/src/registry.rs b/packages/ui-preview-rs/src/registry.rs index 769b545a5..320d8a80c 100644 --- a/packages/ui-preview-rs/src/registry.rs +++ b/packages/ui-preview-rs/src/registry.rs @@ -1,13 +1,69 @@ //! Register a renderer once; derive catalog, captures, and source links from it. use crate::{ - Scene, + Scene, StoryFilter, authoring::{REPLAY_STEP_MS, StoryInput, StorySequence, validate_dimensions, validate_id}, + contract::{StoryContract, StoryEffect, StoryObservation, evaluate_step}, review::{self, Capture}, + schema::{ + CAPTURE_SCHEMA_VERSION, CoverageProfile, ProfileIdentity, ResolvedStoryId, StoryAlias, + StoryAliases, + }, }; use ratatui::{Frame, Terminal, backend::TestBackend, buffer::Buffer}; +use serde::Serialize; use std::collections::BTreeMap; +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CoverageState { + Declared, + Visited, + AssertedPassed, + AssertedFailed, + Skipped, + Unavailable, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct CoverageSummary { + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + pub declared: Vec, + pub states: BTreeMap, +} +impl CoverageSummary { + pub fn declare(&mut self, id: impl Into) { + let id = id.into(); + if !self.declared.contains(&id) { + self.declared.push(id.clone()); + } + self.states.insert(id, CoverageState::Declared); + } + pub fn record(&mut self, id: impl Into, state: CoverageState) { + self.states.insert(id.into(), state); + } + #[must_use] + pub fn state(&self, id: &str) -> Option { + self.states.get(id).copied() + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ContributionDiagnostic { + pub story: String, + pub adapter: String, + pub owner: String, + pub status: &'static str, +} + type Renderer = Box Result>; +type Observer = Box< + dyn Fn( + &Scene, + &[StoryInput], + &Buffer, + ) -> Result<(Vec, Vec), String>, +>; /// One named fixture family. The closure captures typed, caller-owned mock data. pub struct Story { id: String, @@ -16,6 +72,9 @@ pub struct Story { cases: Vec<(u16, u16, u64)>, renderer: Renderer, sequence: Option, + contract: Option, + observer: Option, + adapter: String, } impl Story { pub fn new( @@ -46,6 +105,9 @@ impl Story { cases: vec![(60, 24, 0)], renderer: Box::new(render), sequence: None, + contract: None, + observer: None, + adapter: "unowned".into(), } } /// Build deterministic frames by replaying each prefix through the same @@ -78,6 +140,9 @@ impl Story { render(scene, &replay.inputs[..step]) }), sequence: Some(sequence), + contract: None, + observer: None, + adapter: "unowned".into(), }) } /// Cross product of sizes and fixed timestamps; no wall clock or sleeps. @@ -88,10 +153,36 @@ impl Story { .collect(); self } + #[must_use] + pub fn adapter(mut self, adapter: &str) -> Self { + self.adapter = adapter.into(); + self + } + /// Attach bounded behavior observations to the same renderer that produces + /// visual captures. The evaluator is effect-free and receives replay inputs. + pub fn contract( + mut self, + contract: StoryContract, + observe: impl Fn( + &Scene, + &[StoryInput], + &Buffer, + ) -> Result<(Vec, Vec), String> + + 'static, + ) -> Result { + contract.validate()?; + if contract.story_id != self.id { + return Err("story contract ID must match story ID".into()); + } + self.contract = Some(contract); + self.observer = Some(Box::new(observe)); + Ok(self) + } } #[derive(Default)] pub struct Registry { stories: BTreeMap, + aliases: StoryAliases, } impl Registry { pub fn add(&mut self, story: Story) -> Result<(), String> { @@ -127,6 +218,138 @@ impl Registry { .filter_map(|story| story.sequence.clone()) .collect() } + pub fn coverage_summary(&self) -> Result { + let captures = self.results()?; + let mut summary = CoverageSummary::default(); + for id in self.stories.keys() { + summary.declare(id); + } + for capture in captures { + let state = match capture.semantic { + Some(result) if result.passed() => CoverageState::AssertedPassed, + Some(_) => CoverageState::AssertedFailed, + None => CoverageState::Visited, + }; + summary.record(capture.scene.id, state); + } + Ok(summary) + } + + pub fn coverage_for_profile( + &self, + adapters: &crate::adapters::AdapterRegistry, + profile: &CoverageProfile, + ) -> Result { + profile.validate()?; + let mut summary = CoverageSummary { + profile: Some(ProfileIdentity { + id: profile.id.clone(), + version: profile.version, + }), + ..CoverageSummary::default() + }; + for id in self.stories.keys() { + summary.declare(id); + } + for story in self.stories.values() { + let manifest = adapters.get(&story.adapter).map_err(|_| { + format!( + "orphan story {} references adapter {}", + story.id, story.adapter + ) + })?; + if !manifest.profiles.contains(&profile.id) { + summary.record(&story.id, CoverageState::Unavailable); + continue; + } + let has_profile_case = story + .cases + .iter() + .any(|(width, height, _)| profile.geometries.contains(&(*width, *height))); + if !has_profile_case { + summary.record(&story.id, CoverageState::Skipped); + continue; + } + let captures = self.results_for(std::iter::once(story), None, None, Some(profile))?; + let state = if captures + .iter() + .filter_map(|capture| capture.semantic.as_ref()) + .any(|result| !result.passed()) + { + CoverageState::AssertedFailed + } else if captures.iter().any(|capture| capture.semantic.is_some()) { + CoverageState::AssertedPassed + } else { + CoverageState::Visited + }; + summary.record(&story.id, state); + } + Ok(summary) + } + + pub fn contribution_diagnostics( + &self, + adapters: &crate::adapters::AdapterRegistry, + ) -> Result, String> { + self.stories + .values() + .map(|story| { + let adapter = adapters.get(&story.adapter).map_err(|_| { + format!( + "orphan story {} references adapter {}", + story.id, story.adapter + ) + })?; + if adapter.owner.is_empty() { + return Err(format!("ownerless adapter: {}", adapter.id)); + } + if let Some(profile) = adapter + .profiles + .iter() + .find(|profile| !matches!(profile.as_str(), "pr-v1" | "scheduled-v1")) + { + return Err(format!("unsupported coverage profile: {profile}")); + } + match &adapter.lifecycle { + crate::adapters::StoryLifecycle::Deprecated { replacement } => { + adapters.get(replacement).map_err(|_| { + format!( + "deprecated adapter {} has missing replacement {replacement}", + adapter.id + ) + })?; + } + crate::adapters::StoryLifecycle::Retired { reason } => { + return Err(format!( + "story {} uses retired adapter {}: {reason}", + story.id, adapter.id + )); + } + crate::adapters::StoryLifecycle::Active => {} + } + Ok(ContributionDiagnostic { + story: story.id.clone(), + adapter: adapter.id.clone(), + owner: adapter.owner.clone(), + status: "owned", + }) + }) + .collect() + } + pub fn add_aliases( + &mut self, + aliases: impl IntoIterator, + ) -> Result<(), String> { + let stories = self + .stories + .iter() + .map(|(id, story)| (id.clone(), story.adapter.clone())) + .collect(); + let aliases = StoryAliases::new(aliases, &stories)?; + aliases.validate_for_version(CAPTURE_SCHEMA_VERSION)?; + self.aliases = aliases; + Ok(()) + } /// Bridge existing catalogs without changing their IDs or capture matrix. pub fn import( &mut self, @@ -137,7 +360,8 @@ impl Registry { let mut groups: BTreeMap = BTreeMap::new(); for scene in scenes { let story = groups.entry(scene.id.clone()).or_insert_with(|| { - let mut story = Story::buffer(&scene.id, &scene.label, source, render); + let mut story = + Story::buffer(&scene.id, &scene.label, source, render).adapter("legacy"); story.cases.clear(); story }); @@ -173,15 +397,179 @@ impl Registry { (story.renderer)(scene) } pub fn captures(&self) -> Result, String> { - self.scenes() + self.results() + } + /// Render visual and semantic evidence from one immutable story case. + pub fn results(&self) -> Result, String> { + self.results_for(self.stories.values(), None, None, None) + } + pub fn results_for_profile( + &self, + adapters: &crate::adapters::AdapterRegistry, + profile: &CoverageProfile, + ) -> Result, String> { + profile.validate()?; + let mut stories = Vec::new(); + for story in self.stories.values() { + let adapter = adapters.get(&story.adapter).map_err(|_| { + format!( + "orphan story {} references adapter {}", + story.id, story.adapter + ) + })?; + if adapter.profiles.contains(&profile.id) { + stories.push(story); + } + } + self.results_for(stories, None, None, Some(profile)) + } + fn results_for<'a>( + &self, + stories: impl IntoIterator, + filter: Option, + resolved_story: Option, + profile: Option<&CoverageProfile>, + ) -> Result, String> { + stories .into_iter() - .map(|scene| { - let mut capture = review::from_buffer(scene.clone(), &self.render(&scene)?)?; - capture.source = self.stories[&scene.id].source.clone(); + .flat_map(|story| { + story + .cases + .iter() + .filter(move |&&(width, height, _)| { + profile.is_none_or(|profile| profile.geometries.contains(&(width, height))) + }) + .map(move |&(width, height, time_ms)| { + ( + story, + Scene { + id: story.id.clone(), + label: story.label.clone(), + width, + height, + time_ms, + }, + ) + }) + }) + .map(|(story, scene)| { + let buffer = (story.renderer)(&scene)?; + let mut capture = review::from_buffer(scene.clone(), &buffer)?; + if let Some(profile) = profile { + capture.metadata = crate::schema::CaptureMetadata::for_profile(profile); + } + capture.source = story.source.clone(); + capture.filter = filter.clone(); + capture.resolved_story = resolved_story.clone(); + if let (Some(contract), Some(observer)) = (&story.contract, &story.observer) { + let step = usize::try_from(scene.time_ms / REPLAY_STEP_MS) + .map_err(|_| "invalid replay step")?; + let inputs = story.sequence.as_ref().map_or(&[][..], |sequence| { + &sequence.inputs[..step.min(sequence.inputs.len())] + }); + let (observations, effects) = observer(&scene, inputs, &buffer)?; + let expectations = if story + .sequence + .as_ref() + .is_none_or(|sequence| step == sequence.inputs.len()) + { + &contract.expectations[..] + } else { + &[][..] + }; + capture.semantic = + Some(evaluate_step(step, observations, effects, expectations)?); + } Ok(capture) }) .collect() } + + #[must_use] + pub fn story_ids(&self) -> Vec { + self.stories.keys().cloned().collect() + } + + pub fn filtered(&self, filter: StoryFilter) -> Result, String> { + let resolved_story = match &filter { + StoryFilter::Story(id) => Some(self.aliases.resolve(id)), + StoryFilter::Adapter(_) => None, + }; + let stories: Vec<_> = self + .stories + .values() + .filter(|story| match &filter { + StoryFilter::Story(_) => resolved_story + .as_ref() + .is_some_and(|resolved| story.id == resolved.canonical), + StoryFilter::Adapter(id) => &story.adapter == id, + }) + .collect(); + if stories.is_empty() { + return Err(match &filter { + StoryFilter::Story(id) => format!("unknown story: {id}"), + StoryFilter::Adapter(id) => format!("unknown adapter or empty adapter: {id}"), + }); + } + Ok(FilteredRegistry { + registry: self, + filter, + stories, + resolved_story, + }) + } +} + +pub struct FilteredRegistry<'a> { + registry: &'a Registry, + filter: StoryFilter, + stories: Vec<&'a Story>, + resolved_story: Option, +} +impl FilteredRegistry<'_> { + #[must_use] + pub fn len(&self) -> usize { + self.stories.len() + } + #[must_use] + pub fn is_empty(&self) -> bool { + self.stories.is_empty() + } + #[must_use] + pub fn story_ids(&self) -> Vec { + self.stories.iter().map(|story| story.id.clone()).collect() + } + pub fn scenes(&self) -> Vec { + self.stories + .iter() + .flat_map(|story| { + story + .cases + .iter() + .map(move |&(width, height, time_ms)| Scene { + id: story.id.clone(), + label: story.label.clone(), + width, + height, + time_ms, + }) + }) + .collect() + } + pub fn sequences(&self) -> Vec { + self.stories + .iter() + .filter_map(|story| story.sequence.clone()) + .collect() + } + pub fn captures(&self) -> Result, String> { + self.registry.results_for( + self.stories.iter().copied(), + Some(self.filter.clone()), + self.resolved_story.clone(), + None, + ) + } } #[cfg(test)] mod tests { @@ -193,6 +581,135 @@ mod tests { .matrix(&[(40, 20), (80, 30)], &[0, 80]) } #[test] + fn coverage_distinguishes_declared_visited_asserted_and_terminal_states() { + let mut summary = CoverageSummary::default(); + summary.declare("scheduled-only"); + summary.record("unicode", CoverageState::Visited); + summary.record("error", CoverageState::AssertedPassed); + summary.record("broken", CoverageState::AssertedFailed); + summary.record("skipped", CoverageState::Skipped); + summary.record("unavailable", CoverageState::Unavailable); + assert_eq!(summary.state("error"), Some(CoverageState::AssertedPassed)); + assert_eq!(summary.state("unicode"), Some(CoverageState::Visited)); + assert_eq!( + summary.state("scheduled-only"), + Some(CoverageState::Declared) + ); + assert_eq!(summary.states.len(), 6); + } + + #[test] + fn ownership_diagnostics_reject_orphan_stories() { + let mut registry = Registry::default(); + registry.add(story().adapter("missing-adapter")).unwrap(); + assert!( + registry + .contribution_diagnostics(&crate::adapters::AdapterRegistry::builtins().unwrap()) + .unwrap_err() + .contains("orphan story") + ); + } + + #[test] + fn contribution_validation_enforces_owner_and_lifecycle() { + let mut registry = Registry::default(); + registry.add(story().adapter("shared-menu")).unwrap(); + let manifest = || { + crate::adapters::AdapterManifest::active( + "shared-menu", + "maestro-ui", + "fixtures", + "registration.rs", + crate::adapters::StoryTemplate::MenuRecipe, + ) + }; + let mut ownerless = manifest(); + ownerless.owner.clear(); + assert!( + registry + .contribution_diagnostics( + &crate::adapters::AdapterRegistry::new([ownerless]).unwrap() + ) + .unwrap_err() + .contains("ownerless") + ); + let mut deprecated = manifest(); + deprecated.lifecycle = crate::adapters::StoryLifecycle::Deprecated { + replacement: "missing".into(), + }; + assert!( + registry + .contribution_diagnostics( + &crate::adapters::AdapterRegistry::new([deprecated]).unwrap() + ) + .unwrap_err() + .contains("missing replacement") + ); + let mut unsupported = manifest(); + unsupported.profiles = vec!["unbounded".into()]; + assert!( + registry + .contribution_diagnostics( + &crate::adapters::AdapterRegistry::new([unsupported]).unwrap() + ) + .unwrap_err() + .contains("unsupported coverage profile") + ); + let mut retired = manifest(); + retired.lifecycle = crate::adapters::StoryLifecycle::Retired { + reason: "superseded".into(), + }; + assert!( + registry + .contribution_diagnostics( + &crate::adapters::AdapterRegistry::new([retired]).unwrap() + ) + .unwrap_err() + .contains("retired adapter") + ); + } + + #[test] + fn profile_evaluation_produces_skipped_and_unavailable_states() { + let mut registry = Registry::default(); + registry.add(story().adapter("shared-menu")).unwrap(); + let manifest = crate::adapters::AdapterManifest::active( + "shared-menu", + "maestro-ui", + "fixtures", + "registration.rs", + crate::adapters::StoryTemplate::MenuRecipe, + ); + let mut profile = crate::schema::builtin_profiles()[0].clone(); + profile.geometries = vec![(60, 20)]; + let adapters = crate::adapters::AdapterRegistry::new([manifest.clone()]).unwrap(); + assert_eq!( + registry + .coverage_for_profile(&adapters, &profile) + .unwrap() + .state("menu"), + Some(CoverageState::Skipped) + ); + assert_eq!( + registry + .coverage_for_profile(&adapters, &profile) + .unwrap() + .declared, + vec!["menu"] + ); + let scheduled = crate::schema::builtin_profiles()[1].clone(); + let mut unsupported = manifest.clone(); + unsupported.profiles = vec!["pr-v1".into()]; + let adapters = crate::adapters::AdapterRegistry::new([unsupported]).unwrap(); + assert_eq!( + registry + .coverage_for_profile(&adapters, &scheduled) + .unwrap() + .state("menu"), + Some(CoverageState::Unavailable) + ); + } + #[test] fn registry_derives_every_case_and_rejects_collisions() { let mut registry = Registry::default(); registry.add(story()).unwrap(); @@ -254,4 +771,107 @@ mod tests { .matrix(&[(80, 20)], &[0]); assert!(Registry::default().add(story).is_err()); } + + #[test] + fn filters_select_registered_story_or_adapter_and_reject_unknown_values() { + let mut registry = Registry::default(); + registry.add(story().adapter("shared-menu")).unwrap(); + registry + .add(Story::new("other", "Other", "other.rs", |_, _| {}).adapter("other-adapter")) + .unwrap(); + let selected = registry + .filtered(StoryFilter::Story("menu".into())) + .unwrap(); + assert_eq!(selected.story_ids(), ["menu"]); + assert_eq!( + selected.captures().unwrap()[0].filter, + Some(StoryFilter::Story("menu".into())) + ); + assert_eq!( + registry + .filtered(StoryFilter::Adapter("shared-menu".into())) + .unwrap() + .len(), + 1 + ); + assert!( + registry + .filtered(StoryFilter::Story("missing".into())) + .is_err() + ); + } + + #[test] + fn registry_rejects_aliases_expired_for_the_current_capture_schema() { + let mut registry = Registry::default(); + registry.add(story().adapter("shared-menu")).unwrap(); + let alias = |remove_in_version| StoryAlias { + from: "old-menu".into(), + to: "menu".into(), + adapter: "shared-menu".into(), + remove_in_version, + }; + assert!( + registry + .add_aliases([alias(CAPTURE_SCHEMA_VERSION)]) + .is_err() + ); + registry + .add_aliases([alias(CAPTURE_SCHEMA_VERSION + 1)]) + .unwrap(); + } + + #[test] + fn contract_results_share_the_capture_render_and_assert_final_prefix() { + let sequence = StorySequence::new("asserted-menu", "Asserted menu", 40, 10) + .inputs([StoryInput::Retry]); + let story = Story::replay(sequence, "example.rs", |scene, _| { + Ok(Buffer::empty(ratatui::layout::Rect::new( + 0, + 0, + scene.width, + scene.height, + ))) + }) + .unwrap() + .contract( + StoryContract::new("asserted-menu", "maestro-ui") + .expect(crate::contract::StoryExpectation::effect_count("retry", 1)), + |_, inputs, _| { + Ok(( + vec![StoryObservation::id("state", "ready")], + inputs + .iter() + .filter(|input| matches!(input, StoryInput::Retry)) + .map(|_| StoryEffect::new("retry")) + .collect(), + )) + }, + ) + .unwrap(); + let mut registry = Registry::default(); + registry.add(story).unwrap(); + let results = registry.results().unwrap(); + assert_eq!(results.len(), 2); + assert!(results[0].semantic.as_ref().unwrap().passed()); + assert!( + results[0] + .semantic + .as_ref() + .unwrap() + .expectations + .is_empty(), + "final-state expectations do not apply to earlier prefixes" + ); + assert!(results[1].semantic.as_ref().unwrap().passed()); + assert_eq!( + results[1].semantic.as_ref().unwrap().expectations.len(), + 1, + "the final prefix evaluates the contract" + ); + assert_eq!( + results[1].semantic.as_ref().unwrap().effects, + [StoryEffect::new("retry")] + ); + } } diff --git a/packages/ui-preview-rs/src/review.html b/packages/ui-preview-rs/src/review.html index 21db09e84..5b1765209 100644 --- a/packages/ui-preview-rs/src/review.html +++ b/packages/ui-preview-rs/src/review.html @@ -397,6 +397,7 @@

States

+

@@ -463,6 +464,11 @@

Variant coverage

+ + + + + @@ -471,6 +477,8 @@

Variant coverage

const captures = JSON.parse( document.querySelector("#fixtures").textContent, ), + coverageReport = JSON.parse(document.querySelector("#registry-coverage").textContent), + storyCoverage = new Map(Object.entries(coverageReport.states || {})), $ = (s) => document.querySelector(s), scene = $("#scene"), size = $("#size"), @@ -480,51 +488,13 @@

Variant coverage

pinned = null, restoring = false, visibleEntries = [], + activeGeneration = null, exitInteractive = () => {}, exitStudio = () => {}; - const colors = [ - "#000000", - "#800000", - "#008000", - "#808000", - "#000080", - "#800080", - "#008080", - "#c0c0c0", - "#808080", - "#ff0000", - "#00ff00", - "#ffff00", - "#0000ff", - "#ff00ff", - "#00ffff", - "#ffffff", - ]; - function color(c, fallback) { - if (c === "reset") return fallback; - if (!c.startsWith("indexed:")) return c; - let n = Number(c.slice(8)); - if (n < 16) return colors[n]; - if (n >= 232) { - let v = 8 + (n - 232) * 10; - return `rgb(${v},${v},${v})`; - } - n -= 16; - const ramp = [0, 95, 135, 175, 215, 255]; - return `rgb(${ramp[Math.floor(n / 36)]},${ramp[Math.floor(n / 6) % 6]},${ramp[n % 6]})`; - } - function options(el, entries) { - const prior = el.value; - el.replaceChildren( - ...entries.map(([value, label]) => { - const o = document.createElement("option"); - o.value = value; - o.textContent = label; - return o; - }), - ); - if (entries.some((e) => e[0] === prior)) el.value = prior; - } + const { draw, readable, sourceUrl } = MaestroUI.rendering; + const { options, stateLabel, coverageLabel, experienceOrder } = MaestroUI.catalog; + const playback = MaestroUI.playback; + const authoring = MaestroUI.authoring; function stop() { clearTimeout(timer); timer = null; @@ -532,40 +502,17 @@

Variant coverage

} function selectScenes() { stop(); - const query = $("#search").value.toLowerCase(); - const map = new Map(); - captures.forEach((c) => { - if ((c.scene.id + " " + c.scene.label).toLowerCase().includes(query)) - map.set(c.scene.id, c.scene.label + " / " + c.scene.id); - }); - visibleEntries = [...map]; + visibleEntries = MaestroUI.catalog.sceneEntries(captures, $("#search").value); options(scene, visibleEntries); sidebar(visibleEntries); selectSizes(); } function selectSizes() { stop(); - const map = new Map(); - captures - .filter((c) => c.scene.id === scene.value) - .forEach((c) => - map.set( - `${c.scene.width}x${c.scene.height}`, - `${c.scene.width} × ${c.scene.height}`, - ), - ); - options(size, [...map]); + options(size, MaestroUI.catalog.sizeEntries(captures, scene.value)); selectTimes(); } - function selected() { - return captures - .filter( - (c) => - c.scene.id === scene.value && - `${c.scene.width}x${c.scene.height}` === size.value, - ) - .sort((a, b) => a.scene.time_ms - b.scene.time_ms); - } + function selected() { return playback.selectedFrames(captures, scene.value, size.value); } function selectTimes() { stop(); options( @@ -608,51 +555,11 @@

Variant coverage

); const source = $("#source"); source.hidden = !current.source; - source.href = - "https://github.com/dx-corp/mono/blob/main/" + - (current.source || "").split("/").map(encodeURIComponent).join("/"); + source.href = sourceUrl(current.source); $("#pin").disabled = false; paintComparison(); if (!restoring) saveRoute(); } - function draw(screen, current) { - screen.replaceChildren(); - const { width, height } = current.scene; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; ) { - const c = current.cells[y * width + x], - span = document.createElement("span"), - m = c.modifiers; - let fg = color(c.foreground, "#efebff"), - bg = color(c.background, "#171624"); - if (m & 64) [fg, bg] = [bg, fg]; - span.className = "cell"; - const glyph = document.createElement("span"); - glyph.textContent = c.text; - glyph.style.opacity = m & 2 ? ".55" : "1"; - glyph.style.visibility = m & 128 ? "hidden" : "visible"; - span.append(glyph); - span.style.cssText = `width:${c.columns}ch;color:${fg};background:${bg};font-weight:${m & 1 ? "700" : "400"};font-style:${m & 4 ? "italic" : "normal"};text-decoration:${[m & 8 ? "underline" : "", m & 256 ? "line-through" : ""].filter(Boolean).join(" ") || "none"}`; - screen.append(span); - x += c.columns; - } - screen.append(document.createTextNode("\n")); - } - } - function readable(capture) { - const lines = []; - for (let y = 0; y < capture.scene.height; y++) { - let line = ""; - for (let x = 0; x < capture.scene.width; ) { - const cell = capture.cells[y * capture.scene.width + x]; - line += cell.modifiers & 128 ? " ".repeat(cell.columns) : cell.text; - x += cell.columns; - } - lines.push(line.trimEnd()); - } - while (lines.at(-1) === "") lines.pop(); - return lines.join("\n"); - } scene.onchange = () => { exitStudio(); exitInteractive(); @@ -683,12 +590,7 @@

Variant coverage

function next() { const frames = selected(), i = time.selectedIndex, - delay = - i + 1 < frames.length - ? frames[i + 1].scene.time_ms - frames[i].scene.time_ms - : frames.length > 1 - ? frames[1].scene.time_ms - frames[0].scene.time_ms - : 80; + delay = playback.nextDelay(frames, i); timer = setTimeout( () => { time.selectedIndex = (time.selectedIndex + 1) % time.options.length; @@ -716,32 +618,9 @@

Variant coverage

URL.revokeObjectURL(url); }; - function group(id) { - if (id.startsWith("first-boot")) return "First launch"; - if (id.startsWith("onboarding-")) return "Onboarding"; - if (id.startsWith("conversation-")) return "Conversation"; - if (id.startsWith("menu-")) return "Workspace menu"; - if (id.startsWith("theme-selector")) return "Theme selector"; - if (id.startsWith("picker")) return "Appearance picker"; - if (["startup", "header", "quiet", "motion-off", "working", "finished", "failed", "needs-input", "pet"].includes(id) || id.startsWith("accessory-") || id.startsWith("accent-")) return "Companion appearance"; - return "Other scenes"; - } - const experienceOrder = ["First launch", "Onboarding", "Conversation", "Workspace menu", "Theme selector", "Appearance picker", "Companion appearance", "Other scenes"]; - function stateLabel(capture) { - if (capture.scene.id.startsWith("onboarding-")) { - const id = capture.scene.id.slice("onboarding-".length); - return ({ welcome: "Welcome", role: "Role", "use-case": "Use case", workflow: "Workflow", connect: "Connect", verify: "Verify connection", checking: "Checking", ready: "Results · ready", failed: "Results · failed", waiting: "Waiting", provider: "Provider", key: "API key" })[id] || capture.scene.label; - } - return capture.scene.label.replace(/^.*?\s[\/·]\s/, ""); - } function sidebar(entries) { - const groups = new Map(); - for (const [id] of entries) { - const key = group(id); - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(id); - } - const active = groups.has(group(scene.value)) ? group(scene.value) : experienceOrder.find((name) => groups.has(name)); + const groups = MaestroUI.catalog.grouped(entries); + const active = MaestroUI.catalog.activeGroup(groups, scene.value); const experienceRoot = $("#experiences"), stateRoot = $("#stories"); experienceRoot.replaceChildren(); stateRoot.replaceChildren(); @@ -765,8 +644,10 @@

Variant coverage

button.type = "button"; button.className = "story"; button.dataset.scene = id; + button.dataset.coverage = storyCoverage.get(id) || "declared"; button.setAttribute("aria-current", String(id === scene.value)); button.textContent = stateLabel(c); + button.title = `Coverage: ${coverageLabel(button.dataset.coverage)}`; button.onclick = () => { exitStudio(); exitInteractive(false); @@ -781,17 +662,7 @@

Variant coverage

$("#comparison").hidden = !pinned; $("#unpin").hidden = !pinned; if (!pinned) return; - const frames = captures - .filter( - (c) => - c.scene.id === pinned.id && - c.scene.width === pinned.width && - c.scene.height === pinned.height, - ) - .sort((a, b) => a.scene.time_ms - b.scene.time_ms); - const c = - frames.filter((c) => c.scene.time_ms <= Number(time.value)).at(-1) || - frames[0]; + const c = playback.comparisonFrame(captures, pinned, time.value); if (!c) { pinned = null; $("#comparison").hidden = true; @@ -803,16 +674,7 @@

Variant coverage

$("#reference-transcript").textContent = readable(c); } function saveRoute() { - const p = new URLSearchParams({ - scene: scene.value, - size: size.value, - time: time.value, - }); - if (pinned) { - p.set("compare", pinned.id); - p.set("compareSize", `${pinned.width}x${pinned.height}`); - } - history.replaceState(null, "", "#" + p.toString()); + history.replaceState(null, "", playback.routeString(scene.value, size.value, time.value, pinned)); } function restoreRoute() { stop(); @@ -896,45 +758,11 @@

Variant coverage

document.querySelectorAll("[data-tab]").forEach((button) => { button.onclick = () => setTab(button.dataset.tab); }); function fill(recipe) { - $("#studio-id").value = recipe.id; - $("#studio-label").value = recipe.label; - $("#studio-title").value = recipe.title; - $("#studio-placeholder").value = recipe.placeholder; - $("#studio-empty").value = recipe.empty; - $("#studio-items").value = recipe.items.map((item) => `${item.id} | ${item.label}`).join("\n"); - $("#studio-state").value = recipe.state; - $("#studio-message").value = recipe.status_message; - $("#studio-width").value = recipe.width; - $("#studio-height").value = recipe.height; - } - function read() { - const items = $("#studio-items").value.split(/\r?\n/).filter((line) => line.trim()).map((line) => { - const separator = line.indexOf("|"); - if (separator < 1) throw new Error("Each item needs a stable-id | Display label."); - return { id: line.slice(0, separator).trim(), label: line.slice(separator + 1).trim() }; - }); - return { - ...request, - id: $("#studio-id").value, - label: $("#studio-label").value, - title: $("#studio-title").value, - placeholder: $("#studio-placeholder").value, - empty: $("#studio-empty").value, - items, - state: $("#studio-state").value, - status_message: $("#studio-message").value, - width: Number($("#studio-width").value), - height: Number($("#studio-height").value), - }; - } - function formValues() { - return Object.fromEntries(["id", "label", "title", "placeholder", "empty", "items", "state", "message", "width", "height"].map((name) => [name, $("#studio-" + name).value])); - } - function applyForm(values) { - for (const name of ["id", "label", "title", "placeholder", "empty", "items", "state", "message", "width", "height"]) { - if (typeof values[name] === "string") $("#studio-" + name).value = values[name]; - } + applyForm(authoring.recipeValues(recipe)); } + function read() { return authoring.readRecipe(request, (name) => $("#studio-" + name)); } + function formValues() { return authoring.formValues((name) => $("#studio-" + name)); } + function applyForm(values) { authoring.applyForm(values, (name) => $("#studio-" + name)); } function saveDraft() { sessionStorage.setItem("ui-studio-draft", JSON.stringify(request)); sessionStorage.setItem("ui-studio-form", JSON.stringify(formValues())); @@ -991,7 +819,7 @@

Variant coverage

const response = await fetch("/__studio", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(recipe), + body: JSON.stringify(recipe && recipe.schema ? recipe : authoring.wire("maestro.ui.menu-recipe", recipe)), }); const value = await response.json(); if (response.status === 429) { @@ -1019,6 +847,7 @@

Variant coverage

try { const next = await post(submitted, () => active && submittedGeneration === generation); if (!active || submittedGeneration !== generation || pending) continue; + if (!playback.acceptsGeneration(activeGeneration, next.generation)) continue; result = next; paintResult(next, submittedContentGeneration); saveDraft(); @@ -1051,7 +880,7 @@

Variant coverage

const body = $("#coverage tbody"); body.replaceChildren(...next.coverage.map((row) => { const tr = document.createElement("tr"); - for (const value of [row.variant, row.availability.replaceAll("-", " "), row.exercised ? "Exercised" : "Not exercised", row.automated_check === null ? "Not reported" : row.automated_check ? "Covered" : "Missing"]) { + for (const value of authoring.coverageCells(row)) { const td = document.createElement("td"); td.textContent = value; tr.append(td); } tr.title = row.note; @@ -1059,7 +888,7 @@

Variant coverage

})); const source = $("#source"); source.hidden = !next.capture.source; - source.href = "https://github.com/dx-corp/mono/blob/main/" + next.capture.source.split("/").map(encodeURIComponent).join("/"); + source.href = sourceUrl(next.capture.source); updateStudioControls(); } function edited(clearInputs = true) { @@ -1112,8 +941,8 @@

Variant coverage

if (!$("#studio-error").textContent) $("#studio-error").textContent = "Render the current Content fields before adding interaction inputs."; return; } - if (request.inputs.length + inputs.length > 64) { $("#studio-error").textContent = "Sequence limit reached (64 inputs)."; return; } - request.inputs.push(...inputs); + try { authoring.addInputs(request, inputs); } + catch (error) { $("#studio-error").textContent = error.message; return; } saveDraft(); render(); } @@ -1137,8 +966,8 @@

Variant coverage

const url = URL.createObjectURL(new Blob([value], { type })), anchor = document.createElement("a"); anchor.href = url; anchor.download = name; anchor.click(); URL.revokeObjectURL(url); } - $("#studio-export").onclick = () => download(`${request.id}-recipe.json`, JSON.stringify(request, null, 2)); - $("#studio-replay-export").onclick = () => download(`${request.id}-replay.json`, JSON.stringify({ version: 1, id: request.id, label: request.label, width: request.width, height: request.height, inputs: request.inputs }, null, 2)); + $("#studio-export").onclick = () => download(`${request.id}-recipe.json`, JSON.stringify(authoring.wire("maestro.ui.menu-recipe", request), null, 2)); + $("#studio-replay-export").onclick = () => download(`${request.id}-replay.json`, JSON.stringify(authoring.wire("maestro.ui.replay", { version: 1, id: request.id, label: request.label, width: request.width, height: request.height, inputs: request.inputs }), null, 2)); $("#studio-source").onclick = () => { if (result) download(`${request.id}.rs`, result.fixture_source, "text/plain"); }; $("#studio-import").onclick = () => $("#studio-file").click(); $("#studio-file").onchange = async (event) => { @@ -1207,6 +1036,14 @@

Variant coverage

} catch { return; } + try { + catalog.fixtures = catalog.fixtures.map((value) => authoring.unwrap(value, "maestro.ui.theme-replay")); + catalog.presets = catalog.presets.map((preset) => ({ ...preset, request: authoring.unwrap(preset.request, "maestro.ui.theme-replay") })); + catalog.menu_starter = authoring.unwrap(catalog.menu_starter, "maestro.ui.menu-recipe"); + catalog.menu_fixtures = catalog.menu_fixtures.map((value) => authoring.unwrap(value, "maestro.ui.menu-recipe")); + } catch { + return; + } if (!Array.isArray(catalog.fixtures) || !Array.isArray(catalog.presets)) return; const panel = $("#interaction"), fixture = $("#live-fixture"), preset = $("#live-preset"), screen = $("#screen"), terminal = $("#terminal"); $("#live-toggle").hidden = false; @@ -1283,7 +1120,7 @@

Variant coverage

const response = await fetch("/__replay", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(sequence), + body: JSON.stringify(sequence && sequence.schema ? sequence : authoring.wire("maestro.ui.theme-replay", sequence)), }); const result = await response.json(); if (response.status === 429) { @@ -1291,6 +1128,7 @@

Variant coverage

continue; } if (!response.ok) throw new Error(result.error || `Replay failed (${response.status})`); + if (!playback.acceptsGeneration(activeGeneration, result.generation)) continue; return result; } } @@ -1306,7 +1144,7 @@

Variant coverage

: "No simulated effects. Nothing was persisted or executed."; const source = $("#source"); source.hidden = !result.capture.source; - source.href = "https://github.com/dx-corp/mono/blob/main/" + (result.capture.source || "").split("/").map(encodeURIComponent).join("/"); + source.href = sourceUrl(result.capture.source); } function add(input) { if (request.inputs.length >= 64) { @@ -1363,7 +1201,7 @@

Variant coverage

add({ type: "text", text: event.clipboardData.getData("text") }); }; $("#live-export").onclick = () => { - const blob = new Blob([JSON.stringify(request, null, 2)], { type: "application/json" }), url = URL.createObjectURL(blob), anchor = document.createElement("a"); + const blob = new Blob([JSON.stringify(authoring.wire("maestro.ui.theme-replay", request), null, 2)], { type: "application/json" }), url = URL.createObjectURL(blob), anchor = document.createElement("a"); anchor.href = url; anchor.download = `theme-selector-${request.fixture}-sequence.json`; anchor.click(); @@ -1384,8 +1222,9 @@

Variant coverage

generation++; const importGeneration = generation; while (rendering) await new Promise((resolve) => setTimeout(resolve, 10)); + const imported = authoring.unwrap(candidate, "maestro.ui.theme-replay"); const result = await renderSequence(candidate, () => importGeneration === generation); - request = candidate; + request = imported; interactive = wasInteractive; activate(); const index = catalog.fixtures.findIndex((item) => item.fixture === request.fixture); @@ -1420,6 +1259,11 @@

Variant coverage

const response = await fetch("/__revision", { cache: "no-store" }); if (!response.ok) return; const update = await response.json(); + activeGeneration = update.generation || activeGeneration; + const shortGeneration = update.generation ? update.generation.slice(0, 10) : "none"; + $("#generation-status").textContent = update.building + ? `${update.stale ? "Building — last good" : "Building"} ${shortGeneration}` + : `Current generation ${shortGeneration} · ${update.build_ms || 0} ms`; if (update.error) { hadError = true; $("#status").textContent = @@ -1431,7 +1275,7 @@

Variant coverage

current?.scene.label || "No matching scenes"; hadError = false; } - const next = update.revision; + const next = update.generation; if (revision && next !== revision) { sessionStorage.setItem( "ui-review-scroll", diff --git a/packages/ui-preview-rs/src/review.rs b/packages/ui-preview-rs/src/review.rs index 2973a1f66..5166454d4 100644 --- a/packages/ui-preview-rs/src/review.rs +++ b/packages/ui-preview-rs/src/review.rs @@ -1,5 +1,9 @@ //! Portable review artifacts from production terminal buffers. No runtime startup. -use crate::Scene; +use crate::{ + Scene, StoryFilter, + contract::StoryStepResult, + schema::{CaptureMetadata, ResolvedStoryId}, +}; use ratatui::{ Frame, Terminal, backend::TestBackend, @@ -22,8 +26,15 @@ pub struct Cell { /// Deterministic supplied-state capture. This is a fixture, never live readiness. #[derive(Debug, Serialize)] pub struct Capture { + pub metadata: CaptureMetadata, pub scene: Scene, pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_story: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub semantic: Option, pub cells: Vec, } @@ -62,8 +73,12 @@ pub fn from_buffer(scene: Scene, buffer: &Buffer) -> Result { return Err("scene dimensions differ from rendered buffer".into()); } Ok(Capture { + metadata: CaptureMetadata::current(), scene, source: String::new(), + filter: None, + resolved_story: None, + semantic: None, cells: buffer .content .iter() @@ -121,13 +136,57 @@ pub fn transcript(capture: &Capture) -> String { /// Self-contained review page; escaped data is interpreted only as text cells. pub fn html(captures: &[Capture]) -> Result { + let profile = captures + .first() + .ok_or("a review needs at least one capture")? + .metadata + .profile + .clone(); + if captures + .iter() + .any(|capture| capture.metadata.profile != profile) + { + return Err("a review cannot mix capture profiles".into()); + } + let mut states = std::collections::BTreeMap::new(); + for capture in captures { + let state = match &capture.semantic { + Some(result) if result.passed() => "asserted-passed", + Some(_) => "asserted-failed", + None => "visited", + }; + states.insert(capture.scene.id.clone(), state); + } + let fallback = serde_json::json!({ + "profile": profile, + "declared": states.keys().collect::>(), + "states": states, + }); + html_with_coverage(captures, &fallback) +} + +/// Self-contained review page with coverage produced by the registry's active profile. +pub fn html_with_coverage( + captures: &[Capture], + coverage: &impl Serialize, +) -> Result { if captures.is_empty() { return Err("a review needs at least one capture".into()); } let data = json(captures)? .replace('<', "\\u003c") .replace('&', "\\u0026"); - Ok(include_str!("review.html").replace("__CAPTURES__", &data)) + let coverage = serde_json::to_string(coverage) + .map_err(|error| error.to_string())? + .replace('<', "\\u003c") + .replace('&', "\\u0026"); + Ok(include_str!("review.html") + .replace("__CAPTURES__", &data) + .replace("__COVERAGE__", &coverage) + .replace("__MAESTRO_CATALOG_JS__", include_str!("web/catalog.js")) + .replace("__MAESTRO_PLAYBACK_JS__", include_str!("web/playback.js")) + .replace("__MAESTRO_AUTHORING_JS__", include_str!("web/authoring.js")) + .replace("__MAESTRO_RENDERING_JS__", include_str!("web/rendering.js"))) } #[cfg(test)] @@ -167,6 +226,9 @@ mod tests { let page = html(&[capture]).unwrap(); assert!(page.contains("\\u003c/script>")); assert!(!page.contains("\"\"")); + assert!(page.contains("MaestroUI")); + assert!(page.contains("registry-coverage")); + assert!(!page.contains("__MAESTRO_")); } #[test] fn callback_capture_matches_direct_production_render() { diff --git a/packages/ui-preview-rs/src/schema.rs b/packages/ui-preview-rs/src/schema.rs new file mode 100644 index 000000000..ad393aa12 --- /dev/null +++ b/packages/ui-preview-rs/src/schema.rs @@ -0,0 +1,455 @@ +//! Versioned wire formats, compatibility migrations, aliases, and coverage profiles. +use crate::{ + Scene, + adapters::AdapterManifest, + authoring::{MenuRecipe, StorySequence, validate_dimensions, validate_id}, + contract::StoryContract, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; + +pub const RECIPE_SCHEMA: &str = "maestro.ui.menu-recipe"; +pub const REPLAY_SCHEMA: &str = "maestro.ui.replay"; +pub const CONTRACT_SCHEMA: &str = "maestro.ui.story-contract"; +pub const THEME_REPLAY_SCHEMA: &str = "maestro.ui.theme-replay"; +pub const RECIPE_SCHEMA_VERSION: u16 = 1; +pub const REPLAY_SCHEMA_VERSION: u16 = 1; +pub const CONTRACT_SCHEMA_VERSION: u16 = 1; +pub const THEME_REPLAY_SCHEMA_VERSION: u16 = 1; +pub const OBSERVATION_SCHEMA_VERSION: u16 = 1; +pub const CAPTURE_SCHEMA_VERSION: u16 = 1; +pub const PROFILE_SCHEMA_VERSION: u16 = 1; +const MAX_PROFILE_GEOMETRIES: usize = 16; +const MAX_PROFILE_TEXT_BYTES: usize = 240; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WireEnvelope { + pub schema: String, + pub version: u16, + pub value: T, +} + +impl WireEnvelope { + #[must_use] + pub fn new(schema: &str, version: u16, value: T) -> Self { + Self { + schema: schema.into(), + version, + value, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SchemaError { + code: &'static str, + detail: String, +} + +impl SchemaError { + fn new(code: &'static str, detail: impl Into) -> Self { + Self { + code, + detail: detail.into(), + } + } + #[must_use] + pub fn code(&self) -> &'static str { + self.code + } +} + +impl fmt::Display for SchemaError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.detail) + } +} +impl std::error::Error for SchemaError {} + +fn migrate( + value: Value, + schema: &'static str, + version: u16, + unsupported: &'static str, +) -> Result, SchemaError> { + let is_envelope = value + .as_object() + .is_some_and(|object| object.contains_key("schema")); + if is_envelope { + let envelope: WireEnvelope = serde_json::from_value(value) + .map_err(|error| SchemaError::new("invalid-wire-envelope", error.to_string()))?; + if envelope.schema != schema { + return Err(SchemaError::new("unexpected-schema", envelope.schema)); + } + if envelope.version != version { + return Err(SchemaError::new(unsupported, envelope.version.to_string())); + } + let decoded = serde_json::from_value(envelope.value) + .map_err(|error| SchemaError::new("invalid-schema-value", error.to_string()))?; + Ok(WireEnvelope::new(schema, version, decoded)) + } else { + let raw_version = value + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| SchemaError::new("missing-schema-version", schema))?; + if raw_version != u64::from(version) { + return Err(SchemaError::new(unsupported, raw_version.to_string())); + } + let decoded = serde_json::from_value(value) + .map_err(|error| SchemaError::new("invalid-schema-value", error.to_string()))?; + Ok(WireEnvelope::new(schema, version, decoded)) + } +} + +pub fn migrate_recipe(value: Value) -> Result, SchemaError> { + migrate( + value, + RECIPE_SCHEMA, + RECIPE_SCHEMA_VERSION, + "unsupported-recipe-version", + ) +} +pub fn migrate_replay(value: Value) -> Result, SchemaError> { + migrate( + value, + REPLAY_SCHEMA, + REPLAY_SCHEMA_VERSION, + "unsupported-replay-version", + ) +} +pub fn migrate_contract(value: Value) -> Result, SchemaError> { + migrate( + value, + CONTRACT_SCHEMA, + CONTRACT_SCHEMA_VERSION, + "unsupported-contract-version", + ) +} +pub fn migrate_theme_replay( + value: Value, +) -> Result, SchemaError> { + migrate( + value, + THEME_REPLAY_SCHEMA, + THEME_REPLAY_SCHEMA_VERSION, + "unsupported-theme-replay-version", + ) +} + +pub fn encode_recipe(value: MenuRecipe) -> Result { + serde_json::to_string_pretty(&WireEnvelope::new( + RECIPE_SCHEMA, + RECIPE_SCHEMA_VERSION, + value, + )) + .map_err(|error| error.to_string()) +} +pub fn encode_replay(value: StorySequence) -> Result { + serde_json::to_string_pretty(&WireEnvelope::new( + REPLAY_SCHEMA, + REPLAY_SCHEMA_VERSION, + value, + )) + .map_err(|error| error.to_string()) +} +pub fn encode_contract(value: StoryContract) -> Result { + serde_json::to_string_pretty(&WireEnvelope::new( + CONTRACT_SCHEMA, + CONTRACT_SCHEMA_VERSION, + value, + )) + .map_err(|error| error.to_string()) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StoryAlias { + pub from: String, + pub to: String, + pub adapter: String, + pub remove_in_version: u16, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ResolvedStoryId { + pub original: String, + pub canonical: String, +} + +#[derive(Clone, Debug, Default)] +pub struct StoryAliases { + aliases: BTreeMap, +} + +impl StoryAliases { + pub fn new( + aliases: impl IntoIterator, + canonical_stories: &BTreeMap, + ) -> Result { + let mut by_from = BTreeMap::new(); + for alias in aliases { + validate_id(&alias.from)?; + validate_id(&alias.to)?; + validate_id(&alias.adapter)?; + if alias.remove_in_version == 0 { + return Err(format!("alias {} needs a removal version", alias.from)); + } + if canonical_stories.contains_key(&alias.from) { + return Err(format!( + "story alias shadows canonical story: {}", + alias.from + )); + } + if by_from.insert(alias.from.clone(), alias).is_some() { + return Err("duplicate story alias".into()); + } + } + let graph = Self { aliases: by_from }; + for id in graph.aliases.keys() { + let mut seen = BTreeSet::new(); + let mut current = id.as_str(); + let adapter = graph.aliases[id].adapter.as_str(); + while let Some(alias) = graph.aliases.get(current) { + if !seen.insert(current.to_owned()) { + return Err(format!("story alias cycle: {id}")); + } + if alias.adapter != adapter { + return Err(format!("story alias crosses adapters: {id}")); + } + current = &alias.to; + } + let target_adapter = canonical_stories + .get(current) + .ok_or_else(|| format!("story alias target does not exist: {current}"))?; + if target_adapter != adapter { + return Err(format!("story alias crosses adapters: {id}")); + } + } + Ok(graph) + } + + #[must_use] + pub fn resolve(&self, id: &str) -> ResolvedStoryId { + let mut canonical = id; + while let Some(alias) = self.aliases.get(canonical) { + canonical = &alias.to; + } + ResolvedStoryId { + original: id.into(), + canonical: canonical.into(), + } + } + + #[must_use] + pub fn contains(&self, id: &str) -> bool { + self.aliases.contains_key(id) + } + + pub fn validate_for_version(&self, current_version: u16) -> Result<(), String> { + if let Some(alias) = self + .aliases + .values() + .find(|alias| alias.remove_in_version <= current_version) + { + return Err(format!("expired story alias: {}", alias.from)); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TerminalProfile { + pub color: String, + pub unicode: bool, + pub motion: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CoverageProfile { + pub id: String, + pub version: u16, + pub terminal: TerminalProfile, + pub geometries: Vec<(u16, u16)>, + pub purpose: String, + pub owner: String, + pub runtime_budget_ms: u64, +} + +impl CoverageProfile { + pub fn validate(&self) -> Result<(), String> { + validate_id(&self.id)?; + validate_id(&self.owner) + .map_err(|_| "coverage profile owner must be a stable ID".to_owned())?; + if self.version != PROFILE_SCHEMA_VERSION { + return Err("unsupported coverage profile version".into()); + } + if self.geometries.is_empty() || self.geometries.len() > MAX_PROFILE_GEOMETRIES { + return Err(format!( + "coverage profile needs 1..{MAX_PROFILE_GEOMETRIES} geometries" + )); + } + let mut unique = BTreeSet::new(); + for &(width, height) in &self.geometries { + validate_dimensions(width, height)?; + if !unique.insert((width, height)) { + return Err("duplicate coverage profile geometry".into()); + } + } + if self.purpose.trim().is_empty() || self.purpose.len() > MAX_PROFILE_TEXT_BYTES { + return Err("coverage profile purpose must be 1..240 bytes".into()); + } + if self.terminal.color != "truecolor" && self.terminal.color != "ansi256" { + return Err("unsupported terminal color profile".into()); + } + if self.runtime_budget_ms == 0 || self.runtime_budget_ms > 300_000 { + return Err("coverage profile runtime budget must be 1..300000 ms".into()); + } + Ok(()) + } + + pub fn select<'a>( + &self, + manifest: &AdapterManifest, + scenes: &'a [Scene], + ) -> Result, String> { + self.validate()?; + if !manifest.profiles.contains(&self.id) { + return Err(format!( + "adapter {} does not support profile {}", + manifest.id, self.id + )); + } + let geometries: BTreeSet<_> = self.geometries.iter().copied().collect(); + Ok(scenes + .iter() + .filter(|scene| geometries.contains(&(scene.width, scene.height))) + .collect()) + } +} + +#[must_use] +pub fn builtin_profiles() -> [CoverageProfile; 2] { + [ + CoverageProfile { + id: "pr-v1".into(), + version: PROFILE_SCHEMA_VERSION, + terminal: TerminalProfile { + color: "truecolor".into(), + unicode: true, + motion: false, + }, + geometries: vec![ + (28, 14), + (30, 14), + (40, 20), + (40, 24), + (60, 20), + (60, 24), + (72, 22), + ], + purpose: "Fast deterministic pull request evidence".into(), + owner: "maestro-ui".into(), + runtime_budget_ms: 30_000, + }, + CoverageProfile { + id: "scheduled-v1".into(), + version: PROFILE_SCHEMA_VERSION, + terminal: TerminalProfile { + color: "truecolor".into(), + unicode: true, + motion: true, + }, + geometries: vec![ + (28, 14), + (30, 14), + (32, 12), + (40, 20), + (40, 24), + (60, 20), + (60, 24), + (60, 32), + (72, 22), + (80, 30), + (100, 30), + (100, 40), + ], + purpose: "Broad scheduled UI contract coverage".into(), + owner: "maestro-ui".into(), + runtime_budget_ms: 120_000, + }, + ] +} + +pub fn builtin_profile(id: &str) -> Result { + builtin_profiles() + .into_iter() + .find(|profile| profile.id == id) + .ok_or_else(|| format!("unknown coverage profile: {id}")) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RendererIdentity { + pub id: String, + pub version: String, + pub source_digest: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CaptureMetadata { + pub capture_version: u16, + pub story_contract_version: u16, + pub replay_version: u16, + pub observation_version: u16, + pub profile_version: u16, + pub profile: ProfileIdentity, + pub renderer: RendererIdentity, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileIdentity { + pub id: String, + pub version: u16, +} + +impl CaptureMetadata { + #[must_use] + pub fn current() -> Self { + Self { + capture_version: CAPTURE_SCHEMA_VERSION, + story_contract_version: CONTRACT_SCHEMA_VERSION, + replay_version: REPLAY_SCHEMA_VERSION, + observation_version: OBSERVATION_SCHEMA_VERSION, + profile_version: PROFILE_SCHEMA_VERSION, + profile: ProfileIdentity { + id: "unprofiled".into(), + version: PROFILE_SCHEMA_VERSION, + }, + renderer: RendererIdentity { + id: "maestro-ui-preview".into(), + version: env!("CARGO_PKG_VERSION").into(), + source_digest: env!("MAESTRO_PREVIEW_SOURCE_DIGEST").into(), + }, + } + } + + #[must_use] + pub fn for_profile(profile: &CoverageProfile) -> Self { + let mut metadata = Self::current(); + metadata.profile = ProfileIdentity { + id: profile.id.clone(), + version: profile.version, + }; + metadata + } +} diff --git a/packages/ui-preview-rs/src/stories/mod.rs b/packages/ui-preview-rs/src/stories/mod.rs new file mode 100644 index 000000000..66cc90050 --- /dev/null +++ b/packages/ui-preview-rs/src/stories/mod.rs @@ -0,0 +1,18 @@ +//! Adapter-local story registration. Studio edits only the bounded macro list. +use crate::registry::Registry; + +macro_rules! registered_stories { + ($($story:ident),* $(,)?) => { + $(pub mod $story;)* + pub fn register(registry: &mut Registry) -> Result<(), String> { + $($story::register(registry)?;)* + Ok(()) + } + }; +} + +registered_stories! { +// maestro-ui-stories:start + shared_menu, +// maestro-ui-stories:end +} diff --git a/packages/ui-preview-rs/src/stories/shared_menu.rs b/packages/ui-preview-rs/src/stories/shared_menu.rs new file mode 100644 index 000000000..6182a55b1 --- /dev/null +++ b/packages/ui-preview-rs/src/stories/shared_menu.rs @@ -0,0 +1,6 @@ +//! Shared Menu and ActionPicker stories owned by maestro-ui. +use crate::registry::Registry; + +pub fn register(registry: &mut Registry) -> Result<(), String> { + crate::menus::register(registry) +} diff --git a/packages/ui-preview-rs/src/web/authoring.js b/packages/ui-preview-rs/src/web/authoring.js new file mode 100644 index 000000000..fe9df2760 --- /dev/null +++ b/packages/ui-preview-rs/src/web/authoring.js @@ -0,0 +1,40 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + (root.MaestroUI ??= {}).authoring = api; +})(globalThis, () => { + const fields = ["id", "label", "title", "placeholder", "empty", "items", "state", "message", "width", "height"]; + function isolateDraft(candidate) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("Invalid draft"); + return structuredClone(candidate); + } + function wire(schema, value) { return { schema, version: 1, value }; } + function unwrap(candidate, schema) { + if (!candidate || typeof candidate !== "object" || !("schema" in candidate)) return candidate; + if (Object.keys(candidate).sort().join(",") !== "schema,value,version" || candidate.schema !== schema || candidate.version !== 1) throw new Error(`Unsupported ${schema} envelope`); + return candidate.value; + } + function recipeValues(recipe) { + return { id: recipe.id, label: recipe.label, title: recipe.title, placeholder: recipe.placeholder, empty: recipe.empty, items: recipe.items.map((item) => `${item.id} | ${item.label}`).join("\n"), state: recipe.state, message: recipe.status_message, width: String(recipe.width), height: String(recipe.height) }; + } + function readRecipe(request, get) { + const items = get("items").value.split(/\r?\n/).filter((line) => line.trim()).map((line) => { + const separator = line.indexOf("|"); + if (separator < 1) throw new Error("Each item needs a stable-id | Display label."); + return { id: line.slice(0, separator).trim(), label: line.slice(separator + 1).trim() }; + }); + return { ...request, id: get("id").value, label: get("label").value, title: get("title").value, placeholder: get("placeholder").value, empty: get("empty").value, items, state: get("state").value, status_message: get("message").value, width: Number(get("width").value), height: Number(get("height").value) }; + } + function formValues(get) { return Object.fromEntries(fields.map((name) => [name, get(name).value])); } + function applyForm(values, get) { for (const name of fields) if (typeof values[name] === "string") get(name).value = values[name]; } + function addInputs(request, inputs, limit = 64) { + if (request.inputs.length + inputs.length > limit) throw new Error(`Sequence limit reached (${limit} inputs).`); + request.inputs.push(...inputs); + return request; + } + function coverageCells(row) { + return [row.variant, row.availability.replaceAll("-", " "), row.exercised ? "Exercised" : "Not exercised", row.automated_check === null ? "Not reported" : row.automated_check ? "Covered" : "Missing"]; + } + function boundedSamples(values, limit = 3) { return values.slice(0, limit); } + return { isolateDraft, wire, unwrap, recipeValues, readRecipe, formValues, applyForm, addInputs, coverageCells, boundedSamples }; +}); diff --git a/packages/ui-preview-rs/src/web/catalog.js b/packages/ui-preview-rs/src/web/catalog.js new file mode 100644 index 000000000..d5977442f --- /dev/null +++ b/packages/ui-preview-rs/src/web/catalog.js @@ -0,0 +1,82 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + (root.MaestroUI ??= {}).catalog = api; +})(globalThis, () => { + const experienceOrder = ["First launch", "Onboarding", "Conversation", "Workspace menu", "Theme selector", "Appearance picker", "Companion appearance", "Other scenes"]; + + function unwrap(value, schema) { + if (!value || typeof value !== "object" || !("schema" in value)) return value; + const keys = Object.keys(value).sort().join(","); + if (keys !== "schema,value,version" || value.schema !== schema || value.version !== 1) throw new Error(`Unsupported ${schema} envelope`); + return value.value; + } + + function group(id) { + if (id.startsWith("first-boot")) return "First launch"; + if (id.startsWith("onboarding-")) return "Onboarding"; + if (id.startsWith("conversation-")) return "Conversation"; + if (id.startsWith("menu-")) return "Workspace menu"; + if (id.startsWith("theme-selector")) return "Theme selector"; + if (id.startsWith("picker")) return "Appearance picker"; + if (["startup", "header", "quiet", "motion-off", "working", "finished", "failed", "needs-input", "pet"].includes(id) || id.startsWith("accessory-") || id.startsWith("accent-")) return "Companion appearance"; + return "Other scenes"; + } + + function stateLabel(capture) { + if (capture.scene.id.startsWith("onboarding-")) { + const id = capture.scene.id.slice(11); + return ({ welcome: "Welcome", role: "Role", "use-case": "Use case", workflow: "Workflow", connect: "Connect", verify: "Verify connection", checking: "Checking", ready: "Results · ready", failed: "Results · failed", waiting: "Waiting", provider: "Provider", key: "API key" })[id] || capture.scene.label; + } + return capture.scene.label.replace(/^.*?\s[\/·]\s/, ""); + } + + function options(element, entries, doc = document) { + const prior = element.value; + element.replaceChildren(...entries.map(([value, label]) => { + const option = doc.createElement("option"); + option.value = value; + option.textContent = label; + return option; + })); + if (entries.some(([value]) => value === prior)) element.value = prior; + } + + function sceneEntries(captures, query = "") { + const needle = query.toLowerCase(); + const entries = new Map(); + for (const capture of captures) { + if (`${capture.scene.id} ${capture.scene.label}`.toLowerCase().includes(needle)) entries.set(capture.scene.id, `${capture.scene.label} / ${capture.scene.id}`); + } + return [...entries]; + } + + function sizeEntries(captures, sceneId) { + const entries = new Map(); + for (const capture of captures) { + if (capture.scene.id === sceneId) entries.set(`${capture.scene.width}x${capture.scene.height}`, `${capture.scene.width} × ${capture.scene.height}`); + } + return [...entries]; + } + + function grouped(entries) { + const groups = new Map(); + for (const [id] of entries) { + const name = group(id); + if (!groups.has(name)) groups.set(name, []); + groups.get(name).push(id); + } + return groups; + } + + function activeGroup(groups, sceneId) { + const current = group(sceneId); + return groups.has(current) ? current : experienceOrder.find((name) => groups.has(name)); + } + + function coverageLabel(state) { + return ({ declared: "Declared", visited: "Visited", "asserted-passed": "Asserted · passed", "asserted-failed": "Asserted · failed", skipped: "Skipped", unavailable: "Unavailable" })[state] || "Unknown"; + } + + return { unwrap, group, stateLabel, options, sceneEntries, sizeEntries, grouped, activeGroup, coverageLabel, experienceOrder }; +}); diff --git a/packages/ui-preview-rs/src/web/playback.js b/packages/ui-preview-rs/src/web/playback.js new file mode 100644 index 000000000..7d53c6f4b --- /dev/null +++ b/packages/ui-preview-rs/src/web/playback.js @@ -0,0 +1,31 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + (root.MaestroUI ??= {}).playback = api; +})(globalThis, () => { + function acceptsGeneration(active, incoming) { return !active || active === incoming; } + function selectedFrames(captures, scene, size) { + return captures.filter((item) => item.scene.id === scene && `${item.scene.width}x${item.scene.height}` === size).sort((left, right) => left.scene.time_ms - right.scene.time_ms); + } + function comparisonFrame(captures, pinned, time) { + if (!pinned) return null; + const frames = captures.filter((capture) => capture.scene.id === pinned.id && capture.scene.width === pinned.width && capture.scene.height === pinned.height).sort((left, right) => left.scene.time_ms - right.scene.time_ms); + return frames.filter((capture) => capture.scene.time_ms <= Number(time)).at(-1) || frames[0] || null; + } + function routeString(scene, size, time, pinned) { + const params = new URLSearchParams({ scene, size, time }); + if (pinned) { params.set("compare", pinned.id); params.set("compareSize", `${pinned.width}x${pinned.height}`); } + return `#${params.toString()}`; + } + function route(params, allowed) { + const result = {}; + for (const key of ["scene", "size", "time", "mode"]) if (allowed[key]?.includes(params[key])) result[key] = params[key]; + return result; + } + function nextDelay(frames, index) { + if (index + 1 < frames.length) return frames[index + 1].scene.time_ms - frames[index].scene.time_ms; + if (frames.length > 1) return frames[1].scene.time_ms - frames[0].scene.time_ms; + return 80; + } + return { acceptsGeneration, selectedFrames, comparisonFrame, routeString, route, nextDelay }; +}); diff --git a/packages/ui-preview-rs/src/web/rendering.js b/packages/ui-preview-rs/src/web/rendering.js new file mode 100644 index 000000000..a49b9d4fc --- /dev/null +++ b/packages/ui-preview-rs/src/web/rendering.js @@ -0,0 +1,50 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + (root.MaestroUI ??= {}).rendering = api; +})(globalThis, () => { + const colors = ["#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0", "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff"]; + function color(value, fallback) { + if (value === "reset") return fallback; + if (!value.startsWith("indexed:")) return value; + let index = Number(value.slice(8)); + if (index < 16) return colors[index]; + if (index >= 232) { const channel = 8 + (index - 232) * 10; return `rgb(${channel},${channel},${channel})`; } + index -= 16; + const ramp = [0, 95, 135, 175, 215, 255]; + return `rgb(${ramp[Math.floor(index / 36)]},${ramp[Math.floor(index / 6) % 6]},${ramp[index % 6]})`; + } + function glyph(cell) { return cell.modifiers & 128 ? "" : cell.text; } + function draw(screen, capture, doc = document) { + screen.replaceChildren(); + const { width, height } = capture.scene; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width;) { + const cell = capture.cells[y * width + x], span = doc.createElement("span"), mark = cell.modifiers; + let foreground = color(cell.foreground, "#efebff"), background = color(cell.background, "#171624"); + if (mark & 64) [foreground, background] = [background, foreground]; + span.className = "cell"; + const text = doc.createElement("span"); + text.textContent = cell.text; text.style.opacity = mark & 2 ? ".55" : "1"; text.style.visibility = mark & 128 ? "hidden" : "visible"; + span.append(text); + span.style.cssText = `width:${cell.columns}ch;color:${foreground};background:${background};font-weight:${mark & 1 ? "700" : "400"};font-style:${mark & 4 ? "italic" : "normal"};text-decoration:${[mark & 8 ? "underline" : "", mark & 256 ? "line-through" : ""].filter(Boolean).join(" ") || "none"}`; + screen.append(span); x += cell.columns; + } + screen.append(doc.createTextNode("\n")); + } + } + function readable(capture) { + const lines = []; + for (let y = 0; y < capture.scene.height; y++) { + let line = ""; + for (let x = 0; x < capture.scene.width;) { const cell = capture.cells[y * capture.scene.width + x]; line += cell.modifiers & 128 ? " ".repeat(cell.columns) : cell.text; x += cell.columns; } + lines.push(line.trimEnd()); + } + while (lines.at(-1) === "") lines.pop(); + return lines.join("\n"); + } + function semanticStatus(result) { if (!result) return "Not asserted"; return result.expectations?.some((item) => !item.passed) ? "Failed" : "Passed"; } + function samples(values, limit = 3) { return { values: values.slice(0, limit), truncated: values.length > limit }; } + function sourceUrl(path) { return path ? `https://github.com/dx-corp/mono/blob/main/${path.split("/").map(encodeURIComponent).join("/")}` : ""; } + return { color, glyph, draw, readable, semanticStatus, samples, sourceUrl }; +}); diff --git a/packages/ui-preview-rs/src/web/tests.mjs b/packages/ui-preview-rs/src/web/tests.mjs new file mode 100644 index 000000000..fbd8308ea --- /dev/null +++ b/packages/ui-preview-rs/src/web/tests.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +await import("./catalog.js"); +await import("./playback.js"); +await import("./authoring.js"); +await import("./rendering.js"); +const { catalog, playback, authoring, rendering } = globalThis.MaestroUI; + +const hiddenCapture = { + scene: { id: "menu-ready", label: "Menu / Ready", width: 2, height: 1, time_ms: 0 }, + cells: [ + { text: "x", modifiers: 128, columns: 1 }, + { text: "y", modifiers: 0, columns: 1 }, + ], +}; +assert.equal(rendering.glyph(hiddenCapture.cells[0]), ""); +assert.equal(rendering.readable(hiddenCapture), " y"); +assert.equal(rendering.semanticStatus({ expectations: [{ passed: false }] }), "Failed"); +assert.equal(rendering.semanticStatus({ expectations: [{ passed: true }] }), "Passed"); + +assert.equal(playback.acceptsGeneration("g2", "g1"), false); +assert.equal(playback.acceptsGeneration("g2", "g2"), true); +const frames = [ + { scene: { id: "menu-ready", width: 40, height: 20, time_ms: 80 } }, + { scene: { id: "menu-ready", width: 40, height: 20, time_ms: 0 } }, +]; +assert.deepEqual(playback.selectedFrames(frames, "menu-ready", "40x20").map((frame) => frame.scene.time_ms), [0, 80]); +assert.equal(playback.comparisonFrame(frames, { id: "menu-ready", width: 40, height: 20 }, 40).scene.time_ms, 0); +assert.equal(playback.routeString("menu ready", "40x20", "80", { id: "base", width: 40, height: 20 }), "#scene=menu+ready&size=40x20&time=80&compare=base&compareSize=40x20"); +assert.deepEqual(playback.route({ scene: "a", mode: "bad" }, { scene: ["a"], mode: ["side"] }), { scene: "a" }); + +const captures = [{ scene: { id: "menu-ready", label: "Menu / Ready", width: 40, height: 20 } }]; +assert.deepEqual(catalog.sceneEntries(captures, "READY"), [["menu-ready", "Menu / Ready / menu-ready"]]); +assert.deepEqual(catalog.sizeEntries(captures, "menu-ready"), [["40x20", "40 × 20"]]); +assert.deepEqual([...catalog.grouped([["menu-ready", "Ready"]])], [["Workspace menu", ["menu-ready"]]]); +assert.equal(catalog.coverageLabel("asserted-passed"), "Asserted · passed"); +assert.equal(catalog.coverageLabel("skipped"), "Skipped"); +assert.deepEqual(catalog.unwrap({ schema: "maestro.ui.replay", version: 1, value: { id: "x" } }, "maestro.ui.replay"), { id: "x" }); + +assert.deepEqual(rendering.samples([1, 2, 3, 4], 3), { values: [1, 2, 3], truncated: true }); +assert.deepEqual(authoring.boundedSamples([1, 2, 3, 4], 2), [1, 2]); +assert.throws(() => authoring.isolateDraft(null), /Invalid draft/); +const fields = Object.fromEntries(["id", "label", "title", "placeholder", "empty", "items", "state", "message", "width", "height"].map((name) => [name, { value: "" }])); +fields.items.value = "missing separator"; +assert.throws(() => authoring.readRecipe({ inputs: [] }, (name) => fields[name]), /stable-id/); +assert.deepEqual(authoring.coverageCells({ variant: "error", availability: "fixture-only", exercised: false, automated_check: null }), ["error", "fixture only", "Not exercised", "Not reported"]); +assert.throws(() => authoring.addInputs({ inputs: [1, 2] }, [3], 2), /Sequence limit/); + +console.log("24 browser module assertions passed"); diff --git a/packages/ui-preview-rs/tests/compatibility.rs b/packages/ui-preview-rs/tests/compatibility.rs new file mode 100644 index 000000000..01b02ab34 --- /dev/null +++ b/packages/ui-preview-rs/tests/compatibility.rs @@ -0,0 +1,294 @@ +use maestro_ui_preview::{ + Scene, + adapters::{AdapterManifest, StoryTemplate}, + schema::{ + CONTRACT_SCHEMA, CONTRACT_SCHEMA_VERSION, CoverageProfile, RECIPE_SCHEMA, + RECIPE_SCHEMA_VERSION, REPLAY_SCHEMA, REPLAY_SCHEMA_VERSION, StoryAlias, StoryAliases, + TerminalProfile, WireEnvelope, builtin_profiles, encode_contract, encode_recipe, + encode_replay, migrate_contract, migrate_recipe, migrate_replay, migrate_theme_replay, + }, +}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +fn fixture(name: &str) -> Value { + let source = match name { + "recipe-v1.json" => include_str!("fixtures/recipe-v1.json"), + "replay-v1.json" => include_str!("fixtures/replay-v1.json"), + "contract-v1.json" => include_str!("fixtures/contract-v1.json"), + _ => panic!("unknown fixture"), + }; + serde_json::from_str(source).unwrap() +} + +#[test] +fn compatibility_corpus_migrates_to_current_envelopes() { + let recipe = migrate_recipe(fixture("recipe-v1.json")).unwrap(); + assert_eq!(recipe.schema, RECIPE_SCHEMA); + assert_eq!(recipe.version, RECIPE_SCHEMA_VERSION); + assert_eq!(recipe.value.id, "workspace-picker"); + let round_trip: WireEnvelope = + serde_json::from_value(serde_json::to_value(&recipe).unwrap()).unwrap(); + assert_eq!(round_trip.schema, RECIPE_SCHEMA); + + let replay = migrate_replay(fixture("replay-v1.json")).unwrap(); + assert_eq!( + (replay.schema.as_str(), replay.version), + (REPLAY_SCHEMA, REPLAY_SCHEMA_VERSION) + ); + assert_eq!(replay.value.inputs.len(), 1); + + let contract = migrate_contract(fixture("contract-v1.json")).unwrap(); + assert_eq!( + (contract.schema.as_str(), contract.version), + (CONTRACT_SCHEMA, CONTRACT_SCHEMA_VERSION) + ); + contract.value.validate().unwrap(); + + assert_eq!( + serde_json::from_str::(&encode_recipe(recipe.value).unwrap()).unwrap()["schema"], + RECIPE_SCHEMA + ); + assert_eq!( + serde_json::from_str::(&encode_replay(replay.value).unwrap()).unwrap()["schema"], + REPLAY_SCHEMA + ); + assert_eq!( + serde_json::from_str::(&encode_contract(contract.value).unwrap()).unwrap()["schema"], + CONTRACT_SCHEMA + ); +} + +#[test] +fn unknown_versions_schemas_and_extra_envelope_fields_fail_closed() { + assert_eq!( + migrate_recipe(json!({"version": 99})).unwrap_err().code(), + "unsupported-recipe-version" + ); + assert_eq!( + migrate_replay(json!({"schema": REPLAY_SCHEMA, "version": 99, "value": {}})) + .unwrap_err() + .code(), + "unsupported-replay-version" + ); + assert_eq!( + migrate_contract(json!({"schema": "other", "version": 1, "value": {}})) + .unwrap_err() + .code(), + "unexpected-schema" + ); + assert_eq!( + migrate_recipe(json!({"schema": RECIPE_SCHEMA, "version": 1, "value": {}, "extra": true})) + .unwrap_err() + .code(), + "invalid-wire-envelope" + ); +} + +#[test] +fn theme_replay_has_a_distinct_envelope_and_legacy_reader() { + let raw = json!({"version": 1, "fixture": "ready", "width": 72, "height": 22, "inputs": []}); + let legacy = migrate_theme_replay::(raw.clone()).unwrap(); + assert_eq!(legacy.schema, "maestro.ui.theme-replay"); + assert_eq!(legacy.value, raw); + let wrapped = migrate_theme_replay::(json!({ + "schema": "maestro.ui.theme-replay", "version": 1, "value": raw + })) + .unwrap(); + assert_eq!(wrapped.version, 1); + assert_eq!( + migrate_theme_replay::(json!({ + "schema": "maestro.ui.theme-replay", "version": 9, "value": {} + })) + .unwrap_err() + .code(), + "unsupported-theme-replay-version" + ); +} + +#[test] +fn aliases_reject_ambiguity_cycles_missing_targets_and_adapter_crossings() { + let stories = BTreeMap::from([ + ("workspace-picker".into(), "shared-menu".into()), + ("theme-selector".into(), "theme-selector".into()), + ]); + let alias = |from: &str, to: &str, adapter: &str| StoryAlias { + from: from.into(), + to: to.into(), + adapter: adapter.into(), + remove_in_version: 2, + }; + let graph = StoryAliases::new( + [alias("old-picker", "workspace-picker", "shared-menu")], + &stories, + ) + .unwrap(); + assert_eq!(graph.resolve("old-picker").original, "old-picker"); + assert_eq!(graph.resolve("old-picker").canonical, "workspace-picker"); + assert!(graph.validate_for_version(1).is_ok()); + assert!( + graph + .validate_for_version(2) + .unwrap_err() + .contains("expired") + ); + assert!( + StoryAliases::new( + [ + alias("old-picker", "workspace-picker", "shared-menu"), + alias("old-picker", "workspace-picker", "shared-menu"), + ], + &stories + ) + .unwrap_err() + .contains("duplicate") + ); + assert!( + StoryAliases::new( + [ + alias("old-a", "old-b", "shared-menu"), + alias("old-b", "old-a", "shared-menu"), + ], + &stories + ) + .unwrap_err() + .contains("cycle") + ); + assert!( + StoryAliases::new([alias("old-picker", "missing", "shared-menu")], &stories) + .unwrap_err() + .contains("does not exist") + ); + assert!( + StoryAliases::new( + [alias("old-picker", "theme-selector", "shared-menu")], + &stories + ) + .unwrap_err() + .contains("crosses adapters") + ); + let mut no_expiry = alias("old-picker", "workspace-picker", "shared-menu"); + no_expiry.remove_in_version = 0; + assert!( + StoryAliases::new([no_expiry], &stories) + .unwrap_err() + .contains("removal version") + ); + assert!( + StoryAliases::new( + [alias("workspace-picker", "theme-selector", "shared-menu")], + &stories + ) + .unwrap_err() + .contains("shadows canonical") + ); + + let mut registry = maestro_ui_preview::registry::Registry::default(); + registry + .add( + maestro_ui_preview::registry::Story::new( + "workspace-picker", + "Workspace picker", + "fixture.rs", + |_, _| {}, + ) + .adapter("shared-menu"), + ) + .unwrap(); + registry + .add_aliases([alias("old-picker", "workspace-picker", "shared-menu")]) + .unwrap(); + let capture = registry + .filtered(maestro_ui_preview::StoryFilter::Story("old-picker".into())) + .unwrap() + .captures() + .unwrap() + .pop() + .unwrap(); + let resolved = capture.resolved_story.unwrap(); + assert_eq!(resolved.original, "old-picker"); + assert_eq!(resolved.canonical, "workspace-picker"); +} + +#[test] +fn named_profiles_are_bounded_and_select_existing_cases_only() { + for profile in builtin_profiles() { + profile.validate().unwrap(); + } + let profile = &builtin_profiles()[0]; + let manifest = AdapterManifest::active( + "shared-menu", + "maestro-ui", + "fixtures", + "registration.rs", + StoryTemplate::MenuRecipe, + ); + let scenes = vec![ + Scene { + id: "menu".into(), + label: "Menu".into(), + width: 40, + height: 20, + time_ms: 0, + }, + Scene { + id: "menu".into(), + label: "Menu".into(), + width: 80, + height: 30, + time_ms: 0, + }, + ]; + let selected = profile.select(&manifest, &scenes).unwrap(); + assert_eq!(selected.len(), 1); + assert_eq!((selected[0].width, selected[0].height), (40, 20)); + + let invalid = CoverageProfile { + id: "bad".into(), + version: 1, + terminal: TerminalProfile { + color: "truecolor".into(), + unicode: true, + motion: false, + }, + geometries: vec![(40, 20), (40, 20)], + purpose: "duplicate geometry".into(), + owner: "maestro-ui".into(), + runtime_budget_ms: 1, + }; + assert!(invalid.validate().unwrap_err().contains("duplicate")); +} + +#[test] +fn captures_carry_all_schema_and_renderer_versions_without_changing_cells() { + let capture = maestro_ui_preview::review::capture( + Scene { + id: "fixture".into(), + label: "Fixture".into(), + width: 8, + height: 3, + time_ms: 0, + }, + |_| {}, + ) + .unwrap(); + let json = serde_json::to_value(capture).unwrap(); + let metadata = &json["metadata"]; + for key in [ + "capture_version", + "story_contract_version", + "replay_version", + "observation_version", + "profile_version", + ] { + assert_eq!(metadata[key], 1, "missing {key}"); + } + assert_eq!(metadata["renderer"]["id"], "maestro-ui-preview"); + assert_eq!(metadata["profile"]["id"], "unprofiled"); + assert_eq!(metadata["profile"]["version"], 1); + assert!( + json["cells"] + .as_array() + .is_some_and(|cells| !cells.is_empty()) + ); +} diff --git a/packages/ui-preview-rs/tests/fixtures/contract-v1.json b/packages/ui-preview-rs/tests/fixtures/contract-v1.json new file mode 100644 index 000000000..deb5fa537 --- /dev/null +++ b/packages/ui-preview-rs/tests/fixtures/contract-v1.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "story_id": "workspace-picker", + "owner": "maestro-ui", + "expectations": [ + {"kind": "observation-equals", "name": "state", "value": "ready"} + ] +} diff --git a/packages/ui-preview-rs/tests/fixtures/recipe-v1.json b/packages/ui-preview-rs/tests/fixtures/recipe-v1.json new file mode 100644 index 000000000..266849ee2 --- /dev/null +++ b/packages/ui-preview-rs/tests/fixtures/recipe-v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "id": "workspace-picker", + "label": "Workspace picker", + "title": "Choose a workspace", + "placeholder": "Filter workspaces", + "empty": "No matching workspaces", + "items": [{"id": "docs", "label": "Documentation"}], + "state": "ready", + "status_message": "", + "width": 60, + "height": 20, + "inputs": [{"type": "text", "text": "doc"}] +} diff --git a/packages/ui-preview-rs/tests/fixtures/replay-v1.json b/packages/ui-preview-rs/tests/fixtures/replay-v1.json new file mode 100644 index 000000000..be647e40c --- /dev/null +++ b/packages/ui-preview-rs/tests/fixtures/replay-v1.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "id": "workspace-picker", + "label": "Workspace picker", + "width": 60, + "height": 20, + "inputs": [{"type": "key", "key": "down", "ctrl": false}] +} diff --git a/packages/ui-preview-rs/tests/studio_cli.rs b/packages/ui-preview-rs/tests/studio_cli.rs new file mode 100644 index 000000000..e7f8f49a6 --- /dev/null +++ b/packages/ui-preview-rs/tests/studio_cli.rs @@ -0,0 +1,157 @@ +use std::process::Command; + +fn run(args: &[&str]) -> std::process::Output { + let test_exe = std::env::current_exe().unwrap(); + let binary = test_exe + .parent() + .unwrap() + .parent() + .unwrap() + .join("maestro-ui-preview"); + Command::new(binary) + .args(args) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap() +} + +#[test] +fn parser_fails_closed_for_missing_ambiguous_and_unknown_arguments() { + for (args, message) in [ + (vec!["studio", "new", "story"], "requires --adapter"), + ( + vec!["studio", "new", "--adapter", "missing", "story"], + "unknown adapter", + ), + ( + vec!["studio", "new", "--adapter", "shared-menu", "one", "two"], + "exactly one", + ), + (vec!["studio", "new", "--wat", "story"], "unknown argument"), + ( + vec![ + "studio", + "new", + "--adapter", + "shared-menu", + "--adapter", + "theme-selector", + "story", + ], + "may be specified once", + ), + ] { + let output = run(&args); + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains(message)); + } +} + +#[test] +fn new_check_prints_a_non_mutating_plan() { + let output = run(&[ + "studio", + "new", + "--adapter", + "theme-selector", + "workspace-picker", + "--check", + ]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let plan: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(plan["adapter"], "theme-selector"); + assert_eq!(plan["story_id"], "workspace-picker"); + assert!( + plan["fixture"] + .as_str() + .unwrap() + .ends_with("support/ui_stories/workspace_picker.rs") + ); +} + +#[test] +fn verify_uses_registered_story_results() { + let output = run(&["studio", "verify", "menu-ready"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let captures: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!( + captures + .as_array() + .is_some_and(|captures| captures.len() == 3) + ); +} + +#[test] +fn coverage_command_uses_profile_evaluation_and_declared_inventory() { + let output = run(&["studio", "coverage"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let coverage = report["coverage"].as_array().unwrap(); + assert_eq!(coverage.len(), 2); + assert_eq!(coverage[0]["profile"]["id"], "pr-v1"); + assert!(!coverage[0]["declared"].as_array().unwrap().is_empty()); + let pr_states = coverage[0]["states"].as_object().unwrap(); + assert!(pr_states.values().any(|state| state == "unavailable")); + let scheduled_states = coverage[1]["states"].as_object().unwrap(); + assert!(scheduled_states.values().any(|state| state == "skipped")); + assert!(!report["contributions"].as_array().unwrap().is_empty()); +} + +#[test] +fn catalog_filters_are_explicit_and_reject_ansi_ambiguity() { + let output = run(&["--json", "--story", "menu-ready"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let captures: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(captures.as_array().unwrap().len(), 3); + assert_eq!(captures[0]["filter"]["kind"], "story"); + let adapter = run(&["--list", "--adapter", "shared-menu"]); + assert!( + adapter.status.success(), + "{}", + String::from_utf8_lossy(&adapter.stderr) + ); + let listed: serde_json::Value = serde_json::from_slice(&adapter.stdout).unwrap(); + assert_eq!(listed["filter"]["kind"], "adapter"); + assert!(listed["scenes"].as_array().unwrap().len() > 7); + let invalid = run(&["--story", "menu-ready"]); + assert_eq!(invalid.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid.stderr).contains("require --html")); +} + +#[test] +fn named_profile_export_selects_and_stamps_only_profile_cases() { + let output = run(&["--json", "--profile", "pr-v1"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let captures: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let captures = captures.as_array().unwrap(); + assert!(!captures.is_empty()); + assert!(captures.iter().all(|capture| { + capture + .pointer("/metadata/profile/id") + .and_then(|id| id.as_str()) + == Some("pr-v1") + })); + let unknown = run(&["--json", "--profile", "unknown"]); + assert_eq!(unknown.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&unknown.stderr).contains("unknown coverage profile")); +} diff --git a/scripts/dev/test-ui-workbench.py b/scripts/dev/test-ui-workbench.py index 18a8b856b..b4e03f83a 100644 --- a/scripts/dev/test-ui-workbench.py +++ b/scripts/dev/test-ui-workbench.py @@ -1,76 +1,155 @@ """Rebuild failures must not replace the last successfully rendered artifact.""" + import importlib.util import http.client import json +import os from pathlib import Path import subprocess import tempfile import threading +import time from types import SimpleNamespace import unittest from unittest.mock import patch -spec = importlib.util.spec_from_file_location('workbench', Path(__file__).with_name('ui-workbench.py')) +spec = importlib.util.spec_from_file_location( + "workbench", Path(__file__).with_name("ui-workbench.py") +) workbench = importlib.util.module_from_spec(spec) spec.loader.exec_module(workbench) class WorkbenchTests(unittest.TestCase): + @staticmethod + def generation( + identifier="g1", + replay=("fixed-renderer", "--replay-stdin"), + studio=("fixed-renderer", "--studio-stdin"), + ): + return workbench.Generation( + id=identifier, + renderer_digest="renderer-" + identifier, + source_digest="source-" + identifier, + filter_kind="", + filter_value="", + html=b"valid", + sequences=b"{}", + built_at_monotonic=1.0, + build_ms=12, + replay_command=tuple(replay), + studio_command=tuple(studio), + ) + @staticmethod def menu_recipe(): return { - 'version': 1, - 'id': 'new-menu', - 'label': 'New menu', - 'title': 'Choose a workspace', - 'placeholder': 'Filter workspaces', - 'empty': 'No workspaces', - 'items': [ - {'id': 'application', 'label': 'Application'}, - {'id': 'docs', 'label': 'Docs 夜'}, + "version": 1, + "id": "new-menu", + "label": "New menu", + "title": "Choose a workspace", + "placeholder": "Filter workspaces", + "empty": "No workspaces", + "items": [ + {"id": "application", "label": "Application"}, + {"id": "docs", "label": "Docs 夜"}, ], - 'state': 'ready', - 'status_message': '', - 'width': 60, - 'height': 20, - 'inputs': [], + "state": "ready", + "status_message": "", + "width": 60, + "height": 20, + "inputs": [], } def test_failure_preserves_previous_capture_and_revision(self): - with tempfile.TemporaryDirectory() as directory, patch.object(workbench, 'WORKSPACE', Path(directory)): - preview = workbench.Preview(['cargo'], {}) - with patch.object(workbench.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, b'valid', b'')): + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(workbench, "WORKSPACE", Path(directory)), + ): + preview = workbench.Preview(["cargo"], {}) + with patch.object( + workbench.subprocess, + "run", + return_value=subprocess.CompletedProcess( + [], 0, b"valid", b"" + ), + ): preview.build() revision = preview.revision - with patch.object(workbench.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'partial', b'compile failed')): + with patch.object( + workbench.subprocess, + "run", + return_value=subprocess.CompletedProcess( + [], 1, b"partial", b"compile failed" + ), + ): preview.build() - self.assertEqual(preview.html, b'valid') + self.assertEqual(preview.html, b"valid") self.assertEqual(preview.revision, revision) - self.assertIn('compile failed', preview.error) + self.assertIn("compile failed", preview.error) def test_watches_new_source_but_not_generated_artifacts(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - (root/'packages').mkdir() + (root / "packages").mkdir() before = workbench.fingerprint(root) - (root/'packages/new.rs').write_text('source') + (root / "packages/new.rs").write_text("source") changed = workbench.fingerprint(root) self.assertNotEqual(before, changed) - (root/'preview.html').write_text('generated') + (root / "preview.html").write_text("generated") self.assertEqual(changed, workbench.fingerprint(root)) + def test_bundled_web_module_edit_changes_the_renderer_fingerprint(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + module = workspace / "packages/ui-preview-rs/src/web/catalog.js" + module.parent.mkdir(parents=True) + module.write_text("export const catalog = 1;", encoding="utf-8") + before = workbench.fingerprint(workspace) + digest_before = workbench.source_content_digest(workspace) + module.write_text("export const catalog = 2;", encoding="utf-8") + preview = SimpleNamespace(manifests={}, build=lambda: None) + with patch.object(preview, "build") as build: + after = workbench.rebuild_changed_sources(preview, before, workspace) + build.assert_called_once_with() + self.assertNotEqual(before, after) + self.assertNotEqual( + digest_before, workbench.source_content_digest(workspace) + ) + def test_replay_validation_bounds_events_text_and_dimensions(self): - request = {'version': 1, 'fixture': 'ready', 'width': 60, 'height': 20, 'inputs': [ - {'type': 'text', 'text': '夜'}, - {'type': 'key', 'key': 'down', 'ctrl': False}, - {'type': 'resize', 'width': 30, 'height': 12}, - ]} + request = { + "version": 1, + "fixture": "ready", + "width": 60, + "height": 20, + "inputs": [ + {"type": "text", "text": "夜"}, + {"type": "key", "key": "down", "ctrl": False}, + {"type": "resize", "width": 30, "height": 12}, + ], + } workbench.validate_replay(json.dumps(request).encode()) + workbench.validate_replay( + json.dumps( + {"schema": "maestro.ui.theme-replay", "version": 1, "value": request} + ).encode() + ) + with self.assertRaisesRegex(ValueError, "unsupported"): + workbench.validate_replay( + json.dumps( + { + "schema": "maestro.ui.theme-replay", + "version": 99, + "value": request, + } + ).encode() + ) for change in ( lambda value: value.update(width=241), - lambda value: value['inputs'].append({'type': 'text', 'text': '\n'}), - lambda value: value.update(inputs=[{'type': 'retry'}] * 65), - lambda value: value.update(inputs=[{'type': 'text', 'text': 'x' * 4097}]), + lambda value: value["inputs"].append({"type": "text", "text": "\n"}), + lambda value: value.update(inputs=[{"type": "retry"}] * 65), + lambda value: value.update(inputs=[{"type": "text", "text": "x" * 4097}]), ): invalid = json.loads(json.dumps(request)) change(invalid) @@ -78,26 +157,44 @@ def test_replay_validation_bounds_events_text_and_dimensions(self): workbench.validate_replay(json.dumps(invalid).encode()) def test_replay_invokes_only_fixed_command_with_structured_stdin(self): - body = json.dumps({'version': 1, 'fixture': 'ready', 'width': 60, 'height': 20, 'inputs': []}).encode() - preview = workbench.Preview(['renderer', '--html'], {}, replay_command=['fixed-renderer', '--replay-stdin']) - result = subprocess.CompletedProcess([], 0, b'{"capture":{},"effects":[]}', b'') - with patch.object(workbench.subprocess, 'run', return_value=result) as run: - self.assertEqual(preview.replay(body), result.stdout) - self.assertEqual(run.call_args.args[0], ['fixed-renderer', '--replay-stdin']) - self.assertEqual(run.call_args.kwargs['input'], body) - self.assertNotIn('shell', run.call_args.kwargs) + body = json.dumps( + {"version": 1, "fixture": "ready", "width": 60, "height": 20, "inputs": []} + ).encode() + preview = workbench.Preview( + ["renderer", "--html"], + {}, + replay_command=["fixed-renderer", "--replay-stdin"], + ) + preview.install_generation(self.generation()) + result = subprocess.CompletedProcess([], 0, b'{"capture":{},"effects":[]}', b"") + with patch.object(workbench.subprocess, "run", return_value=result) as run: + self.assertEqual(json.loads(preview.replay(body))["generation"], "g1") + self.assertEqual(run.call_args.args[0], ("fixed-renderer", "--replay-stdin")) + self.assertEqual(run.call_args.kwargs["input"], body) + self.assertNotIn("shell", run.call_args.kwargs) def test_studio_validates_typed_items_and_invokes_only_fixed_command(self): request = self.menu_recipe() body = json.dumps(request).encode() workbench.validate_studio(body) + workbench.validate_studio( + json.dumps( + {"schema": "maestro.ui.menu-recipe", "version": 1, "value": request} + ).encode() + ) + with self.assertRaisesRegex(ValueError, "unsupported"): + workbench.validate_studio( + json.dumps( + {"schema": "maestro.ui.menu-recipe", "version": 2, "value": request} + ).encode() + ) for mutate in ( - lambda value: value['items'].append({'id': 'docs', 'label': 'Duplicate'}), - lambda value: value['items'][0].update(id='../command'), - lambda value: value.update(path='/tmp/output'), - lambda value: value.update(inputs=[{'type': 'retry'}] * 65), + lambda value: value["items"].append({"id": "docs", "label": "Duplicate"}), + lambda value: value["items"][0].update(id="../command"), + lambda value: value.update(path="/tmp/output"), + lambda value: value.update(inputs=[{"type": "retry"}] * 65), lambda value: value.update(state={}), - lambda value: value.update(inputs=[{'type': {}}]), + lambda value: value.update(inputs=[{"type": {}}]), ): invalid = json.loads(json.dumps(request)) mutate(invalid) @@ -105,83 +202,368 @@ def test_studio_validates_typed_items_and_invokes_only_fixed_command(self): workbench.validate_studio(json.dumps(invalid).encode()) preview = workbench.Preview( - ['renderer', '--html'], + ["renderer", "--html"], {}, - studio_command=['fixed-renderer', '--studio-stdin'], + studio_command=["fixed-renderer", "--studio-stdin"], + ) + preview.install_generation(self.generation()) + result = subprocess.CompletedProcess( + [], 0, b'{"capture":{},"coverage":[]}', b"" ) - result = subprocess.CompletedProcess([], 0, b'{"capture":{},"coverage":[]}', b'') - with patch.object(workbench.subprocess, 'run', return_value=result) as run: - self.assertEqual(preview.studio(body), result.stdout) - self.assertEqual(run.call_args.args[0], ['fixed-renderer', '--studio-stdin']) - self.assertEqual(run.call_args.kwargs['input'], body) - self.assertNotIn('shell', run.call_args.kwargs) + with patch.object(workbench.subprocess, "run", return_value=result) as run: + self.assertEqual(json.loads(preview.studio(body))["generation"], "g1") + self.assertEqual(run.call_args.args[0], ("fixed-renderer", "--studio-stdin")) + self.assertEqual(run.call_args.kwargs["input"], body) + self.assertNotIn("shell", run.call_args.kwargs) def test_invalid_studio_edit_cannot_replay_the_previous_valid_request(self): valid = json.dumps(self.menu_recipe()).encode() invalid_recipe = self.menu_recipe() - invalid_recipe['items'] = [{'id': 'unfinished-id'}] + invalid_recipe["items"] = [{"id": "unfinished-id"}] invalid = json.dumps(invalid_recipe).encode() preview = workbench.Preview( - ['renderer', '--html'], + ["renderer", "--html"], {}, - studio_command=['fixed-renderer', '--studio-stdin'], + studio_command=["fixed-renderer", "--studio-stdin"], + ) + preview.install_generation(self.generation()) + result = subprocess.CompletedProcess( + [], 0, b'{"capture":{},"coverage":[]}', b"" ) - result = subprocess.CompletedProcess([], 0, b'{"capture":{},"coverage":[]}', b'') - with patch.object(workbench.subprocess, 'run', return_value=result) as run: + with patch.object(workbench.subprocess, "run", return_value=result) as run: preview.studio(valid) with self.assertRaises(ValueError): preview.studio(invalid) run.assert_called_once() - self.assertEqual(run.call_args.kwargs['input'], valid) + self.assertEqual(run.call_args.kwargs["input"], valid) def test_http_replay_rejects_foreign_origin_and_accepts_same_origin(self): seen = [] preview = SimpleNamespace( - html=b'', sequences=b'{"fixtures":[],"presets":[]}', revision='r', error='', + html=b"", + sequences=b'{"fixtures":[],"presets":[]}', + revision="r", + error="", + revision_state=lambda: {"generation": "r", "error": ""}, replay=lambda body: seen.append(body) or b'{"capture":{},"effects":[]}', studio=lambda body: seen.append(body) or b'{"capture":{},"coverage":[]}', ) - server = workbench.ThreadingHTTPServer(('127.0.0.1', 0), workbench.handler(preview)) + server = workbench.ThreadingHTTPServer( + ("127.0.0.1", 0), workbench.handler(preview) + ) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() port = server.server_port - body = json.dumps({'version': 1, 'fixture': 'ready', 'width': 60, 'height': 20, 'inputs': []}) + body = json.dumps( + {"version": 1, "fixture": "ready", "width": 60, "height": 20, "inputs": []} + ) try: - connection = http.client.HTTPConnection('127.0.0.1', port) - connection.request('POST', '/__replay', body, { - 'Content-Type': 'application/json', 'Origin': 'https://attacker.example', - }) + connection = http.client.HTTPConnection("127.0.0.1", port) + connection.request( + "POST", + "/__replay", + body, + { + "Content-Type": "application/json", + "Origin": "https://attacker.example", + }, + ) self.assertEqual(connection.getresponse().status, 403) self.assertEqual(seen, []) - connection = http.client.HTTPConnection('127.0.0.1', port) - connection.request('POST', '/__replay', body, { - 'Content-Type': 'application/json', 'Origin': f'http://127.0.0.1:{port}', - }) + connection = http.client.HTTPConnection("127.0.0.1", port) + connection.request( + "POST", + "/__replay", + body, + { + "Content-Type": "application/json", + "Origin": f"http://127.0.0.1:{port}", + }, + ) response = connection.getresponse() self.assertEqual(response.status, 200) - self.assertEqual(json.loads(response.read()), {'capture': {}, 'effects': []}) + self.assertEqual( + json.loads(response.read()), {"capture": {}, "effects": []} + ) self.assertEqual(seen, [body.encode()]) studio = json.dumps(self.menu_recipe()) - connection = http.client.HTTPConnection('127.0.0.1', port) - connection.request('POST', '/__studio', studio, { - 'Content-Type': 'application/json', 'Origin': f'http://localhost:{port}', - }) + connection = http.client.HTTPConnection("127.0.0.1", port) + connection.request( + "POST", + "/__studio", + studio, + { + "Content-Type": "application/json", + "Origin": f"http://localhost:{port}", + }, + ) response = connection.getresponse() self.assertEqual(response.status, 200) - self.assertEqual(json.loads(response.read()), {'capture': {}, 'coverage': []}) + self.assertEqual( + json.loads(response.read()), {"capture": {}, "coverage": []} + ) self.assertEqual(seen[-1], studio.encode()) finally: server.shutdown() server.server_close() def test_host_and_origin_parsing_fail_closed(self): - self.assertTrue(workbench.valid_host('localhost:8770', 8770)) - self.assertFalse(workbench.valid_host('example.com:8770', 8770)) - self.assertTrue(workbench.valid_origin('http://127.0.0.1:8770', 8770)) - self.assertFalse(workbench.valid_origin('http://127.0.0.1:bad', 8770)) + self.assertTrue(workbench.valid_host("localhost:8770", 8770)) + self.assertFalse(workbench.valid_host("example.com:8770", 8770)) + self.assertTrue(workbench.valid_origin("http://127.0.0.1:8770", 8770)) + self.assertFalse(workbench.valid_origin("http://127.0.0.1:bad", 8770)) + + def test_failed_and_stale_builds_never_replace_last_good_generation(self): + body = json.dumps( + {"version": 1, "fixture": "ready", "width": 60, "height": 20, "inputs": []} + ).encode() + preview = workbench.Preview( + ["renderer"], {}, replay_command=["shared", "--replay-stdin"] + ) + preview.install_generation( + self.generation("g1", replay=("renderer-g1", "--replay-stdin")) + ) + g2 = preview.begin_build("g2") + response = subprocess.CompletedProcess([], 0, b'{"capture":{}}', b"") + with patch.object(workbench.subprocess, "run", return_value=response) as run: + self.assertEqual(json.loads(preview.replay(body))["generation"], "g1") + self.assertEqual(run.call_args.args[0][0], "renderer-g1") + preview.fail_build(g2, "compile failed") + self.assertEqual(preview.generation.id, "g1") + + preview.begin_build("g2-stale") + preview.begin_build("g3") + self.assertTrue(preview.finish_build(self.generation("g3"))) + self.assertFalse(preview.finish_build(self.generation("g2-stale"))) + self.assertEqual(preview.generation.id, "g3") + + def test_revision_reports_last_good_build_while_next_generation_builds(self): + preview = workbench.Preview( + ["renderer"], {}, filter_kind="adapter", filter_value="shared-menu" + ) + preview.install_generation(self.generation("g1")) + preview.begin_build("g2") + state = preview.revision_state() + self.assertEqual(state["generation"], "g1") + self.assertTrue(state["building"]) + self.assertTrue(state["stale"]) + preview.fail_build("g2", "compile failed") + failed = preview.revision_state() + self.assertTrue(failed["stale"]) + self.assertEqual(failed["generation"], "g1") + + def test_adapter_fingerprint_narrows_and_unknown_falls_back(self): + with tempfile.TemporaryDirectory() as directory: + mono = Path(directory) + workspace = mono / "products" / "maestro" + for path in [ + workspace / "packages/ui-preview-rs/src", + workspace / "packages/ui-rs", + workspace / "packages/interaction-rs/src", + workspace / "packages/presentation-rs", + workspace / "packages/tui-rs/src", + workspace / "packages/owned/src", + workspace / "packages/unrelated", + workspace / ".cargo", + ]: + path.mkdir(parents=True, exist_ok=True) + (workspace / "Cargo.toml").write_text("") + (workspace / "Cargo.lock").write_text("") + (workspace / ".cargo/config.toml").write_text("config") + (workspace / "packages/ui-preview-rs/Cargo.toml").write_text("crate") + (workspace / "packages/ui-preview-rs/build.rs").write_text("build") + interaction = workspace / "packages/interaction-rs/src/lib.rs" + interaction.write_text("interaction") + theme_selector = workspace / "packages/tui-rs/src/theme_selector.rs" + theme_selector.write_text("theme selector") + (workspace / "packages/owned/src/story.rs").write_text("owned") + (workspace / "packages/owned/register.rs").write_text("register") + (workspace / "packages/unrelated/noise.rs").write_text("noise") + manifests = { + "owned": { + "fixture_dir": "products/maestro/packages/owned/src", + "registration_file": "products/maestro/packages/owned/register.rs", + "template": "theme-selector", + } + } + paths = workbench.source_paths(workspace, "adapter", "owned", manifests) + self.assertIn(workspace / ".cargo/config.toml", paths) + self.assertIn(workspace / "packages/ui-preview-rs/Cargo.toml", paths) + self.assertIn(workspace / "packages/ui-preview-rs/build.rs", paths) + self.assertIn(interaction, paths) + self.assertIn(theme_selector, paths) + narrowed = workbench.fingerprint(workspace, "adapter", "owned", manifests) + interaction.write_text("interaction changed") + changed_dependency = workbench.fingerprint( + workspace, "adapter", "owned", manifests + ) + self.assertNotEqual(narrowed, changed_dependency) + narrowed = changed_dependency + (workspace / "packages/unrelated/noise.rs").write_text("changed") + self.assertEqual( + narrowed, + workbench.fingerprint(workspace, "adapter", "owned", manifests), + ) + fallback = workbench.fingerprint(workspace, "adapter", "missing", manifests) + (workspace / "packages/unrelated/noise.rs").write_text("changed again") + self.assertNotEqual( + fallback, + workbench.fingerprint(workspace, "adapter", "missing", manifests), + ) + + def test_source_digest_uses_contents_even_when_metadata_is_restored(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + (workspace / "packages").mkdir() + source = workspace / "packages" / "same-size.rs" + source.write_text("aa") + stat = source.stat() + before = workbench.source_content_digest(workspace) + source.write_text("bb") + os.utime(source, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + after = workbench.source_content_digest(workspace) + self.assertNotEqual(before, after) + + def test_source_digest_is_stable_across_equivalent_worktrees(self): + with ( + tempfile.TemporaryDirectory() as first, + tempfile.TemporaryDirectory() as second, + ): + for directory in [first, second]: + workspace = Path(directory) + (workspace / "packages").mkdir() + (workspace / "Cargo.toml").write_text("[workspace]") + (workspace / "packages/story.rs").write_text("same source") + self.assertEqual( + workbench.source_content_digest(Path(first)), + workbench.source_content_digest(Path(second)), + ) + + def test_build_bodies_are_serialized_without_blocking_last_good_replay(self): + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(workbench, "WORKSPACE", Path(directory)), + ): + active = 0 + maximum = 0 + guard = threading.Lock() + + def render(*_args, **_kwargs): + nonlocal active, maximum + with guard: + active += 1 + maximum = max(maximum, active) + time.sleep(0.03) + with guard: + active -= 1 + return subprocess.CompletedProcess([], 0, b"valid", b"") + + preview = workbench.Preview(["renderer"], {}) + preview.install_generation(self.generation("g1")) + with patch.object(workbench.subprocess, "run", side_effect=render): + first = threading.Thread(target=preview.build) + second = threading.Thread(target=preview.build) + first.start() + second.start() + first.join() + second.join() + self.assertEqual(maximum, 1) + self.assertIsNotNone(preview.generation) + + def test_source_change_during_build_keeps_last_good_generation(self): + with ( + tempfile.TemporaryDirectory() as directory, + patch.object(workbench, "WORKSPACE", Path(directory)), + ): + root = Path(directory) + (root / "packages").mkdir() + source = root / "packages/story.rs" + source.write_text("before") + preview = workbench.Preview(["renderer"], {}) + preview.install_generation(self.generation("g1")) + + def render(*_args, **_kwargs): + source.write_text("after") + return subprocess.CompletedProcess([], 0, b"new", b"") + + with patch.object(workbench.subprocess, "run", side_effect=render): + preview.build() + self.assertEqual(preview.generation.id, "g1") + self.assertIn("Source inputs changed", preview.error) + + def test_snapshot_cleanup_keeps_active_and_bounds_recent_generations(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) + preview = workbench.Preview(["renderer"], {"CARGO_TARGET_DIR": str(target)}) + root = preview.snapshot_root + for index in range(7): + path = root / f"digest-{index}" + path.mkdir(parents=True) + (path / "renderer").write_text(str(index)) + os.utime(path, ns=(index + 1, index + 1)) + preview.install_generation( + self.generation("active").__class__( + **{ + **self.generation("active").__dict__, + "renderer_digest": "digest-0", + } + ) + ) + preview._in_use_digests["digest-1"] = 1 + preview._cleanup_snapshots("digest-0") + remaining = {path.name for path in root.iterdir()} + self.assertIn("digest-0", remaining) + self.assertIn("digest-1", remaining) + self.assertLessEqual(len(remaining), 5) + + def test_snapshot_sessions_do_not_clean_other_process_namespaces(self): + with tempfile.TemporaryDirectory() as directory: + env = {"CARGO_TARGET_DIR": directory} + first = workbench.Preview(["renderer"], env) + second = workbench.Preview(["renderer"], env) + self.assertNotEqual(first.snapshot_root, second.snapshot_root) + foreign = second.snapshot_root / "foreign-digest" + foreign.mkdir() + first._cleanup_snapshots("active") + self.assertTrue(foreign.exists()) + first.close() + self.assertTrue(second.snapshot_root.exists()) + + def test_in_flight_renderer_is_protected_during_generation_cleanup(self): + with tempfile.TemporaryDirectory() as directory: + preview = workbench.Preview(["renderer"], {"CARGO_TARGET_DIR": directory}) + old = self.generation("old", replay=("old-renderer", "--replay-stdin")) + preview.install_generation(old) + for digest in [old.renderer_digest, "new", "a", "b", "c", "d"]: + (preview.snapshot_root / digest).mkdir() + entered = threading.Event() + release = threading.Event() + + def replay(*_args, **_kwargs): + entered.set() + release.wait(1) + return subprocess.CompletedProcess([], 0, b'{"capture":{}}', b"") + + body = json.dumps( + { + "version": 1, + "fixture": "ready", + "width": 60, + "height": 20, + "inputs": [], + } + ).encode() + with patch.object(workbench.subprocess, "run", side_effect=replay): + thread = threading.Thread(target=lambda: preview.replay(body)) + thread.start() + self.assertTrue(entered.wait(1)) + preview._cleanup_snapshots("new") + self.assertTrue((preview.snapshot_root / old.renderer_digest).exists()) + release.set() + thread.join() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/scripts/dev/ui-workbench.py b/scripts/dev/ui-workbench.py index 309f1734c..0b2107cf9 100644 --- a/scripts/dev/ui-workbench.py +++ b/scripts/dev/ui-workbench.py @@ -1,26 +1,61 @@ #!/usr/bin/env python3 """Build and watch native UI fixtures. Localhost only; no browser-triggered commands.""" + import argparse import hashlib import json import os from pathlib import Path import re +import shutil import subprocess +import tempfile import threading import time +from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer WORKSPACE = Path(__file__).resolve().parents[2] -def fingerprint(workspace): +def source_paths(workspace, filter_kind="", filter_value="", manifests=None): + paths = [ + workspace / "Cargo.toml", + workspace / "Cargo.lock", + workspace / ".cargo" / "config.toml", + ] + roots = None + if filter_kind == "adapter" and manifests and filter_value in manifests: + manifest = manifests[filter_value] + mono = workspace.parents[1] + roots = [ + workspace / "packages" / "ui-preview-rs", + workspace / "packages" / "interaction-rs", + workspace / "packages" / "ui-rs", + workspace / "packages" / "presentation-rs", + mono / manifest["fixture_dir"], + ] + if manifest.get("template") == "theme-selector": + roots.append(workspace / "packages" / "tui-rs") + paths.append(mono / manifest["registration_file"]) + if roots is None: + roots = (workspace / "packages", workspace / "scripts" / "dev") + for root in roots: + if root.is_file(): + paths.append(root) + continue + paths.extend( + p + for p in root.rglob("*") + if p.suffix in {".rs", ".html", ".js", ".toml", ".txt", ".py"} + ) + return sorted(set(paths)) + + +def fingerprint(workspace, filter_kind="", filter_value="", manifests=None): """Watch source inputs, never generated captures or Cargo output.""" - paths = [workspace / 'Cargo.toml', workspace / 'Cargo.lock'] - for root in (workspace / 'packages', workspace / 'scripts' / 'dev'): - paths.extend(p for p in root.rglob('*') if p.suffix in {'.rs', '.html', '.toml', '.txt', '.py'}) result = [] - for path in sorted(paths): + for path in source_paths(workspace, filter_kind, filter_value, manifests): try: if path.is_file(): stat = path.stat() @@ -31,294 +66,685 @@ def fingerprint(workspace): return tuple(result) +def source_content_digest(workspace, filter_kind="", filter_value="", manifests=None): + digest = hashlib.sha256() + total = 0 + mono = workspace.parents[1] if workspace.parent.name == "products" else workspace + for index, path in enumerate( + source_paths(workspace, filter_kind, filter_value, manifests) + ): + if index >= 10_000: + raise RuntimeError("Source fingerprint exceeds 10000 files") + try: + content = path.read_bytes() + except FileNotFoundError: + continue + total += len(content) + if total > 128 * 1024 * 1024: + raise RuntimeError("Source fingerprint exceeds 128 MiB") + try: + identity = path.relative_to(mono).as_posix() + except ValueError: + identity = path.relative_to(workspace).as_posix() + digest.update(identity.encode()) + digest.update(b"\0") + digest.update(content) + digest.update(b"\0") + return digest.hexdigest() + + +def rebuild_changed_sources(preview, previous, workspace, filter_kind="", filter_value=""): + current = fingerprint( + workspace, filter_kind, filter_value, preview.manifests + ) + if current != previous: + preview.build() + return current + + +@dataclass(frozen=True) +class Generation: + id: str + renderer_digest: str + source_digest: str + filter_kind: str + filter_value: str + html: bytes + sequences: bytes + built_at_monotonic: float + build_ms: int + replay_command: tuple = () + studio_command: tuple = () + + class Preview: - def __init__(self, command, env, build_command=None, replay_command=None, studio_command=None, sequences_command=None): + MAX_ERROR_BYTES = 4_000 + + def __init__( + self, + command, + env, + build_command=None, + replay_command=None, + studio_command=None, + sequences_command=None, + manifest_command=None, + filter_kind="", + filter_value="", + ): self.command, self.env = command, env self.build_command = build_command self.replay_command = replay_command self.studio_command = studio_command self.sequences_command = sequences_command - self.html = b'' - self.sequences = b'' - self.revision = '' - self.error = '' + self.manifest_command = manifest_command + self.filter_kind, self.filter_value = filter_kind, filter_value + self.manifests = {} + self.generation = None + self.error = "" self.lock = threading.Lock() + self.build_lock = threading.Lock() + self._next_request = 0 + self._building_request = None + self._last_failed_request = None + self._in_use_digests = {} + snapshot_parent = ( + Path( + self.env.get( + "CARGO_TARGET_DIR", + Path(tempfile.gettempdir()) / "maestro-ui-workbench", + ) + ) + / "ui-workbench-generations" + ) + snapshot_parent.mkdir(parents=True, exist_ok=True) + self.snapshot_root = Path( + tempfile.mkdtemp(prefix=f"{os.getpid()}-", dir=snapshot_parent) + ) + + @property + def html(self): + return self.generation.html if self.generation else b"" + + @property + def sequences(self): + return self.generation.sequences if self.generation else b"" + + @property + def revision(self): + return self.generation.id if self.generation else "" + + def begin_build(self, request_id=None): + with self.lock: + self._next_request += 1 + request_id = request_id or f"g{self._next_request}" + self._building_request = request_id + return request_id + + def install_generation(self, generation): + with self.lock: + self.generation = generation + self.error = "" + + def finish_build(self, generation, request_id=None): + request_id = request_id or generation.id + with self.lock: + if request_id != self._building_request: + return False + self.generation = generation + self._building_request = None + self.error = "" + return True + + def fail_build(self, request_id, error): + with self.lock: + if request_id != self._building_request: + return False + self._building_request = None + self._last_failed_request = request_id + encoded = str(error).encode(errors="replace")[-self.MAX_ERROR_BYTES :] + self.error = encoded.decode(errors="replace") + return True def build(self): - try: - with self.lock: + request_id = self.begin_build() + with self.build_lock: + started = time.monotonic() + try: + source_before = source_content_digest( + WORKSPACE, self.filter_kind, self.filter_value, self.manifests + ) mono = WORKSPACE.parent.parent - if (mono / 'scripts/dev/local_build_capacity.py').exists(): - subprocess.run(['make', 'local-build-capacity-check'], cwd=mono, env=self.env, check=True, timeout=60) + if (mono / "scripts/dev/local_build_capacity.py").exists(): + subprocess.run( + ["make", "local-build-capacity-check"], + cwd=mono, + env=self.env, + check=True, + timeout=60, + ) if self.build_command: - built = subprocess.run(self.build_command, cwd=WORKSPACE, env=self.env, capture_output=True, timeout=600) + built = subprocess.run( + self.build_command, + cwd=WORKSPACE, + env=self.env, + capture_output=True, + timeout=600, + ) if built.returncode: - raise RuntimeError(built.stderr.decode(errors='replace')[-4000:]) - result = subprocess.run(self.command, cwd=WORKSPACE, env=self.env, capture_output=True, timeout=60) + raise RuntimeError( + built.stderr.decode(errors="replace")[ + -self.MAX_ERROR_BYTES : + ] + ) + result = subprocess.run( + self.command, + cwd=WORKSPACE, + env=self.env, + capture_output=True, + timeout=60, + ) if result.returncode: - raise RuntimeError(result.stderr.decode(errors='replace')[-4000:]) - if b' 16_384: - raise ValueError('Replay request exceeds 16384 bytes') + raise ValueError(f"{label} exceeds 16384 bytes") request = json.loads(body) - if not isinstance(request, dict) or not isinstance(request.get('inputs'), list): - raise ValueError('Replay request must contain an input list') - if len(request['inputs']) > 64: - raise ValueError('Replay request exceeds 64 inputs') - width, height = request.get('width'), request.get('height') - if type(width) is not int or type(height) is not int or not 8 <= width <= 240 or not 3 <= height <= 100: - raise ValueError('Replay dimensions are out of bounds') + if isinstance(request, dict) and "schema" in request: + if set(request) != {"schema", "version", "value"}: + raise ValueError(f"{label} envelope fields are invalid") + if request["schema"] != schema or request["version"] != 1: + raise ValueError(f"{label} envelope schema or version is unsupported") + request = request["value"] + return request + + +def validate_replay(body): + request = _wire_value(body, "maestro.ui.theme-replay", "Replay request") + if not isinstance(request, dict) or not isinstance(request.get("inputs"), list): + raise ValueError("Replay request must contain an input list") + if len(request["inputs"]) > 64: + raise ValueError("Replay request exceeds 64 inputs") + width, height = request.get("width"), request.get("height") + if ( + type(width) is not int + or type(height) is not int + or not 8 <= width <= 240 + or not 3 <= height <= 100 + ): + raise ValueError("Replay dimensions are out of bounds") text_bytes = 0 - for event in request['inputs']: - event_type = event.get('type') if isinstance(event, dict) else None - if not isinstance(event_type, str) or event_type not in {'key', 'text', 'resize', 'retry'}: - raise ValueError('Replay input is invalid') - if event_type == 'text': - text = event.get('text') - if not isinstance(text, str) or any(ord(char) < 32 or ord(char) == 127 for char in text): - raise ValueError('Replay text is invalid') + for event in request["inputs"]: + event_type = event.get("type") if isinstance(event, dict) else None + if not isinstance(event_type, str) or event_type not in { + "key", + "text", + "resize", + "retry", + }: + raise ValueError("Replay input is invalid") + if event_type == "text": + text = event.get("text") + if not isinstance(text, str) or any( + ord(char) < 32 or ord(char) == 127 for char in text + ): + raise ValueError("Replay text is invalid") text_bytes += len(text.encode()) - if event_type == 'resize': - event_width, event_height = event.get('width'), event.get('height') - if type(event_width) is not int or type(event_height) is not int or not 8 <= event_width <= 240 or not 3 <= event_height <= 100: - raise ValueError('Replay resize is out of bounds') + if event_type == "resize": + event_width, event_height = event.get("width"), event.get("height") + if ( + type(event_width) is not int + or type(event_height) is not int + or not 8 <= event_width <= 240 + or not 3 <= event_height <= 100 + ): + raise ValueError("Replay resize is out of bounds") if text_bytes > 4_096: - raise ValueError('Replay text exceeds 4096 bytes') + raise ValueError("Replay text exceeds 4096 bytes") def validate_studio(body): - if len(body) > 16_384: - raise ValueError('Menu recipe exceeds 16384 bytes') - request = json.loads(body) + request = _wire_value(body, "maestro.ui.menu-recipe", "Menu recipe") required = { - 'version', 'id', 'label', 'title', 'placeholder', 'empty', 'items', - 'state', 'status_message', 'width', 'height', 'inputs', + "version", + "id", + "label", + "title", + "placeholder", + "empty", + "items", + "state", + "status_message", + "width", + "height", + "inputs", } if not isinstance(request, dict) or set(request) != required: - raise ValueError('Menu recipe fields are invalid') - state = request['state'] - if request['version'] != 1 or not isinstance(state, str) or state not in {'ready', 'empty', 'loading', 'error'}: - raise ValueError('Menu recipe version or state is invalid') - if not isinstance(request['id'], str) or not re.fullmatch(r'[a-z0-9-]{1,64}', request['id']): - raise ValueError('Menu recipe ID is invalid') + raise ValueError("Menu recipe fields are invalid") + state = request["state"] + if ( + request["version"] != 1 + or not isinstance(state, str) + or state not in {"ready", "empty", "loading", "error"} + ): + raise ValueError("Menu recipe version or state is invalid") + if not isinstance(request["id"], str) or not re.fullmatch( + r"[a-z0-9-]{1,64}", request["id"] + ): + raise ValueError("Menu recipe ID is invalid") text_bytes = 0 for field, maximum, allow_empty in ( - ('label', 160, False), ('title', 160, False), - ('placeholder', 160, False), ('empty', 240, False), - ('status_message', 240, True), + ("label", 160, False), + ("title", 160, False), + ("placeholder", 160, False), + ("empty", 240, False), + ("status_message", 240, True), ): value = request[field] - if not isinstance(value, str) or (not allow_empty and not value) or len(value.encode()) > maximum: - raise ValueError(f'Menu recipe {field} is invalid') + if ( + not isinstance(value, str) + or (not allow_empty and not value) + or len(value.encode()) > maximum + ): + raise ValueError(f"Menu recipe {field} is invalid") if any(ord(char) < 32 or ord(char) == 127 for char in value): - raise ValueError(f'Menu recipe {field} contains control characters') + raise ValueError(f"Menu recipe {field} contains control characters") text_bytes += len(value.encode()) - items = request['items'] + items = request["items"] if not isinstance(items, list) or len(items) > 64: - raise ValueError('Menu recipe items are invalid') + raise ValueError("Menu recipe items are invalid") ids = set() for item in items: - if not isinstance(item, dict) or set(item) != {'id', 'label'}: - raise ValueError('Menu recipe item fields are invalid') - item_id, label = item['id'], item['label'] - if not isinstance(item_id, str) or not re.fullmatch(r'[a-z0-9-]{1,64}', item_id) or item_id in ids: - raise ValueError('Menu recipe item ID is invalid or duplicated') + if not isinstance(item, dict) or set(item) != {"id", "label"}: + raise ValueError("Menu recipe item fields are invalid") + item_id, label = item["id"], item["label"] + if ( + not isinstance(item_id, str) + or not re.fullmatch(r"[a-z0-9-]{1,64}", item_id) + or item_id in ids + ): + raise ValueError("Menu recipe item ID is invalid or duplicated") if not isinstance(label, str) or not label or len(label.encode()) > 160: - raise ValueError('Menu recipe item label is invalid') + raise ValueError("Menu recipe item label is invalid") if any(ord(char) < 32 or ord(char) == 127 for char in label): - raise ValueError('Menu recipe item label contains control characters') + raise ValueError("Menu recipe item label contains control characters") ids.add(item_id) text_bytes += len(item_id.encode()) + len(label.encode()) if text_bytes > 8_192: - raise ValueError('Menu recipe text exceeds 8192 bytes') - validate_replay(json.dumps({ - 'width': request['width'], - 'height': request['height'], - 'inputs': request['inputs'], - }).encode()) + raise ValueError("Menu recipe text exceeds 8192 bytes") + validate_replay( + json.dumps( + { + "width": request["width"], + "height": request["height"], + "inputs": request["inputs"], + } + ).encode() + ) def valid_host(value, port): - return value in {f'127.0.0.1:{port}', f'localhost:{port}'} + return value in {f"127.0.0.1:{port}", f"localhost:{port}"} def valid_origin(value, port): - return value in {f'http://127.0.0.1:{port}', f'http://localhost:{port}'} + return value in {f"http://127.0.0.1:{port}", f"http://localhost:{port}"} def handler(preview): class Handler(BaseHTTPRequestHandler): def trusted_host(self): - if valid_host(self.headers.get('Host', ''), self.server.server_port): + if valid_host(self.headers.get("Host", ""), self.server.server_port): return True - self.send_error(403, 'Untrusted Host') + self.send_error(403, "Untrusted Host") return False def do_GET(self): if not self.trusted_host(): return - path = self.path.split('?', 1)[0] - if path == '/__revision': - body = json.dumps({'revision': preview.revision, 'error': preview.error}).encode() - content_type, status = 'application/json', 200 - elif path == '/__sequences' and preview.sequences: + path = self.path.split("?", 1)[0] + if path == "/__revision": + body = json.dumps(preview.revision_state()).encode() + content_type, status = "application/json", 200 + elif path == "/__sequences" and preview.sequences: body = preview.sequences - content_type, status = 'application/json', 200 - elif path in {'/', '/index.html'}: - body = preview.html or b'Preview has not built. See the terminal for the build error.' - content_type, status = 'text/html; charset=utf-8', 200 if preview.html else 503 + content_type, status = "application/json", 200 + elif path in {"/", "/index.html"}: + body = ( + preview.html + or b"Preview has not built. See the terminal for the build error." + ) + content_type, status = ( + "text/html; charset=utf-8", + 200 if preview.html else 503, + ) else: - body, content_type, status = b'Not found', 'text/plain', 404 + body, content_type, status = b"Not found", "text/plain", 404 self.send_response(status) - self.send_header('Content-Type', content_type) - self.send_header('Content-Length', str(len(body))) - self.send_header('Cache-Control', 'no-store') + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def do_POST(self): if not self.trusted_host(): return - path = self.path.split('?', 1)[0] - if path not in {'/__replay', '/__studio'}: - self.send_error(404, 'Not found') + path = self.path.split("?", 1)[0] + if path not in {"/__replay", "/__studio"}: + self.send_error(404, "Not found") return - if not valid_origin(self.headers.get('Origin', ''), self.server.server_port): - self.send_error(403, 'Untrusted Origin') + if not valid_origin( + self.headers.get("Origin", ""), self.server.server_port + ): + self.send_error(403, "Untrusted Origin") return - if self.headers.get_content_type() != 'application/json': - self.send_error(415, 'Expected application/json') + if self.headers.get_content_type() != "application/json": + self.send_error(415, "Expected application/json") return try: - length = int(self.headers.get('Content-Length', '')) + length = int(self.headers.get("Content-Length", "")) if not 0 < length <= 16_384: - raise ValueError('Invalid Content-Length') + raise ValueError("Invalid Content-Length") body = self.rfile.read(length) - rendered = preview.replay(body) if path == '/__replay' else preview.studio(body) + rendered = ( + preview.replay(body) + if path == "/__replay" + else preview.studio(body) + ) status = 200 except BlockingIOError as exc: - rendered, status = json.dumps({'error': str(exc)}).encode(), 429 + rendered, status = json.dumps({"error": str(exc)}).encode(), 429 except (LookupError, ValueError, json.JSONDecodeError) as exc: - rendered, status = json.dumps({'error': str(exc)}).encode(), 400 + rendered, status = json.dumps({"error": str(exc)}).encode(), 400 except (OSError, subprocess.SubprocessError) as exc: - rendered, status = json.dumps({'error': str(exc)}).encode(), 500 + rendered, status = json.dumps({"error": str(exc)}).encode(), 500 self.send_response(status) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', str(len(rendered))) - self.send_header('Cache-Control', 'no-store') - self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(rendered))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(rendered) def log_message(self, *_): pass + return Handler def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--port', type=int, default=8770) - parser.add_argument('--components-only', action='store_true') + parser.add_argument("--port", type=int, default=8770) + parser.add_argument("--components-only", action="store_true") + filter_group = parser.add_mutually_exclusive_group() + filter_group.add_argument("--story") + filter_group.add_argument("--adapter") args = parser.parse_args() - env = dict(os.environ, COLORTERM='truecolor') - env.setdefault('CARGO_TARGET_DIR', str(Path.home() / '.cache' / 'maestro-ui-target')) - target = Path(env['CARGO_TARGET_DIR']) / 'debug' - executable_suffix = '.exe' if os.name == 'nt' else '' + env = dict(os.environ, COLORTERM="truecolor") + env.setdefault( + "CARGO_TARGET_DIR", str(Path.home() / ".cache" / "maestro-ui-target") + ) + target = Path(env["CARGO_TARGET_DIR"]) / "debug" + executable_suffix = ".exe" if os.name == "nt" else "" + filter_kind = "story" if args.story else ("adapter" if args.adapter else "") + filter_value = args.story or args.adapter or "" + filter_args = [f"--{filter_kind}", filter_value] if filter_kind else [] if args.components_only: - binary = target / f'maestro-ui-preview{executable_suffix}' - build_command = ['cargo', 'build', '--locked', '-p', 'maestro-ui-preview'] - preview = Preview([str(binary), '--html'], env, build_command=build_command) + binary = target / f"maestro-ui-preview{executable_suffix}" + build_command = ["cargo", "build", "--locked", "-p", "maestro-ui-preview"] + preview = Preview( + [str(binary), "--html", *filter_args], + env, + build_command=build_command, + sequences_command=[str(binary), "--sequences", *filter_args], + manifest_command=[str(binary), "studio", "manifests"], + filter_kind=filter_kind, + filter_value=filter_value, + ) else: - binary = target / 'examples' / f'onboarding-preview{executable_suffix}' - build_command = ['cargo', 'build', '--locked', '-p', 'maestro-tui', '--example', 'onboarding-preview'] + if filter_kind: + parser.error("--story and --adapter currently require --components-only") + binary = target / "examples" / f"onboarding-preview{executable_suffix}" + build_command = [ + "cargo", + "build", + "--locked", + "-p", + "maestro-tui", + "--example", + "onboarding-preview", + ] preview = Preview( - [str(binary), '--html'], + [str(binary), "--html"], env, build_command=build_command, - replay_command=[str(binary), '--replay-stdin'], - studio_command=[str(binary), '--studio-stdin'], - sequences_command=[str(binary), '--sequences'], + replay_command=[str(binary), "--replay-stdin"], + studio_command=[str(binary), "--studio-stdin"], + sequences_command=[str(binary), "--sequences"], + filter_kind=filter_kind, + filter_value=filter_value, ) # Bind before building, so a busy port fails without starting an unused build. - server = ThreadingHTTPServer(('127.0.0.1', args.port), handler(preview)) - previous = fingerprint(WORKSPACE) + server = ThreadingHTTPServer(("127.0.0.1", args.port), handler(preview)) + previous = fingerprint(WORKSPACE, filter_kind, filter_value, preview.manifests) preview.build() + if preview.generation: + previous = fingerprint(WORKSPACE, filter_kind, filter_value, preview.manifests) stop = threading.Event() def watch(): nonlocal previous while not stop.wait(1): - current = fingerprint(WORKSPACE) - if current != previous: - previous = current - preview.build() + previous = rebuild_changed_sources( + preview, previous, WORKSPACE, filter_kind, filter_value + ) thread = threading.Thread(target=watch, daemon=True) thread.start() - print(f'UI workbench: http://127.0.0.1:{args.port}/?watch=1&interactive=1', flush=True) + print( + f"UI workbench: http://127.0.0.1:{args.port}/?watch=1&interactive=1", flush=True + ) try: server.serve_forever() except KeyboardInterrupt: @@ -326,7 +752,8 @@ def watch(): finally: stop.set() server.server_close() + preview.close() -if __name__ == '__main__': +if __name__ == "__main__": main()