diff --git a/.changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json b/.changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json new file mode 100644 index 00000000..18c09ccc --- /dev/null +++ b/.changepacks/changepack_log_Cplouv_3AT3N0Fj41CiIp.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Preserve screen-plan frame inventories and bind navigation labels to confirmed routes", + "date": "2026-10-19T08:05:44.718031500Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json b/.changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json new file mode 100644 index 00000000..2770c239 --- /dev/null +++ b/.changepacks/changepack_log_F-lJ1_TR61qiwFfH3-BxP.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Report missing public assets and omitted Figma sections and asset references during export and UI validation", + "date": "2026-10-19T08:09:42.879273700Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json b/.changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json new file mode 100644 index 00000000..f7773e7d --- /dev/null +++ b/.changepacks/changepack_log_PlugVer_0a1B2c3D4e5F6g7H.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Report the Devup Bridge plugin version in hello and show plugin and devup-mcp versions in the plugin window", + "date": "2026-10-19T09:00:00.000000000Z" +} diff --git a/.changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json b/.changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json new file mode 100644 index 00000000..dd6ca4dc --- /dev/null +++ b/.changepacks/changepack_log_RsrcChk_9xK2mQ7vL4pT8wZ1.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Make runtime audit image-loaded, native-appearance and stale-resources checks fail on unpainted images, native button chrome and differences from a fresh cache-free load", + "date": "2026-10-19T09:00:00.000000000Z" +} diff --git a/.changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json b/.changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json new file mode 100644 index 00000000..79d672b8 --- /dev/null +++ b/.changepacks/changepack_log_jGeCGJnOLsqlhVJ7U-mhi.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Require complete URL-by-width runtime evidence, reading-order and responsive clipping checks before completion", + "date": "2026-10-19T08:11:39.299827700Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json b/.changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json new file mode 100644 index 00000000..9d345ace --- /dev/null +++ b/.changepacks/changepack_log_mMIn1zlIqjF-pZXLZ_mFi.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Show rendered completeness in per-width audit reports", + "date": "2026-10-19T08:14:14.391652Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json b/.changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json new file mode 100644 index 00000000..78ab5578 --- /dev/null +++ b/.changepacks/changepack_log_qCx9bQYoBJWWTta31W1XI.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Verify rendered completeness, effective text contrast, and loaded image resources in runtime audits", + "date": "2026-10-19T08:04:41.792494500Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json b/.changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json new file mode 100644 index 00000000..eb254b60 --- /dev/null +++ b/.changepacks/changepack_log_u8RE3FZrsXubFLyVO98qr.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Reset generated button appearance and preserve original hero image fills without baking child text.", + "date": "2026-10-19T09:18:21.640928500Z" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json b/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json new file mode 100644 index 00000000..ce1d882c --- /dev/null +++ b/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Require real Chromium rendering and pinned Figma pixel comparisons before page completion; cover generated DevupUI pages, missing backgrounds, wrong colors and failed assets in the end-to-end harness.", + "date": "2026-10-19T14:22:49.794557600Z" +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/examples/render_fixture.rs b/crates/devup-mcp-devup-ui/examples/render_fixture.rs new file mode 100644 index 00000000..ae6f30c6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/examples/render_fixture.rs @@ -0,0 +1,23 @@ +//! Replay a snapshot through production codegen for the browser regression test. +use std::{error::Error, path::Path}; + +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, generate_component, node_ids::annotate_node_ids, +}; +use devup_mcp_figma::Snapshot; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + if args.len() != 2 { + return Err("usage: render_fixture ".into()); + } + let snapshot: Snapshot = serde_json::from_slice(&std::fs::read(&args[0])?)?; + let options = CodegenOptions { + component_name: Some("RenderFixture".into()), + ..Default::default() + }; + let mut output = generate_component(&snapshot, &snapshot.roots[0], &options)?; + annotate_node_ids(&mut output, &snapshot.nodes.keys().cloned().collect()); + std::fs::write(Path::new(&args[1]), output.tsx)?; + Ok(()) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs index b71d0715..832f8e1f 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs @@ -550,6 +550,29 @@ pub(super) fn push_style_props( tokens: length_tokens, viewport: drawn_viewport(snapshot, node), }; + // Reset native faces even with transparent paint, including Figma button + // components that consumers render polymorphically. + let explicit_button = view.string("as") == Some("button") + || view.node_type().eq_ignore_ascii_case("button"); + let button_name = |name: &str| { + name.split(|c: char| !c.is_alphanumeric()) + .next() + .is_some_and(|word| word.eq_ignore_ascii_case("button")) + }; + let named_button = component != "Text" + && (view.name().is_some_and(button_name) + || snapshot.nodes.values().any(|parent| { + let parent = parent.typed_view(); + parent.node_type() == "COMPONENT_SET" + && parent.name().is_some_and(button_name) + && parent.child_ids().any(|id| id == node.id) + })); + if explicit_button || named_button || component.eq_ignore_ascii_case("button") { + if explicit_button { + string_prop(props, "as", "button"); + } + string_prop(props, "appearance", "none"); + } if view.bool("visible") == Some(false) { string_prop(props, "display", "none"); } @@ -805,6 +828,11 @@ pub(super) fn push_style_props( } else if let Some(background) = background_css(snapshot, node, used_tokens, style) { string_prop(props, "bg", background); } + if (explicit_button || named_button || component.eq_ignore_ascii_case("button")) + && !props.iter().any(|(name, _)| name == "bg") + { + string_prop(props, "bg", "transparent"); + } if let Some(mode) = view .value("fills") .and_then(Value::as_array) @@ -819,7 +847,6 @@ pub(super) fn push_style_props( { string_prop(props, "bgBlendMode", mode); } - push_radius(&view, props); if component != "Text" { push_strokes( @@ -969,4 +996,4 @@ fn background_css( } } (!css.is_empty()).then(|| css.join(", ")) -} \ No newline at end of file +} diff --git a/crates/devup-mcp-devup-ui/src/ui_layout.rs b/crates/devup-mcp-devup-ui/src/ui_layout.rs index 6a91afd2..612053e7 100644 --- a/crates/devup-mcp-devup-ui/src/ui_layout.rs +++ b/crates/devup-mcp-devup-ui/src/ui_layout.rs @@ -99,7 +99,7 @@ struct Collector<'s> { impl<'a> Visit<'a> for Collector<'_> { fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { - let attrs = string_attributes(&element.opening_element); + let attrs = string_attributes(&element.opening_element, self.source); let follows_viewport = match self.ancestors.last() { None => fluid_width(&attrs), Some(parent) => parent.follows_viewport && stretches_across(parent, &attrs), @@ -133,7 +133,7 @@ impl<'a> Visit<'a> for Collector<'_> { } } -fn string_attributes(element: &JSXOpeningElement<'_>) -> BTreeMap { +fn string_attributes(element: &JSXOpeningElement<'_>, source: &str) -> BTreeMap { element .attributes .iter() @@ -150,7 +150,10 @@ fn string_attributes(element: &JSXOpeningElement<'_>) -> BTreeMap text.value.to_string(), Expression::NumericLiteral(number) => number.value.to_string(), - _ => return None, + _ => { + let span = container.expression.span(); + source[span.start as usize..span.end as usize].to_owned() + } } } _ => return None, @@ -221,6 +224,51 @@ fn center_offset(left: &str) -> Option { impl Collector<'_> { fn analyze(&mut self, parent: &Element, children: &[Element]) { + fn direction(element: &Element) -> Option<&String> { + element + .attrs + .get("flexDir") + .or_else(|| element.attrs.get("flexDirection")) + } + let column = |element: &Element| { + element.tag == "VStack" + || direction(element).is_some_and(|value| value.contains("column")) + }; + if children + .iter() + .filter(|child| column(child) && child.has_children) + .count() + >= 2 + { + self.risks.push(json!({"rule":"column-reading-order", "confidence":"medium", "elementIndex":parent.ordinal, + "message":"Column wrappers can collapse cards in column order (01,03,02,04). Use one Grid or flex-wrap in design reading order, or explicit breakpoint order; run sequence-order at every width."})); + } + let row = parent.tag == "Flex" + && direction(parent).is_none_or(|value| !value.contains("column")) + || parent.tag == "Grid" + && parent + .attrs + .get("gridTemplateColumns") + .is_some_and(|value| value.contains(" ") || value.contains("repeat")); + let wraps = parent + .attrs + .get("flexWrap") + .is_some_and(|value| value.contains("wrap")); + let fixed_children = children + .iter() + .filter(|child| pixels(child.attrs.get("w")).is_some()) + .count(); + let wide_only = std::iter::once(parent).chain(children).any(|element| { + element.attrs.values().any(|value| { + (value.trim_start().starts_with("[null") + || value.trim_start().starts_with("[undefined")) + && value.contains("px") + }) + }); + if row && !wraps && (fixed_children >= 2 || wide_only) { + self.risks.push(json!({"rule":"narrow-fixed-columns", "confidence":"medium", "elementIndex":parent.ordinal, + "message":"A multi-column row keeps fixed child widths without a narrow column or wrap rule. Supply narrow responsive layout props and verify section-layout, overflow and clipped text at 390/858px."})); + } let width = pixels(parent.attrs.get("w")) .or_else(|| pixels(parent.attrs.get("boxSize"))) .or(self.reference_width); @@ -587,3 +635,36 @@ mod tests { assert_eq!(report["groupsWithoutReferenceWidth"], 1); } } + +#[cfg(test)] +mod responsive_failure_tests { + use super::*; + #[test] + fn columns_and_wide_only_fixed_rows_are_flagged() { + let columns = r#"const A = () => "#; + assert!( + layout_risks(columns, None)["risks"] + .as_array() + .unwrap() + .iter() + .any(|risk| risk["rule"] == "column-reading-order") + ); + let fixed = + r#"const A = () => "#; + assert!( + crate::ui_validate::validate_devup_ui_tsx(fixed, None, true) + .violations + .iter() + .any(|risk| risk.rule == "narrow-fixed-columns") + ); + let wrapping = + r#"const A = () => "#; + assert!( + !layout_risks(wrapping, None)["risks"] + .as_array() + .unwrap() + .iter() + .any(|risk| risk["rule"] == "narrow-fixed-columns") + ); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs index d7e192a2..37189f68 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs @@ -150,6 +150,18 @@ fn validate_source( let checked_tokens = visitor.checked_tokens; let attributes = crate::provenance::attributes::generated_attributes(tsx); violations.extend(blur_layers::check(tsx, &attributes)); + for risk in crate::ui_layout::layout_risks(tsx, None)["risks"].as_array().into_iter().flatten() { + let rule = match risk["rule"].as_str() { + Some("narrow-fixed-columns") => "narrow-fixed-columns", + Some("column-reading-order") => "column-reading-order", + _ => continue, + }; + violations.push(Violation { rule, severity: Severity::Warning, byte_range: [0, 0], + context: risk.as_object().cloned().unwrap_or_default(), + message: risk["message"].as_str().unwrap_or_default().to_owned(), + suggestion: Some("Use reading-order DOM and explicit narrow layout props, then run the runtime audit at every required width.".into()) }); + } + let elements = crate::provenance::attributes::opening_elements(tsx); for violation in &mut violations { let [start, end] = violation.byte_range; diff --git a/crates/devup-mcp-devup-ui/tests/button_appearance.rs b/crates/devup-mcp-devup-ui/tests/button_appearance.rs new file mode 100644 index 00000000..d74b1fe2 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/button_appearance.rs @@ -0,0 +1,70 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::Snapshot; +use serde_json::json; + +#[test] +fn white_outline_button_without_fill_disables_the_native_face() { + for (name, tag) in [("Button / Outline", None), ("CTA", Some("button"))] { + for fill in [ + json!([]), + json!([{"type":"SOLID","color":{"r":0,"g":0,"b":0}}]), + ] { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"button", "roots":["button"], "diagnostics":[], "nodes": { + "button":{"id":"button","type":"FRAME","fields":{ + "name":name,"as":tag, "width":160,"height":48,"layoutMode":"HORIZONTAL", + "fills":fill,"strokeWeight":1,"strokeAlign":"INSIDE", + "strokes":[{"type":"SOLID","color":{"r":1,"g":1,"b":1}}], + "childrenIds":["label"] + }}, + "label":{"id":"label","type":"TEXT","fields":{ + "characters":"Get started","fontSize":16, + "fills":[{"type":"SOLID","color":{"r":1,"g":1,"b":1}}] + }} + } + })) + .unwrap(); + let output = + generate_component(&snapshot, "button", &CodegenOptions::default()).unwrap(); + assert!(output.tsx.contains("appearance=\"none\""), "{}", output.tsx); + assert!(output.tsx.contains("color=\"#FFF\""), "{}", output.tsx); + assert!( + output.tsx.contains("border=\"solid 1px #FFF\""), + "{}", + output.tsx + ); + let expected = if fill.as_array().unwrap().is_empty() { + "transparent" + } else { + "#000" + }; + assert!( + output.tsx.contains(&format!("bg=\"{expected}\"")), + "{}", + output.tsx + ); + assert_eq!(output.tsx.matches("bg=").count(), 1, "{}", output.tsx); + if tag.is_some() { + assert!(output.tsx.contains("as=\"button\""), "{}", output.tsx); + } + } + } +} + +#[test] +fn button_variant_inherits_native_appearance_safety_from_its_component_set() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"button","roots":["variant"],"diagnostics":[],"nodes":{ + "set":{"id":"set","type":"COMPONENT_SET","fields":{ + "name":"Button","childrenIds":["variant"] + }}, + "variant":{"id":"variant","type":"COMPONENT","fields":{ + "name":"Style=Outline","width":160,"height":48,"fills":[] + }} + } + })) + .unwrap(); + let output = generate_component(&snapshot, "variant", &CodegenOptions::default()).unwrap(); + assert!(output.tsx.contains("appearance=\"none\""), "{}", output.tsx); + assert!(output.tsx.contains("bg=\"transparent\""), "{}", output.tsx); +} diff --git a/crates/devup-mcp-figma/src/asset_render_info.rs b/crates/devup-mcp-figma/src/asset_render_info.rs index acf64d92..a3d4bb44 100644 --- a/crates/devup-mcp-figma/src/asset_render_info.rs +++ b/crates/devup-mcp-figma/src/asset_render_info.rs @@ -592,6 +592,26 @@ pub fn suggested_asset_filename(snapshot: &Snapshot, asset: &AssetManifestEntry) /// Add snapshot context and safe filename suggestions without changing byte identity /// or choosing a filesystem destination. Live export observations take precedence. +fn has_child_text(snapshot: &Snapshot, node_id: &str) -> bool { + let mut pending = vec![node_id]; + let mut seen = std::collections::BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + if let Some(node) = snapshot.nodes.get(id) { + if node.typed_view().bool("visible") == Some(false) { + continue; + } + if id != node_id && node.typed_view().node_type() == "TEXT" { + return true; + } + pending.extend(node.typed_view().child_ids()); + } + } + false +} + pub fn annotate_assets(snapshot: &Snapshot, assets: &mut [AssetManifestEntry]) { let parents: std::collections::BTreeMap<_, _> = snapshot .nodes @@ -724,6 +744,15 @@ pub fn annotate_assets(snapshot: &Snapshot, assets: &mut [AssetManifestEntry]) { info["byteEvidence"] = Value::Null; } compare(&mut info); + if !asset.render_mode.is_original() && has_child_text(snapshot, &asset.node_id) { + let warning = json!( + "node-render-bakes-child-text; use original-source IMAGE fill for backgrounds" + ); + let warnings = info["warnings"].as_array_mut().unwrap(); + if !warnings.contains(&warning) { + warnings.push(warning); + } + } asset.render_info = Some(info); if let Some(diagnostics) = &mut asset.export_diagnostics { diagnostics.render_info = asset.render_info.clone(); diff --git a/crates/devup-mcp-figma/src/assets/asset_manifest.rs b/crates/devup-mcp-figma/src/assets/asset_manifest.rs index e411ee71..4b1c2093 100644 --- a/crates/devup-mcp-figma/src/assets/asset_manifest.rs +++ b/crates/devup-mcp-figma/src/assets/asset_manifest.rs @@ -905,6 +905,28 @@ pub fn resolve_asset_selections( .ok_or_else(|| { invalid("The selected asset or nodeId is not in the acquired snapshot scope.") })?; + // nodeId is a convenient target, not permission to flatten a hero's + // live heading and controls into its background photograph. + let source_fill = (selection.render_mode == AssetRenderMode::OriginalSource + || (selection.render_mode == AssetRenderMode::Auto + && snapshot.nodes.get(&asset.node_id).is_some_and(|node| { + node.typed_view().child_ids().next().is_some() + }))) + .then(|| { + manifest.assets.iter().find(|candidate| { + candidate.node_id == asset.node_id + && candidate.field.starts_with("fills/") + && candidate.image_hash.is_some() + && (asset.field == "node" || asset.field == candidate.field) + }) + }) + .flatten(); + let asset = source_fill.unwrap_or(asset); + let render_mode = if source_fill.is_some() { + AssetRenderMode::OriginalSource + } else { + selection.render_mode + }; let capture_scope = if selection.render_mode == AssetRenderMode::Visible { Some(visible_scope( snapshot, @@ -925,7 +947,7 @@ pub fn resolve_asset_selections( image_hash: asset.image_hash.clone(), format: selection.format, scale: selection.scale, - render_mode: selection.render_mode, + render_mode, quality: selection.quality, lossless: selection.lossless, }) @@ -933,4 +955,4 @@ pub fn resolve_asset_selections( .collect::, _>>()?; validate_asset_requests(snapshot, &requests)?; Ok(requests) -} \ No newline at end of file +} diff --git a/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs b/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs index 56da2547..816f4c33 100644 --- a/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs +++ b/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs @@ -320,6 +320,7 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { match value.get("kind").and_then(Value::as_str) { Some("hello") => { + state.announce_server(&outbox); // 키가 없어도 등록한다. 건너뛰면 창은 "연결됨"이라고 하는데 // 어떤 읽기도 오지 않아 원인을 찾을 수 없다. 키 없는 연결을 // 어디까지 믿을지는 resolve 가 정한다. @@ -350,11 +351,13 @@ async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { // 이 값으로 대상을 고르므로, 늦게라도 최신이어야 한다. 중계들도 // 같은 선택을 보도록 함께 알린다. Some("context") => { - let Ok(context) = serde_json::from_value::(value) else { + let Ok(mut context) = serde_json::from_value::(value) else { continue; }; let mut inner = state.inner.lock().await; if let Some(plugin) = inner.plugins.get_mut(&id) { + // 버전은 hello 에만 실려 온다. 맥락을 바꿔 끼워도 잃지 않는다. + context.plugin_version = plugin.context.plugin_version.take(); plugin.context = context; state.plugins_changed(&mut inner); } diff --git a/crates/devup-mcp-figma/src/bridge/transport_and_state.rs b/crates/devup-mcp-figma/src/bridge/transport_and_state.rs index 0cd73223..df30e84e 100644 --- a/crates/devup-mcp-figma/src/bridge/transport_and_state.rs +++ b/crates/devup-mcp-figma/src/bridge/transport_and_state.rs @@ -114,6 +114,10 @@ pub struct PluginContext { /// 보고하기 전에 빌드된 플러그인은 `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub in_view: Option, + /// 플러그인 빌드의 버전. `hello` 에만 실려 오며, 이것을 보고하기 전에 빌드된 + /// 플러그인은 `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_version: Option, } impl PluginContext { @@ -601,6 +605,19 @@ impl BridgeState { Ok(()) } + /// 플러그인이 어느 devup-mcp 에 붙었는지 알 수 있게 먼저 밝힌다. + /// 모르는 메시지는 예전 플러그인이 무시한다. + fn announce_server(&self, outbox: &tokio::sync::mpsc::UnboundedSender) { + let _ = outbox.send( + json!({ + "kind": "server-info", + "version": self.link.me.version, + "buildId": self.link.me.build_id, + }) + .to_string(), + ); + } + /// 플러그인 목록이 바뀌었다. 세고, 중계들에게 알리고, 기다리는 쪽을 깨운다. fn plugins_changed(&self, inner: &mut Inner) { self.connected.store(inner.plugins.len(), Ordering::Relaxed); diff --git a/crates/devup-mcp-figma/tests/bridge_relay.rs b/crates/devup-mcp-figma/tests/bridge_relay.rs index a4bff5f9..af75618f 100644 --- a/crates/devup-mcp-figma/tests/bridge_relay.rs +++ b/crates/devup-mcp-figma/tests/bridge_relay.rs @@ -93,7 +93,11 @@ async fn next_text(socket: &mut Socket) -> Value { .expect("the socket stays open") .expect("the socket reads cleanly"); if let Message::Text(text) = message { - return serde_json::from_str(&text).expect("messages are JSON"); + let value: Value = serde_json::from_str(&text).expect("messages are JSON"); + if value["kind"] == "server-info" { + continue; + } + return value; } } } @@ -358,7 +362,7 @@ async fn the_relay_door_turns_away_browsers_and_unproven_callers() { // None of them got a read through. A forwarded job would have been queued // on this socket long before now, so a short look is enough to see none. assert!( - tokio::time::timeout(Duration::from_millis(300), plugin.next()) + tokio::time::timeout(Duration::from_millis(300), next_text(&mut plugin)) .await .is_err(), "no job may reach the plugin from an unauthenticated relay" diff --git a/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs b/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs index 9be69826..19023f88 100644 --- a/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs +++ b/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs @@ -47,15 +47,20 @@ async fn connect_plugin(server: &BridgeServer) -> Plugin { /// 다음 작업 하나를 받아 요청 id 와 함께 돌려준다. async fn next_job(socket: &mut Plugin) -> Value { - let message = socket - .next() - .await - .expect("a job arrives") - .expect("the socket stays open"); - let Message::Text(text) = message else { - panic!("jobs are sent as text"); - }; - serde_json::from_str(&text).expect("a job is JSON") + loop { + let message = socket + .next() + .await + .expect("a job arrives") + .expect("the socket stays open"); + let Message::Text(text) = message else { + panic!("jobs are sent as text"); + }; + let value: Value = serde_json::from_str(&text).expect("a job is JSON"); + if value["kind"] != "server-info" { + return value; + } + } } #[tokio::test] diff --git a/crates/devup-mcp-figma/tests/hero_source_selection.rs b/crates/devup-mcp-figma/tests/hero_source_selection.rs new file mode 100644 index 00000000..c5115009 --- /dev/null +++ b/crates/devup-mcp-figma/tests/hero_source_selection.rs @@ -0,0 +1,55 @@ +use devup_mcp_figma::{ + AssetRenderMode, AssetSelection, Snapshot, annotate_assets, discover_asset_manifest, + resolve_asset_selections, +}; +use serde_json::json; + +#[test] +fn hero_node_target_selects_original_photo_without_baking_text_or_buttons() { + let snapshot: Snapshot = serde_json::from_value(json!({ + "fileKey":"hero", "roots":["hero"], "diagnostics":[], "nodes":{ + "hero":{"id":"hero","type":"FRAME","fields":{ + "name":"Hero","width":1440,"height":720,"childrenIds":["heading","buttons"], + "fills":[{"type":"IMAGE","imageHash":"hero-photo","scaleMode":"FILL"}] + }}, + "heading":{"id":"heading","type":"TEXT","fields":{"characters":"Build your future"}}, + "buttons":{"id":"buttons","type":"FRAME","fields":{"childrenIds":["button"]}}, + "button":{"id":"button","type":"FRAME","fields":{"name":"Button","childrenIds":["label"],"fills":[]}}, + "label":{"id":"label","type":"TEXT","fields":{"characters":"Get started"}} + } + })).unwrap(); + let mut selection: AssetSelection = serde_json::from_value(json!({ + "assetId":"hero:node","format":"png","scale":1 + })) + .unwrap(); + for mode in [AssetRenderMode::OriginalSource, AssetRenderMode::Auto] { + selection.render_mode = mode; + let requests = resolve_asset_selections(&snapshot, &[selection.clone()]).unwrap(); + assert_eq!(requests[0].asset_id, "hero:fills:0"); + assert_eq!(requests[0].field, "fills/0"); + assert_eq!(requests[0].image_hash.as_deref(), Some("hero-photo")); + assert_eq!(requests[0].render_mode, AssetRenderMode::OriginalSource); + } + selection.render_mode = AssetRenderMode::Node; + let requests = resolve_asset_selections(&snapshot, &[selection]).unwrap(); + assert_eq!(requests[0].field, "node"); + let mut assets = discover_asset_manifest(&snapshot).assets; + let asset = assets + .iter_mut() + .find(|asset| asset.node_id == "hero") + .unwrap(); + asset.render_mode = AssetRenderMode::Node; + annotate_assets(&snapshot, &mut assets); + assert!(assets.iter().any(|asset| { + asset.render_info.as_ref().unwrap()["warnings"] + .as_array() + .unwrap() + .iter() + .any(|warning| { + warning + .as_str() + .unwrap() + .contains("node-render-bakes-child-text") + }) + })); +} diff --git a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs index 3ea6405f..06e9baef 100644 --- a/crates/devup-mcp-figma/tests/manifest_covers_readers.rs +++ b/crates/devup-mcp-figma/tests/manifest_covers_readers.rs @@ -12,6 +12,7 @@ use std::{collections::BTreeSet, fs, path::Path}; /// Names that are read through the same accessors but never come from a Figma /// node, so the manifest has nothing to say about them. const NOT_NODE_FIELDS: &[&str] = &[ + "as", // Optional codegen semantic tag override on supplied snapshots; not a Figma API property. // Written by our own scripts onto the node record. "parentId", "parentType", diff --git a/crates/devup-mcp/src/server/asset_guard.rs b/crates/devup-mcp/src/server/asset_guard.rs new file mode 100644 index 00000000..5f0d9394 --- /dev/null +++ b/crates/devup-mcp/src/server/asset_guard.rs @@ -0,0 +1,665 @@ +//! `devup_ui_validate`'s `asset-missing` rule: generated code that points at +//! an asset file the project does not have. +//! +//! An export without `assetPublicRoot` keeps placeholder asset paths, and an +//! asset request that failed or was never made leaves the reference in the +//! TSX with no file behind it. Nothing in the source shows that: the page +//! compiles, and the browser draws an empty box or a broken-image icon where +//! the design had a picture. So when `projectRoot` is given, every +//! root-relative asset URL (`src`, and any `url(...)` in a style value) is +//! looked up under the project's public directories, and one that resolves +//! to no file is an error. +//! +//! Like the rest of the ground-truth tools this reads the disk on every call +//! and never caches. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use devup_mcp_devup_ui::ui_validate::{Severity, Violation}; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use serde_json::{Map, json}; + +use super::mask_guard::{percent_decode, public_dirs_of}; + +pub(super) const RULE: &str = "asset-missing"; +pub(super) const COVERAGE_RULE: &str = "section-coverage"; + +/// Paths may be renamed/deduplicated or percent-encoded at delivery. Keep +/// the original node census, but freeze URLs only after those rewrites. +pub(super) fn refresh_export_asset_urls(result: &mut serde_json::Value) { + if let Some(object) = result.as_object_mut() { + for field in ["tsx", "componentTsx"] { + if let Some(source) = object.get(field).and_then(serde_json::Value::as_str) { + let urls = asset_urls(source) + .into_iter() + .map(|(url, _)| url) + .collect::>(); + if let Some(contract) = object + .get_mut("outputResults") + .and_then(|outputs| outputs.get_mut(field)) + .and_then(|output| output.get_mut("sectionCoverage")) + .and_then(serde_json::Value::as_object_mut) + { + contract.insert("assetUrls".into(), json!(urls)); + } + } + } + if let Some(frames) = object + .get_mut("frames") + .and_then(serde_json::Value::as_array_mut) + { + for frame in frames { + refresh_export_asset_urls(frame); + } + } + } +} + +pub(super) fn asset_coverage_violations( + sources: &[(&str, &str)], + expected: &[String], +) -> Vec { + let present = sources + .iter() + .flat_map(|(_, source)| asset_urls(source).into_iter().map(|(url, _)| url)) + .collect::>(); + expected.iter().collect::>().into_iter().filter(|url| !present.contains(url.as_str())).map(|url| Violation { + rule: COVERAGE_RULE, + severity: Severity::Error, + byte_range: [0, 0], + context: Map::from_iter([("assetUrl".into(), json!(url))]), + message: format!("Asset reference {url} from the original export is missing from the supplied code."), + suggestion: Some("Restore the generated image, mask or background reference from the original export.".into()), + }).collect() +} + +/// Compare against the original export, not the edited code's source map. +/// Parse JSX attributes so comments and string literals cannot satisfy coverage. +pub(super) fn coverage_violations(sources: &[(&str, &str)], expected: &[String]) -> Vec { + let mut present = BTreeSet::new(); + for (_, source) in sources { + for attr in devup_mcp_devup_ui::provenance::attributes::generated_attributes(source) { + if attr.name != "data-figma-node-id" { + continue; + } + let Some((_, value)) = attr.source.split_once('=') else { + continue; + }; + let value = value + .trim() + .trim_start_matches('{') + .trim_end_matches('}') + .trim(); + if let Some(quote @ ('\'' | '"')) = value.chars().next() + && value.ends_with(quote) + { + present.insert(value[1..value.len() - 1].to_owned()); + } + } + } + expected.iter().collect::>().into_iter() + .filter(|id| !present.contains(id.as_str())) + .map(|id| Violation { + rule: COVERAGE_RULE, + severity: Severity::Error, + byte_range: [0, 0], + context: Map::from_iter([("nodeId".into(), json!(id))]), + message: format!("Captured section or asset {id} is missing from the supplied JSX identity coverage."), + suggestion: Some("Restore the generated section/image and its data-figma-node-id from the original export. Static identity coverage does not verify runtime visibility.".into()), + }).collect() +} + +/// Expected visible sections and assets come from the captured tree, even if +/// generation omitted them. Asset boundaries deliberately hide their internals. +pub(super) fn export_coverage( + snapshot: &devup_mcp_figma::Snapshot, + root: &str, + output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, + inline_instances: bool, + annotate: bool, +) -> serde_json::Value { + let mut scoped = snapshot.clone(); + scoped.roots = vec![root.into()]; + let manifest = devup_mcp_figma::discover_asset_manifest(&scoped); + let assets = manifest + .assets + .iter() + .filter(|a| a.error_code.as_deref() != Some("DEVUP_ASSET_NODE_HIDDEN")) + .map(|a| a.node_id.as_str()) + .collect::>(); + let mut wanted = BTreeSet::new(); + let mut seen = BTreeSet::new(); + let mut pending = vec![(root, 0)]; + while let Some((id, depth)) = pending.pop() { + if !seen.insert(id) { + continue; + } + let Some(node) = snapshot.nodes.get(id) else { + continue; + }; + let view = node.typed_view(); + if view.bool("visible") == Some(false) + || view.number("opacity").is_some_and(|opacity| opacity <= 0.0) + { + continue; + } + if manifest + .assets + .iter() + .any(|a| a.node_id == id && a.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN")) + { + continue; + } + if depth <= 1 || node.node_type == "SECTION" || assets.contains(id) { + wanted.insert(id.to_owned()); + } + // Leaf assets (including a flattened wrapper) represent all their children. + let flattened = manifest + .assets + .iter() + .any(|a| a.node_id == id && a.field == "node"); + if flattened || (!inline_instances && node.node_type == "INSTANCE") { + continue; + } + pending.extend(view.child_ids().map(|child| (child, depth + 1))); + } + if annotate { + devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); + // Identity attributes are derived from captured IDs. Record that + // derivation so the final attribute audit can verify them too. + for attr in devup_mcp_devup_ui::provenance::attributes::generated_attributes(&output.tsx) { + if attr.name != "data-figma-node-id" { + continue; + } + let Some((_, value)) = attr.source.split_once('=') else { + continue; + }; + let id = value.trim().trim_matches('"'); + output + .source_map + .entries + .push(devup_mcp_devup_ui::provenance::ProvenanceEntry { + generated_range: Some(devup_mcp_devup_ui::provenance::GeneratedRange { + start: attr.start, + end: attr.end, + }), + calculation: None, + generated_property: Some(attr.source.clone()), + json_pointer: None, + node_id: Some(id.into()), + property: Some("id".into()), + variable_id: None, + style_id: None, + asset_id: None, + resolution: "exact".into(), + element_index: Some(attr.element), + }); + } + } + let node_ids = wanted.into_iter().collect::>(); + let mapped = output + .source_map + .entries + .iter() + .filter(|e| e.element_index.is_some()) + .filter_map(|e| e.node_id.as_ref()) + .collect::>(); + for id in node_ids.iter().filter(|id| !mapped.contains(id)) { + output.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_SECTION_COVERAGE".into(), + node_id: Some(id.clone()), + message: format!("Captured section or asset {id} has no generated JSX element."), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(json!({"stage":"projection","classification":"omitted-content","originalValue":{"nodeId":id},"appliedValue":null,"appliedValueReason":"No generated JSX element maps to the captured node."})), + ..Default::default() + }); + } + for asset in manifest + .assets + .iter() + .filter(|asset| node_ids.contains(&asset.node_id)) + { + if output.source_map.entries.iter().any(|entry| { + entry.asset_id.as_deref() == Some(&asset.asset_id) && entry.element_index.is_some() + }) { + continue; + } + output.diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_SECTION_COVERAGE".into(), + node_id: Some(asset.node_id.clone()), + property: Some("assetReference".into()), + message: format!( + "Captured asset {} has no generated asset reference.", + asset.asset_id + ), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(json!({"stage":"projection","classification":"omitted-content","originalValue":{"assetId":asset.asset_id,"nodeId":asset.node_id},"appliedValue":null,"appliedValueReason":"No generated asset reference maps to the captured asset."})), + ..Default::default() + }); + } + if annotate { + let asset_urls = asset_urls(&output.tsx) + .into_iter() + .map(|(url, _)| url) + .collect::>(); + json!({"nodeIds":node_ids,"assetUrls":asset_urls}) + } else { + serde_json::Value::Null + } +} + +/// Extensions treated as files a page serves from `public`. +const ASSET_EXTENSIONS: &[&str] = &[ + "svg", "png", "jpg", "jpeg", "webp", "gif", "avif", "ico", "bmp", "woff", "woff2", "ttf", + "otf", "mp4", "webm", "mp3", "pdf", +]; + +/// One `asset-missing` error per distinct root-relative asset URL in each +/// source that no public directory of `project_root` holds. A project with +/// no public directory at all is still checked: every asset is then missing. +pub(super) fn violations(sources: &[(&str, &str)], project_root: Option<&str>) -> Vec { + let Some(root) = project_root.map(Path::new).filter(|root| root.is_dir()) else { + return Vec::new(); + }; + let mut public_dirs: Option> = None; + let mut found = Vec::new(); + for (name, source) in sources { + let mut reported = BTreeSet::new(); + for (url, byte_range) in asset_urls(source) { + if !reported.insert(url.clone()) { + continue; + } + let dirs = public_dirs.get_or_insert_with(|| public_dirs_of(root)); + if served(dirs, &url) { + continue; + } + let looked_in = dirs + .iter() + .map(|dir| { + dir.strip_prefix(root) + .unwrap_or(dir) + .to_string_lossy() + .replace('\\', "/") + }) + .collect::>(); + let mut context = Map::new(); + context.insert("assetUrl".into(), json!(url)); + context.insert("publicDirs".into(), json!(looked_in)); + if !name.is_empty() { + context.insert("sourceName".into(), json!(name)); + } + found.push(Violation { + rule: RULE, + severity: Severity::Error, + byte_range, + context, + message: if looked_in.is_empty() { + format!("{url} is referenced, but the project has no public directory, so no file serves it; the browser renders an empty or broken image.") + } else { + format!("{url} is referenced, but no file at that path exists under {}; the browser renders an empty or broken image.", looked_in.join(", ")) + }, + suggestion: Some( + "Acquire the asset with devup_figma_export assetRequests (outputPath under public/, or assetPublicRoot so references are rewritten), or point the reference at the file that exists. Never leave a placeholder path in shipped code.".into(), + ), + }); + } + } + found +} + +/// Whether a root-relative URL is a file under one of `dirs`. A URL with a +/// segment that could leave the directory is never served. +fn served(dirs: &[PathBuf], url: &str) -> bool { + let segments = url.trim_start_matches('/').split('/').collect::>(); + if segments.iter().any(|segment| { + segment.is_empty() || matches!(*segment, "." | "..") || segment.contains(['\\', ':']) + }) { + return false; + } + dirs.iter().any(|dir| { + segments + .iter() + .fold(dir.clone(), |path, segment| path.join(segment)) + .is_file() + }) +} + +/// Root-relative asset URLs with their byte ranges: the quoted value of a +/// `src` prop or property, and the inside of every `url(...)`. +fn asset_urls(source: &str) -> Vec<(String, [usize; 2])> { + #[derive(Default)] + struct Literals { + src: Vec<[usize; 2]>, + strings: Vec<[usize; 2]>, + } + impl<'a> Visit<'a> for Literals { + fn visit_jsx_attribute(&mut self, attr: &JSXAttribute<'a>) { + if attr + .name + .as_identifier() + .is_some_and(|name| name.name == "src") + { + let span = match &attr.value { + Some(JSXAttributeValue::StringLiteral(s)) => Some(s.span), + Some(JSXAttributeValue::ExpressionContainer(c)) => match &c.expression { + JSXExpression::StringLiteral(s) => Some(s.span), + JSXExpression::TemplateLiteral(t) if t.expressions.is_empty() => { + Some(t.span) + } + _ => None, + }, + _ => None, + }; + if let Some(span) = span { + self.src.push([span.start as usize, span.end as usize]); + } + } + walk::walk_jsx_attribute(self, attr); + } + fn visit_object_property(&mut self, prop: &ObjectProperty<'a>) { + if !prop.computed + && prop.key.static_name().as_deref() == Some("src") + && matches!( + &prop.value, + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) + ) + { + let span = prop.value.span(); + self.src.push([span.start as usize, span.end as usize]); + } + walk::walk_object_property(self, prop); + } + fn visit_string_literal(&mut self, literal: &StringLiteral<'a>) { + self.strings + .push([literal.span.start as usize, literal.span.end as usize]); + } + fn visit_template_literal(&mut self, literal: &TemplateLiteral<'a>) { + if literal.expressions.is_empty() { + self.strings + .push([literal.span.start as usize, literal.span.end as usize]); + } + walk::walk_template_literal(self, literal); + } + } + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let mut literals = Literals::default(); + literals.visit_program(&parsed.program); + let mut urls = Vec::new(); + for span in literals.src { + if let Some(raw) = source.get(span[0] + 1..span[1].saturating_sub(1)) + && let Some(url) = asset_path(raw) + { + urls.push((url, span)); + } + } + let mut cursor = 0; + while let Some(offset) = source[cursor..].find("url(") { + let open = cursor + offset + "url(".len(); + let Some(close) = source[open..].find(')') else { + break; + }; + let raw = source[open..open + close] + .trim() + .trim_matches(|c| matches!(c, '\'' | '"')); + if let Some(url) = asset_path(raw) + && literals + .strings + .iter() + .any(|span| span[0] <= open && open + close < span[1]) + { + urls.push((url, [open - "url(".len(), open + close + 1])); + } + cursor = open + close + 1; + } + urls.sort_by_key(|(_, range)| range[0]); + urls +} + +/// The decoded path of a root-relative asset URL, without query or fragment. +/// Template interpolation, protocol-relative and absolute URLs are not files +/// of this project and are left alone. +fn asset_path(raw: &str) -> Option { + if raw.contains("${") { + return None; + } + let path = raw.split(['?', '#']).next().unwrap_or(raw); + if !path.starts_with('/') || path.starts_with("//") { + return None; + } + let extension = path.rsplit_once('.')?.1.to_ascii_lowercase(); + ASSET_EXTENSIONS + .contains(&extension.as_str()) + .then(|| percent_decode(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coverage_uses_jsx_identity_across_files_not_comments_or_strings() { + let expected = vec![ + "section".into(), + "picture".into(), + "absent".into(), + "absent".into(), + ]; + let found = coverage_violations( + &[ + ("page.tsx", r#""#), + ( + "image.tsx", + r#"; /* */ const example = '';"#, + ), + ], + &expected, + ); + assert_eq!(found.len(), 1); + assert_eq!(found[0].context["nodeId"], "absent"); + assert_eq!(found[0].severity, Severity::Error); + } + + #[test] + fn export_reports_omitted_sections_and_images_without_requiring_diagnostics() { + use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; + let snapshot = serde_json::from_value(json!({ + "fileKey":"coverage", "roots":["root"], "diagnostics":[], "nodes": { + "root":{"id":"root","type":"FRAME","fields":{"layoutMode":"VERTICAL","childrenIds":["section","hidden"]}}, + "section":{"id":"section","type":"FRAME","fields":{"layoutMode":"VERTICAL","childrenIds":["picture"]}}, + "picture":{"id":"picture","type":"RECTANGLE","fields":{"width":100,"height":100,"fills":[{"type":"IMAGE","imageHash":"photo"}]}}, + "hidden":{"id":"hidden","type":"FRAME","fields":{"visible":false,"childrenIds":["hiddenPicture"]}}, + "hiddenPicture":{"id":"hiddenPicture","type":"RECTANGLE","fields":{"fills":[{"type":"IMAGE","imageHash":"hidden"}]}} + } + })).unwrap(); + let mut output = generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap(); + let contract = export_coverage(&snapshot, "root", &mut output, true, true); + let attributes = devup_mcp_devup_ui::provenance::attributes::attribute_contract(&output); + let unmapped = devup_mcp_devup_ui::provenance::attributes::uncovered_attributes( + &output.tsx, + &attributes, + ); + assert!( + !unmapped + .iter() + .any(|attr| attr["property"] == "data-figma-node-id"), + "{unmapped:?}" + ); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_SECTION_COVERAGE"), + "{:?}", + output.diagnostics + ); + let ids: Vec = serde_json::from_value(contract["nodeIds"].clone()).unwrap(); + assert!(ids.contains(&"section".into()), "{contract}"); + assert!(ids.contains(&"picture".into()), "{contract}"); + assert!(!ids.contains(&"hiddenPicture".into())); + assert!( + coverage_violations(&[("", &output.tsx)], &ids).is_empty(), + "{}", + output.tsx + ); + // Simulate a generator silently dropping the two nodes from its map. + output + .source_map + .entries + .retain(|e| !matches!(e.node_id.as_deref(), Some("section" | "picture"))); + export_coverage(&snapshot, "root", &mut output, true, false); + let missing = output + .diagnostics + .iter() + .filter(|d| d.code == "DEVUP_CODEGEN_SECTION_COVERAGE") + .collect::>(); + assert_eq!( + missing.iter().filter(|d| d.property.is_none()).count(), + 2, + "{missing:?}" + ); + assert!( + missing + .iter() + .any(|d| d.property.as_deref() == Some("assetReference")), + "{missing:?}" + ); + assert!( + missing + .iter() + .all(|d| d.fidelity_impact == Some(devup_mcp_figma::FidelityImpact::Lossy)) + ); + } + + #[test] + fn src_props_and_style_urls_are_read() { + let source = r#"const page = <> + + + + +About; +const s = { src: '/fonts/a.woff2' }"#; + let urls = asset_urls(source) + .into_iter() + .map(|(url, _)| url) + .collect::>(); + assert_eq!( + urls, + [ + "/images/hero.webp", + "/images/bg.png", + "/icons/a b.svg", + "/icons/c.svg", + "/fonts/a.woff2" + ] + ); + } + + #[test] + fn comments_examples_and_dynamic_sources_are_not_asset_references() { + let source = r#"// +const example = ''; +/* const style = { bg: "url(/comment.svg)" }; */ +; +;"#; + assert!(asset_urls(source).is_empty(), "{:?}", asset_urls(source)); + } + + #[test] + fn an_image_removed_from_an_existing_node_fails_reference_coverage() { + let expected = vec!["/picture.png".into(), "/mask.svg".into()]; + let source = + r#"<>"#; + let found = asset_coverage_violations(&[("", source)], &expected); + assert_eq!(found.len(), 1); + assert_eq!(found[0].context["assetUrl"], "/picture.png"); + } + + #[test] + fn delivery_refreshes_renamed_asset_urls_for_each_output_and_frame() { + let mut result = json!({"frames":[{ + "tsx":"", + "componentTsx":"", + "outputResults":{ + "tsx":{"sectionCoverage":{"nodeIds":["photo"],"assetUrls":["/images/placeholder.png"]}}, + "componentTsx":{"sectionCoverage":{"nodeIds":["icon"],"assetUrls":[]}} + } + }]}); + refresh_export_asset_urls(&mut result); + let output = &result["frames"][0]["outputResults"]; + assert_eq!( + output["tsx"]["sectionCoverage"]["assetUrls"], + json!(["/photos/final name.webp"]) + ); + assert_eq!( + output["componentTsx"]["sectionCoverage"]["assetUrls"], + json!(["/icons/final.svg"]) + ); + assert_eq!( + output["tsx"]["sectionCoverage"]["nodeIds"], + json!(["photo"]) + ); + } + + #[test] + fn traversal_is_never_served() { + let dirs = vec![PathBuf::from("public")]; + assert!(!served(&dirs, "/../Cargo.toml")); + assert!(!served(&dirs, "/a//b.png")); + assert!(!served(&dirs, "/C:/x.png")); + } + + #[test] + fn a_missing_file_is_an_error_and_a_present_one_is_not() -> std::io::Result<()> { + let root = std::env::temp_dir().join(format!( + "asset-guard-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(root.join("apps/web/public/images"))?; + std::fs::write(root.join("apps/web/public/images/hero.webp"), b"RIFF")?; + let tsx = r#"<>"#; + let root_text = root.to_string_lossy().into_owned(); + + let found = violations(&[("Page.tsx", tsx)], Some(&root_text)); + assert_eq!(found.len(), 1, "{found:?}"); + let violation = &found[0]; + assert_eq!(violation.rule, RULE); + assert_eq!(violation.severity, Severity::Error); + assert_eq!(violation.context["assetUrl"], "/images/missing.png"); + assert_eq!(violation.context["sourceName"], "Page.tsx"); + assert_eq!(violation.context["publicDirs"][0], "apps/web/public"); + assert_eq!( + &tsx[violation.byte_range[0]..violation.byte_range[1]], + "\"/images/missing.png\"" + ); + + assert!(violations(&[("", tsx)], None).is_empty()); + std::fs::remove_dir_all(&root)?; + Ok(()) + } + + #[test] + fn a_project_without_public_reports_every_asset() -> std::io::Result<()> { + let root = std::env::temp_dir().join(format!( + "asset-guard-empty-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&root)?; + let found = violations( + &[("", r#""#)], + Some(&root.to_string_lossy()), + ); + assert_eq!(found.len(), 1); + assert!(found[0].message.contains("no public directory")); + std::fs::remove_dir_all(&root)?; + Ok(()) + } +} diff --git a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs index 66e72d47..cadcea96 100644 --- a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs +++ b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs @@ -307,7 +307,14 @@ pub(super) fn url_plan( // Health and containment at every width. let mut checks = vec![ + check(json!({"kind": "no-clipped-content", "label": "navigation and card content not cut off"})), + check(json!({"kind": "sequence-order", "label": "numbered cards in reading order"})), + check(json!({"kind": "section-layout", "label": "responsive sections and text columns"})), check(json!({"kind": "page-health", "label": "page health"})), + check(json!({"kind": "no-low-contrast-text", "label": "visible text contrast"})), + check(json!({"kind": "image-loaded", "label": "images and backgrounds loaded"})), + check(json!({"kind": "native-appearance", "label": "controls drawn without native browser chrome"})), + check(json!({"kind": "stale-resources", "label": "no stale or missing build resources"})), check(json!({"kind": "no-horizontal-overflow", "label": "no horizontal overflow"})), check(json!({"kind": "no-clipped-text", "label": "no clipped text"})), ]; @@ -342,6 +349,7 @@ pub(super) fn url_plan( continue; }; let sections = visible_children(payload, frame_id); + let mut images = Vec::new(); let mut renders_missing = Vec::new(); for section in §ions { let name = name_of(payload, section); @@ -366,6 +374,14 @@ pub(super) fn url_plan( "subject": node(section), "container": node(frame_id), "nodeId": section}), )); } + if !is_asset && bounds(payload, section).is_some_and(|section_box| section_box.width >= frame_box.width * 0.5) { + layout.push(check(json!({"kind": "section-layout", "label": format!("{name} remains a visible section"), "subject": node_at(*width, section), "container": node_at(*width, frame_id), + "widths": viewports.iter().filter(|viewport| { + target.frames.iter().filter(|(drawn, _, _)| *drawn <= viewport.width).max_by_key(|(drawn, _, _)| *drawn) + .or_else(|| target.frames.iter().min_by_key(|(drawn, _, _)| *drawn)) + .is_some_and(|(drawn, _, _)| drawn == width) + }).map(|viewport| viewport.width).collect::>(), "nodeId": section}))); + } if let Some(section_box) = bounds(payload, section) { layout.push(bounds_check( &name, @@ -390,6 +406,9 @@ pub(super) fn url_plan( } } } + if is_asset { + images.push(section.clone()); + } let scope = if is_asset { "asset" } else { "section" }; match sources.native_render(section) { Some((artifact, asset)) => layout.push(capture( @@ -419,9 +438,13 @@ pub(super) fn url_plan( let is_asset = assets_everywhere.contains(&child) || payload.snapshot.nodes[&child].typed_view().bool("isAsset") == Some(true); if !is_asset { + if let Some(alignment) = text_alignment_check(payload, &child, *width) { + parts.push(alignment); + } stack.push((child, section.clone())); continue; } + images.push(child.clone()); let name = name_of(payload, &child); if let (Some(section_box), Some(asset_box)) = (bounds(payload, §ion), bounds(payload, &child)) @@ -449,6 +472,14 @@ pub(super) fn url_plan( } } } + checks.push(completeness_check( + *width, + frame_id, + frame_box.height, + §ions, + images, + &mut notes, + )); for batch in renders_missing.chunks(6) { missing.push(json!({"kind": "nativeRender", "frame": frame_id, "width": width, "nodeIds": batch, "why": "No native-node PNG at scale 1 for these sections or assets, so their visual comparison cannot run.", @@ -953,25 +984,3 @@ fn local_imports(source: &str) -> Vec<(String, String)> { } found } - -fn read_module(base: &Path, module: &str) -> Option { - for suffix in [ - "", - ".ts", - ".tsx", - ".js", - ".mjs", - ".json", - "/index.ts", - "/index.js", - ] { - let path = base.join(format!("{module}{suffix}")); - if path.is_file() - && std::fs::metadata(&path).is_ok_and(|meta| meta.len() <= 512 * 1024) - && let Ok(text) = std::fs::read_to_string(&path) - { - return Some(text); - } - } - None -} diff --git a/crates/devup-mcp/src/server/audit_plan/static_params.rs b/crates/devup-mcp/src/server/audit_plan/static_params.rs index d851c7d4..446bf8f7 100644 --- a/crates/devup-mcp/src/server/audit_plan/static_params.rs +++ b/crates/devup-mcp/src/server/audit_plan/static_params.rs @@ -3,6 +3,40 @@ static FAILURE_CLASSES: std::sync::LazyLock> = std::sync::Lazy .expect("vendored failure classes") }); +/// The rendered-completeness check for one drawn width, listing at most +/// `MAX_COMPLETENESS_NODES` sections and images and noting what it left out. +fn completeness_check( + width: u32, + frame_id: &str, + frame_height: f64, + sections: &[String], + images: Vec, + notes: &mut Vec, +) -> CheckInput { + let limit = runtime_audit::MAX_COMPLETENESS_NODES; + let mut listed = |ids: &[String], kind: &str| { + if ids.len() > limit { + notes.push(format!( + "{} {kind} past the first {limit} left out of the completeness check at {width}px.", + ids.len() - limit + )); + } + ids.iter().take(limit).cloned().collect::>() + }; + let mut unique_images = Vec::new(); + for id in images { + if !unique_images.contains(&id) { + unique_images.push(id); + } + } + let section_ids = listed(sections, "sections"); + let image_ids = listed(&unique_images, "images"); + check(json!({"kind": "rendered-completeness", + "label": format!("page renders its {width}px frame"), + "nodeId": frame_id, "widths": [width], + "completeness": {"frameHeight": frame_height, "sections": section_ids, "images": image_ids}})) +} + /// `generateStaticParams` in the route's page: literal params in its body, /// else the param's values in a local data file it reads. pub(super) fn static_params( @@ -438,6 +472,12 @@ pub(super) fn record_round( record["status"] = json!("resolved"); } } + let mut visual_pending = plan["audit"]["visualPending"].as_array().cloned().unwrap_or_default(); + visual_pending.retain(|comparison| !comparison["routePath"].as_str().zip(comparison["viewport"]["width"].as_u64()) + .is_some_and(|(route, width)| audited.contains(&(route.to_owned(), width)))); + visual_pending.extend(comparisons.iter().filter(|comparison| !visual.iter().any(|result| + comparison["id"] == result.id && matches!(result.verdict.as_str(), "pass" | "fail"))).cloned()); + plan["audit"]["visualPending"] = json!(visual_pending); json!({"pendingComparisons": pending, "inconclusiveComparisons": inconclusive}) } @@ -496,7 +536,7 @@ pub(super) fn work_queue( "why": decision["question"], "meanwhile": "work the rest; it is taken with assume at the end"})); } } - if row["implemented"] != "yes" { + if row["implemented"] == "no" { items.push(json!({"do": "build", "file": row["file"]})); } if let Some(reason) = plan["audit"]["inconclusive"][url].as_str() { @@ -526,7 +566,11 @@ pub(super) fn work_queue( items.push(json!({"do": "compare", "count": comparisons, "how": "run each visualComparisons call and pass the results back as visualResults"})); } - let done = items.is_empty(); + let cells_pass = row["widths"].as_array().is_some_and(|widths| !widths.is_empty() && widths.iter().all(|width| width["result"] == "pass")); + if !cells_pass && items.is_empty() { + items.push(json!({"do":"fix and rerun the audit", "why":"every required URL x width cell must pass before done"})); + } + let done = items.is_empty() && blocked.is_empty() && cells_pass && row["implemented"] == "yes"; items.extend(blocked); let reported = plan["audit"]["converterBugs"] .as_object() @@ -562,7 +606,7 @@ pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> Stri other => other.to_string(), }; let mut out = String::from( - "| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|\n", + "| URL | width | result | completeness | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|\n", ); let records = plan["audit"]["findings"].as_object(); for row in rows { @@ -592,8 +636,10 @@ pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> Stri .count() }; out.push_str(&format!( - "| {url} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", + "| {url} | {} | {} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", width["width"], + cell(&width["result"]), + cell(&columns["completeness"]), cell(&columns["health"]), cell(&columns["geometry"]), count("code-drift"), @@ -784,3 +830,33 @@ pub(super) fn classify_geometry( } } } + +fn text_alignment_check(payload: &CollectedPayload, child: &str, width: u32) -> Option { + let view = payload.snapshot.nodes[child].typed_view(); + let edge = view.string("textAlignHorizontal")?.to_lowercase(); + if !["left", "right", "center", "justify"].contains(&edge.as_str()) { return None; } + Some(check(json!({"kind":"text-align", "label":format!("{} text alignment", name_of(payload, child)), + "subject":node_at(width, child), "nodeId":child, "edge":edge, "widths":[width]}))) +} + +fn read_module(base: &Path, module: &str) -> Option { + for suffix in [ + "", + ".ts", + ".tsx", + ".js", + ".mjs", + ".json", + "/index.ts", + "/index.js", + ] { + let path = base.join(format!("{module}{suffix}")); + if path.is_file() + && std::fs::metadata(&path).is_ok_and(|meta| meta.len() <= 512 * 1024) + && let Ok(text) = std::fs::read_to_string(&path) + { + return Some(text); + } + } + None +} diff --git a/crates/devup-mcp/src/server/audit_plan_tests.rs b/crates/devup-mcp/src/server/audit_plan_tests.rs index 496fbc60..caedca6e 100644 --- a/crates/devup-mcp/src/server/audit_plan_tests.rs +++ b/crates/devup-mcp/src/server/audit_plan_tests.rs @@ -175,6 +175,9 @@ fn a_two_url_audit_plan_checks_the_page_its_sections_assets_geometry_overflow_an for plan in [&home.plan, &about.plan] { for kind in [ "page-health", + "sequence-order", + "section-layout", + "rendered-completeness", "no-horizontal-overflow", "no-clipped-text", "bounds", @@ -185,6 +188,18 @@ fn a_two_url_audit_plan_checks_the_page_its_sections_assets_geometry_overflow_an } } let checks = &home.plan.screens[0].checks; + for kind in ["no-low-contrast-text", "image-loaded"] { + assert!(checks.iter().any(|check| check.kind == kind && check.widths.is_empty())); + } + let completeness = checks.iter().filter(|check| check.kind == "rendered-completeness").collect::>(); + assert_eq!(completeness.len(), 2); + for check in completeness { + assert_eq!(check.widths.len(), 1); + let frame = check.completeness.as_ref().unwrap(); + assert!(frame.frame_height > 0.0); + assert!(!frame.sections.is_empty()); + } + let captures = checks .iter() .filter_map(|check| { @@ -763,8 +778,8 @@ fn a_mismatch_with_the_code_still_equal_to_the_export_is_a_converter_bug_with_a_ ]; let queue = work_queue(&plan, &rows, &[]); assert_eq!( - queue[0]["done"], true, - "a filed converter bug does not hold the URL: {queue:?}" + queue[0]["done"], false, + "a filed converter bug remains reported, but a failing cell cannot be called done: {queue:?}" ); assert_eq!(queue[0]["reported"][0]["converterBug"], "10:132"); let report = final_report(&plan, &rows, ""); @@ -818,3 +833,15 @@ fn a_mismatch_without_an_export_record_keeps_its_class_and_says_why() { .contains("No export record") ); } + +#[test] +fn an_asset_capture_waiting_for_comparison_blocks_completion() { + let mut plan = json!({"audit":{}}); + let audit = json!({"screenTable":[{"routePath":"/", "width":1440}], "visualComparisons":[ + {"id":"home|asset|icon|1440", "routePath":"/", "viewport":{"width":1440}, "blockers":[]} + ]}); + record_round(&mut plan, &audit, "run", &[], &BTreeMap::new(), 3); + assert_eq!(plan["audit"]["visualPending"].as_array().unwrap().len(), 1); + record_round(&mut plan, &audit, "run", &[VisualResultInput { id:"home|asset|icon|1440".into(), verdict:"pass".into(), changed_ratio:Some(0.0), diff_path:None, reason:None }], &BTreeMap::new(), 3); + assert!(plan["audit"]["visualPending"].as_array().unwrap().is_empty()); +} diff --git a/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs b/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs index 61f44fa2..6965ea00 100644 --- a/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs @@ -620,6 +620,7 @@ fn attached_file(file: &AttachedFile) -> Value { "zoom":file.context.zoom,"uiDevicePixelRatio":file.context.ui_device_pixel_ratio, "contextFreshness":"last-delivered-event","observedAt":file.context.observed_at, "lastKnownSelection":file.context.last_known_selection,"lastKnownPage":file.context.last_known_page, + "pluginVersion":file.context.plugin_version, "displayContextEvidenceLimit":"Zoom is Figma viewport zoom; DPR belongs to the plugin UI iframe and does not verify screenshot capture scale.", }); if let Some(in_view) = file.context.in_view { diff --git a/crates/devup-mcp/src/server/diagnostics/tests.rs b/crates/devup-mcp/src/server/diagnostics/tests.rs index 08464e2e..e4f550ae 100644 --- a/crates/devup-mcp/src/server/diagnostics/tests.rs +++ b/crates/devup-mcp/src/server/diagnostics/tests.rs @@ -86,6 +86,7 @@ mod tests { selection: selected.map(|count| (1..=count).map(node).collect()), selection_count: selected, in_view: Some(false), + plugin_version: Some("0.4.6".to_owned()), ..Default::default() }, } @@ -218,6 +219,7 @@ mod tests { assert_eq!(file["selection"][0]["type"], "FRAME"); // Out of view, status says so before a collection meets Figma's refusals. assert_eq!(file["inView"], false); + assert_eq!(file["pluginVersion"], "0.4.6"); assert!( file["inViewNote"] .as_str() diff --git a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs index 2d897df5..e962bb9d 100644 --- a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs +++ b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs @@ -105,6 +105,27 @@ impl DevupServer { report.ok = false; } report.violations.extend(masks); + // A reference with no file behind it renders an empty box, which only + // the disk shows. + let missing = asset_guard::violations(&sources, input.project_root.as_deref()); + if !missing.is_empty() { + report.ok = false; + } + report.violations.extend(missing); + if let Some(coverage) = &input.section_coverage { + if coverage.node_ids.len() + coverage.asset_urls.len() > 4096 { + return Err(invalid("sectionCoverage exceeds 4096 entries.".into())); + } + let mut missing = asset_guard::coverage_violations(&sources, &coverage.node_ids); + missing.extend(asset_guard::asset_coverage_violations( + &sources, + &coverage.asset_urls, + )); + if !missing.is_empty() { + report.ok = false; + } + report.violations.extend(missing); + } // These keys are assembled here rather than serialized from // `UiValidation`, so anything the struct's own documentation // explains reaches nobody unless it is answered here too. Three diff --git a/crates/devup-mcp/src/server/mask_guard.rs b/crates/devup-mcp/src/server/mask_guard.rs index c15e6790..72287c30 100644 --- a/crates/devup-mcp/src/server/mask_guard.rs +++ b/crates/devup-mcp/src/server/mask_guard.rs @@ -168,7 +168,7 @@ fn value_end(bytes: &[u8], start: usize) -> Option { } } -fn percent_decode(path: &str) -> String { +pub(super) fn percent_decode(path: &str) -> String { let bytes = path.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; @@ -190,7 +190,7 @@ fn percent_decode(path: &str) -> String { /// `/public` first, then any other `public` directory the project's /// packages have, as the authoritative scan finds them. -fn public_dirs_of(root: &Path) -> Vec { +pub(super) fn public_dirs_of(root: &Path) -> Vec { let mut dirs = Vec::new(); let top = root.join("public"); if top.is_dir() { diff --git a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs index efd56b3a..31e6b0fc 100644 --- a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs +++ b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs @@ -1,5 +1,3 @@ - - /// Cuts the ranked candidate list to `limit` and reports the two truncations /// apart. /// @@ -66,51 +64,51 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value { #[path = "../coerce_args.rs"] mod coerce_args; -#[path = "../handlers/devup_figma_measure.rs"] -mod devup_figma_measure_handler; -#[path = "../handlers/devup_skills.rs"] -mod devup_skills_handler; +#[path = "../handlers/devup_feature_trace.rs"] +mod devup_feature_trace_handler; #[path = "../handlers/devup_figma_auth.rs"] mod devup_figma_auth_handler; -#[path = "../handlers/devup_figma_search.rs"] -mod devup_figma_search_handler; #[path = "../handlers/devup_figma_explore.rs"] mod devup_figma_explore_handler; #[path = "../handlers/devup_figma_export.rs"] mod devup_figma_export_handler; +#[path = "../handlers/devup_figma_measure.rs"] +mod devup_figma_measure_handler; +#[path = "../handlers/devup_figma_search.rs"] +mod devup_figma_search_handler; #[path = "../handlers/devup_project_context.rs"] mod devup_project_context_handler; -#[path = "../handlers/devup_ui_validate.rs"] -mod devup_ui_validate_handler; -#[path = "../handlers/devup_stack_diff.rs"] -mod devup_stack_diff_handler; -#[path = "../handlers/devup_visual_compare.rs"] -mod devup_visual_compare_handler; -#[path = "../handlers/devup_feature_trace.rs"] -mod devup_feature_trace_handler; -#[path = "../handlers/devup_runtime_audit.rs"] -mod devup_runtime_audit_handler; #[path = "../handlers/devup_project_plan.rs"] mod devup_project_plan_handler; +#[path = "../handlers/devup_runtime_audit.rs"] +mod devup_runtime_audit_handler; #[path = "../handlers/devup_screen_plan.rs"] mod devup_screen_plan_handler; +#[path = "../handlers/devup_skills.rs"] +mod devup_skills_handler; +#[path = "../handlers/devup_stack_diff.rs"] +mod devup_stack_diff_handler; +#[path = "../handlers/devup_ui_validate.rs"] +mod devup_ui_validate_handler; +#[path = "../handlers/devup_visual_compare.rs"] +mod devup_visual_compare_handler; impl DevupServer { fn tool_router() -> ToolRouter { - Self::devup_figma_measure_router() + - Self::devup_skills_router() + - Self::devup_figma_auth_router() + - Self::devup_figma_search_router() + - Self::devup_figma_explore_router() + - Self::devup_figma_export_router() + - Self::devup_project_context_router() + - Self::devup_ui_validate_router() + - Self::devup_stack_diff_router() + - Self::devup_visual_compare_router() + - Self::devup_feature_trace_router() + - Self::devup_runtime_audit_router() + - Self::devup_project_plan_router() + - Self::devup_screen_plan_router() + Self::devup_figma_measure_router() + + Self::devup_skills_router() + + Self::devup_figma_auth_router() + + Self::devup_figma_search_router() + + Self::devup_figma_explore_router() + + Self::devup_figma_export_router() + + Self::devup_project_context_router() + + Self::devup_ui_validate_router() + + Self::devup_stack_diff_router() + + Self::devup_visual_compare_router() + + Self::devup_feature_trace_router() + + Self::devup_runtime_audit_router() + + Self::devup_project_plan_router() + + Self::devup_screen_plan_router() } } @@ -151,6 +149,10 @@ fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { .collect::>(); if input.project_root.is_some() { rules.push(mask_guard::RULE); + rules.push(asset_guard::RULE); + } + if input.section_coverage.is_some() { + rules.push(asset_guard::COVERAGE_RULE); } if input.files.is_some() { rules.extend([ @@ -164,6 +166,10 @@ fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { result["verificationScope"] = json!({ "static": { "rules": rules, + "sectionCoverage": { + "state": if input.section_coverage.is_some() { "checked" } else { "unverified" }, + "scope": "Supplied JSX identities and asset references; runtime use and visibility remain unverified." + }, "unknownTokenCheckRan": result["tokens"]["unknownTokenCheckRan"], "layout": layout["checked"], "referenceWidth": input.reference_width, diff --git a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs index 47a466f4..4675934a 100644 --- a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs +++ b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs @@ -1,5 +1,7 @@ #[path = "../artifacts.rs"] pub mod artifacts; +#[path = "../asset_guard.rs"] +mod asset_guard; #[path = "../asset_jobs.rs"] mod asset_jobs; #[path = "../audit_plan.rs"] @@ -481,4 +483,4 @@ pub enum Bridge { /// `DEVUP_FIGMA_BRIDGE_PORT` decides, as in a serving process. FromEnvironment, Off, -} \ No newline at end of file +} diff --git a/crates/devup-mcp/src/server/projection/delivered_property_audit.rs b/crates/devup-mcp/src/server/projection/delivered_property_audit.rs index 371cc318..df2fbcad 100644 --- a/crates/devup-mcp/src/server/projection/delivered_property_audit.rs +++ b/crates/devup-mcp/src/server/projection/delivered_property_audit.rs @@ -23,6 +23,16 @@ fn audit_delivered_properties(value: &mut Value) { let Some(code) = value[field].as_str() else { continue; }; + let risks = devup_mcp_devup_ui::ui_layout::layout_risks(code, None)["risks"].clone(); + if let Some(risks) = risks.as_array() { + if !value["layoutRisks"].is_array() { value["layoutRisks"] = json!([]); } + for risk in risks { + let mut risk = risk.clone(); + risk["output"] = json!(field); + value["layoutRisks"].as_array_mut().unwrap().push(risk); + } + } + let code = value[field].as_str().unwrap(); let mut missing = devup_mcp_devup_ui::provenance::attributes::uncovered_attributes(code, &contract); let layout = &value["outputResults"][field]["fidelity"]["layout"]; diff --git a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs index 91d75764..f1c83719 100644 --- a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs +++ b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs @@ -160,6 +160,7 @@ pub(super) async fn finalize_export_delivery( } let mut value = Value::Object(std::mem::take(&mut result)); rewrite_result_asset_references(&mut value, &replacements); + super::super::asset_guard::refresh_export_asset_urls(&mut value); audit_delivered_properties(&mut value); super::super::interactions::attach(&mut value, &payload.snapshot); attach_diagnostic_screens(&mut value, &owners); diff --git a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs index 19f0f655..136c20be 100644 --- a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs +++ b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs @@ -102,8 +102,25 @@ pub(super) fn project_node_and_theme_outputs( } .with_payload_tokens(payload), )?; + if let Some(root) = theme_options.project_root.as_deref() + && super::super::screen_plan::node_ids_enabled(root) + { + let annotated = annotate_node_ids(&mut output, &payload.snapshot, node_id); + result.insert("figmaNodeIds".to_owned(), annotated); + } + let coverage = super::super::asset_guard::export_coverage( + &payload.snapshot, + node_id, + &mut output, + true, + theme_options + .project_root + .as_deref() + .is_some_and(super::super::screen_plan::node_ids_enabled), + ); scope_output(&mut output, "tsx"); result.entry("outputResults").or_insert_with(|| json!({}))["tsx"] = output_result(&output); + result["outputResults"]["tsx"]["sectionCoverage"] = coverage; result.insert( "widthBehavior".to_owned(), devup_mcp_devup_ui::codegen::width::width_behavior(&payload.snapshot, node_id, &output), @@ -111,12 +128,6 @@ pub(super) fn project_node_and_theme_outputs( projection_diagnostics.extend(output.diagnostics.iter().cloned()); fidelity_reports.push(output.fidelity_report.clone()); generated_output_target = "tsx"; - if let Some(root) = theme_options.project_root.as_deref() - && super::super::screen_plan::node_ids_enabled(root) - { - let annotated = annotate_node_ids(&mut output, &payload.snapshot, node_id); - result.insert("figmaNodeIds".to_owned(), annotated); - } tsx_source_map = Some(output.source_map.clone()); if keep_tsx && output_paths.contains_key("tsx") { pending_text_outputs.insert("tsx".to_owned(), output.tsx.clone()); @@ -161,9 +172,20 @@ pub(super) fn project_node_and_theme_outputs( ); match output { Ok(mut output) => { + let coverage = super::super::asset_guard::export_coverage( + &payload.snapshot, + node_id, + &mut output, + false, + theme_options + .project_root + .as_deref() + .is_some_and(super::super::screen_plan::node_ids_enabled), + ); scope_output(&mut output, "componentTsx"); result.entry("outputResults").or_insert_with(|| json!({}))["componentTsx"] = output_result(&output); + result["outputResults"]["componentTsx"]["sectionCoverage"] = coverage; projection_diagnostics.extend(output.diagnostics.iter().cloned()); fidelity_reports.push(output.fidelity_report.clone()); if tsx_source_map.is_none() { diff --git a/crates/devup-mcp/src/server/projection/project_operation.rs b/crates/devup-mcp/src/server/projection/project_operation.rs index e07cfbc8..3de92a35 100644 --- a/crates/devup-mcp/src/server/projection/project_operation.rs +++ b/crates/devup-mcp/src/server/projection/project_operation.rs @@ -121,6 +121,26 @@ async fn project_operation( delivery, width_policy, } => { + // Source selection can turn a nodeId target into its IMAGE fill. + // Keep requested file destinations and capture reporting attached + // to the resolved asset rather than the convenience node alias. + for capture in &mut asset_captures { + if !matches!(capture.render_mode, devup_mcp_figma::AssetRenderMode::OriginalSource | devup_mcp_figma::AssetRenderMode::Auto) { + continue; + } + let Some(asset) = payload.assets.iter().find(|asset| { + asset.render_mode.is_original() && asset.field.starts_with("fills/") + && (asset.asset_id == capture.asset_id + || capture.asset_id == format!("{}:node", asset.node_id)) + }) else { continue }; + if capture.asset_id != asset.asset_id { + if let Some(path) = asset_output_paths.remove(&capture.asset_id) { + asset_output_paths.insert(asset.asset_id.clone(), path); + } + capture.asset_id = asset.asset_id.clone(); + } + capture.render_mode = asset.render_mode; + } if web_ready_images { asset_captures = payload .assets @@ -517,6 +537,10 @@ async fn project_operation( section_tsx_projected, ) = project_section_frames::project_section_frames( project_section_frames::SectionFrames { + annotate_node_ids: theme_options + .project_root + .as_deref() + .is_some_and(super::screen_plan::node_ids_enabled), failures, design_fingerprints: &design_fingerprints, payload, diff --git a/crates/devup-mcp/src/server/projection/project_section_frames.rs b/crates/devup-mcp/src/server/projection/project_section_frames.rs index f13be26b..3054da40 100644 --- a/crates/devup-mcp/src/server/projection/project_section_frames.rs +++ b/crates/devup-mcp/src/server/projection/project_section_frames.rs @@ -1,6 +1,7 @@ use super::*; pub(super) struct SectionFrames<'a> { + pub(super) annotate_node_ids: bool, pub(super) design_fingerprints: &'a Option>, pub(super) payload: &'a CollectedPayload, pub(super) artifact: &'a ArtifactLookup, @@ -42,6 +43,7 @@ type SectionFrameProjection = Result< pub(super) fn project_section_frames(input: SectionFrames<'_>) -> SectionFrameProjection { let SectionFrames { + annotate_node_ids, design_fingerprints, payload, artifact, @@ -197,8 +199,16 @@ pub(super) fn project_section_frames(input: SectionFrames<'_>) -> SectionFramePr continue; } }; + let coverage = super::super::asset_guard::export_coverage( + &payload.snapshot, + &candidate.node.node_id, + &mut output, + field == "tsx", + annotate_node_ids, + ); scope_output(&mut output, field); frame["outputResults"][field] = output_result(&output); + frame["outputResults"][field]["sectionCoverage"] = coverage; if frame.get("widthBehavior").is_none() { frame["widthBehavior"] = devup_mcp_devup_ui::codegen::width::width_behavior( &payload.snapshot, diff --git a/crates/devup-mcp/src/server/projection/regression_tests.rs b/crates/devup-mcp/src/server/projection/regression_tests.rs index 301ac9e3..cc9eacb1 100644 --- a/crates/devup-mcp/src/server/projection/regression_tests.rs +++ b/crates/devup-mcp/src/server/projection/regression_tests.rs @@ -36,6 +36,7 @@ mod w1_regressions { include!("w1_regressions.rs"); include!("w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs"); include!("w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs"); + include!("w1_regressions_hero_source.rs"); include!("w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs"); include!("w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs"); } diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_hero_source.rs b/crates/devup-mcp/src/server/projection/w1_regressions_hero_source.rs new file mode 100644 index 00000000..6071d702 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_regressions_hero_source.rs @@ -0,0 +1,44 @@ +#[tokio::test] +async fn hero_node_source_selection_preserves_the_requested_file_destination() { + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(8, 4) + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let bytes = encoded.into_inner(); + let mut data = p3_asset_payload(&bytes); + data.assets.truncate(1); + let hero = data.snapshot.nodes.get_mut("1:2").unwrap(); + hero.node_type = "FRAME".into(); + hero.fields.insert("name".into(), json!("Hero")); + hero.fields.insert("childrenIds".into(), json!(["heading"])); + data.snapshot.nodes.insert("heading".into(), serde_json::from_value(json!({ + "id":"heading","type":"TEXT","fields":{"characters":"Hero heading"} + })).unwrap()); + data.assets[0].data_base64 = Some(STANDARD.encode(&bytes)); + data.assets[0].byte_length = Some(bytes.len()); + data.assets[0].sha256 = Some(sha256_hex(&bytes)); + data.assets[0].render_mode = devup_mcp_figma::AssetRenderMode::OriginalSource; + let path = std::env::temp_dir().join(format!("hero-original-source-{}.png", std::process::id())); + let mut op = operation(&["assetManifest"]); + if let PendingOperation::Export { asset_captures, asset_output_paths, .. } = &mut op { + asset_captures.push(serde_json::from_value(json!({ + "assetId":"1:2:node","format":"png","scale":1 + })).unwrap()); + asset_output_paths.insert("1:2:node".into(), path.to_string_lossy().into_owned()); + } + let store = ArtifactStore::default(); + let artifact = store.insert( + ArtifactRequestKey::from_collection(&CollectionRequest::new(data.target.clone(), CollectionScope::Node)), + data, + ).await.unwrap(); + let result = complete_operation( + op, &artifact.payload, "fixture", &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), &store, + ).await.unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let asset = &result["assetManifest"]["assets"][0]; + assert_eq!(asset["assetId"], "1:2:fills:0"); + assert_eq!(asset["renderMode"], "original-source"); + assert_eq!(asset["outputPath"], result["outputPaths"]["asset:1:2:fills:0"]); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs index 2aaeb0cd..2dbb16d7 100644 --- a/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs +++ b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs @@ -163,4 +163,107 @@ fn visual_comparison( "blockers": blockers, "note": include_str!("../text/runtime_audit-visual_comparison-8418b2cf.md"), }) -} \ No newline at end of file +} +/// What a page must render to count as built from its frame: the height the +/// frame was drawn at, every section, every image, and text a person can +/// see. Code on disk that renders a blank, a cut-short page, a broken image +/// or white-on-white text is written, not implemented. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct CompletenessInput { + /// The frame's height at this width, in Figma logical px. + pub frame_height: f64, + /// The rendered page must be at least this share of frameHeight tall; + /// default 0.9. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_height_ratio: Option, + /// Figma node ids of the frame's sections; each must render visible with + /// a non-empty box. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sections: Vec, + /// Figma node ids of the frame's images and icons; each must render with + /// a non-empty box, an decoded. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, + /// Lowest contrast ratio text may have against the background painted + /// behind it; default 1.5, below which text cannot be seen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_contrast: Option, +} + +/// Most node ids one completeness check lists per kind. +pub(crate) const MAX_COMPLETENESS_NODES: usize = 200; + +fn validate_completeness(completeness: &CompletenessInput) -> Result<(), DevupError> { + if !completeness.frame_height.is_finite() + || completeness.frame_height <= 0.0 + || completeness.frame_height > 100_000.0 + { + return Err(invalid( + "completeness.frameHeight must be a positive finite px value.", + )); + } + if completeness + .min_height_ratio + .is_some_and(|ratio| !(ratio > 0.0 && ratio <= 1.0)) + { + return Err(invalid("completeness.minHeightRatio must be in (0, 1].")); + } + if completeness + .min_contrast + .is_some_and(|ratio| !(1.0..=21.0).contains(&ratio)) + { + return Err(invalid("completeness.minContrast must be in [1, 21].")); + } + for (field, ids) in [ + ("completeness.sections", &completeness.sections), + ("completeness.images", &completeness.images), + ] { + if ids.len() > MAX_COMPLETENESS_NODES { + return Err(invalid(format!( + "{field} lists at most {MAX_COMPLETENESS_NODES} node ids." + ))); + } + let mut seen = std::collections::BTreeSet::new(); + for id in ids { + bounded_text(&format!("{field}[]"), id)?; + if id.contains(['"', '\\', ']']) { + return Err(invalid(format!( + "{field}[] must be a Figma node id, without quotes, backslashes or brackets." + ))); + } + if !seen.insert(id.as_str()) { + return Err(invalid(format!("{field} repeats {id}."))); + } + } + } + Ok(()) +} + +/// At a drawn width, checks that pass on code rendering a blank or half a +/// page prove nothing: without a rendered-completeness check the width stays +/// not-run. +fn require_completeness( + row: &mut Value, + screen: &ScreenInput, + viewport: &ViewportInput, + judged: &[(&str, &CheckInput)], +) { + let drawn_here = screen + .figma + .as_ref() + .is_some_and(|figma| figma.drawn_widths.contains(&viewport.width)); + if drawn_here + && row["status"] == "pass" + && !judged + .iter() + .any(|(_, check)| check.kind == "rendered-completeness") + { + row["status"] = json!("not-run"); + row["reasons"] = json!([format!( + "no rendered-completeness check at {}px, a width the frame was drawn at; passing checks do not show the page renders its frame", + viewport.width + )]); + } +} diff --git a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs index 14a1f3a0..30d3c5f4 100644 --- a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs +++ b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs @@ -206,6 +206,10 @@ pub struct CheckInput { /// next screen should start from its own routePath. The plan writer /// moves it after the screen's other checks and the executor returns to /// the screen's URL after it, so order in the plan cannot break a run. + /// text-align: subject computed text alignment matches edge from Figma. + /// sequence-order: numbered siblings increase in visual reading order. + /// section-layout: visible sections span at least half their container. + /// no-clipped-content: internal clipped rows require an explicit carousel with controls. /// page-health: no error page text ("This page couldn't load", /// "Application error", ...), page error, console error or failed /// navigation while the screen was open. @@ -221,7 +225,18 @@ pub struct CheckInput { /// something visible; without a subject, every visible link and button. /// capture: screenshot subject's element box for devup_visual_compare /// against `reference`; ingest turns it into a ready call. - #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden"]))] + /// no-low-contrast-text: text against effective computed background colours. + /// image-loaded: every img and CSS background URL decodes with naturalWidth > 0. + /// Both accept an optional subject scope. + /// native-appearance: visible form controls (optional subject scope) have + /// computed appearance none, not the browser's native face. + /// stale-resources: no image, script, stylesheet, font or media response + /// while the screen was open has an HTTP error status. + /// rendered-completeness: at one drawn width, the page is at least + /// minHeightRatio of the frame's height, every listed section and image + /// renders (images decoded), and no text is invisible against what is + /// painted behind it. A drawn width without a passing one is not-run. + #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden", "rendered-completeness", "sequence-order", "section-layout", "text-align", "no-clipped-content", "no-low-contrast-text", "image-loaded", "native-appearance", "stale-resources"]))] pub kind: String, #[serde(default)] pub subject: Option, @@ -266,6 +281,10 @@ pub struct CheckInput { /// capture: what the element screenshot is compared with. #[serde(default, skip_serializing_if = "Option::is_none")] pub reference: Option, + /// rendered-completeness: what the frame drawn at this width says must be + /// on the page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completeness: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -604,11 +623,12 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { Ok(()) }; match check.kind.as_str() { - "no-horizontal-overflow" | "page-health" => needs(false, false)?, + "no-horizontal-overflow" | "page-health" | "no-clipped-content" => needs(false, false)?, "visible" | "view-transition" | "hidden" => needs(true, false)?, - "no-clipped-text" => { + "stale-resources" => needs(false, false)?, + "no-clipped-text" | "no-low-contrast-text" | "image-loaded" | "native-appearance" => { if check.container.is_some() { - return Err(invalid("A no-clipped-text check takes no container.")); + return Err(invalid(format!("A {} check takes no container.", check.kind))); } } "bounds" => { @@ -673,6 +693,30 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { )); } } + "text-align" => { + needs(true, false)?; + if !matches!(check.edge.as_deref(), Some("left" | "right" | "center" | "justify")) { + return Err(invalid("A text-align check needs edge left, right, center or justify.")); + } + } + "sequence-order" => needs(false, false)?, + "section-layout" => { + if check.subject.is_some() { needs(true, true)?; } else { needs(false, false)?; } + } + "rendered-completeness" => { + needs(false, false)?; + let Some(completeness) = &check.completeness else { + return Err(invalid( + "A rendered-completeness check needs completeness {frameHeight, sections, images}.", + )); + }; + validate_completeness(completeness)?; + if check.widths.len() != 1 { + return Err(invalid( + "A rendered-completeness check holds one frame, so it names exactly one width.", + )); + } + } "contained" | "spans" => needs(true, true)?, "aligned" => { needs(true, true)?; @@ -688,6 +732,11 @@ fn validate_check(check: &CheckInput) -> Result<(), DevupError> { ("expected", check.expected.is_some(), "bounds"), ("state", check.state.is_some(), "interaction"), ("reference", check.reference.is_some(), "capture"), + ( + "completeness", + check.completeness.is_some(), + "rendered-completeness", + ), ] { if present && check.kind != kind { return Err(invalid(format!("{field} is only valid for {kind} checks."))); @@ -870,7 +919,7 @@ fn columns( let health = if unreached { "fail" } else { - fold(&["page-health", "no-horizontal-overflow", "no-clipped-text"]) + fold(&["page-health", "no-horizontal-overflow", "no-clipped-content", "no-clipped-text", "no-low-contrast-text", "image-loaded", "native-appearance", "stale-resources"]) }; let captures = |scope: &str| { let count = judged @@ -903,9 +952,15 @@ fn columns( .figma .as_ref() .is_some_and(|figma| figma.drawn_widths.contains(&viewport.width)); + let completeness = match fold(&["rendered-completeness"]) { + "-" if drawn => "not-run", + "-" => "not drawn", + other => other, + }; json!({ "health": health, - "geometry": fold(&["bounds", "dimensions", "contained", "aligned", "spans", "visible"]), + "completeness": completeness, + "geometry": fold(&["text-align", "sequence-order", "section-layout", "text-align", "bounds", "dimensions", "contained", "aligned", "spans", "visible"]), "interaction": fold(&["interaction", "view-transition"]), "page": if drawn { json!(if row["screenshot"].is_string() { "1 to compare" } else { "not captured" }) } else { json!("not drawn") }, "section": captures("section"), diff --git a/crates/devup-mcp/src/server/runtime_audit/tests.rs b/crates/devup-mcp/src/server/runtime_audit/tests.rs index edb0d9b2..faf1eaa0 100644 --- a/crates/devup-mcp/src/server/runtime_audit/tests.rs +++ b/crates/devup-mcp/src/server/runtime_audit/tests.rs @@ -353,6 +353,23 @@ mod tests { ); } + #[test] + fn drawn_widths_need_completeness_before_they_can_pass() { + let mut screen = plan_input().screens.remove(0); + screen.figma = Some(serde_json::from_value(json!({"nodeId": "1:1", "drawnWidths": [1440]})).unwrap()); + let viewport: ViewportInput = serde_json::from_value(json!({"width": 1440, "height": 900})).unwrap(); + let mut missing = json!({"status": "pass", "reasons": []}); + require_completeness(&mut missing, &screen, &viewport, &[]); + assert_eq!(missing["status"], "not-run"); + let check: CheckInput = serde_json::from_value(json!({"kind": "rendered-completeness", "widths": [1440], "completeness": {"frameHeight": 2000}})).unwrap(); + let mut complete = json!({"status": "pass"}); + require_completeness(&mut complete, &screen, &viewport, &[("pass", &check)]); + assert_eq!(complete["status"], "pass"); + let mut failed = json!({"status": "fail"}); + require_completeness(&mut failed, &screen, &viewport, &[]); + assert_eq!(failed["status"], "fail"); + } + #[test] fn the_new_check_kinds_take_what_they_measure() { let ok = |check: Value| { @@ -364,6 +381,30 @@ mod tests { }; let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); assert!(ok(json!({"kind": "page-health"})).is_ok()); + for kind in ["sequence-order", "section-layout", "no-clipped-content"] { + assert!(ok(json!({"kind": kind})).is_ok()); + } + assert!(ok(json!({"kind":"text-align", "subject":node("1:2"), "edge":"center"})).is_ok()); + assert!(ok(json!({"kind":"text-align", "subject":node("1:2"), "edge":"bad"})).is_err()); + assert!(ok(json!({"kind": "stale-resources"})).is_ok()); + assert!(ok(json!({"kind": "stale-resources", "subject": node("1:2")})).is_err()); + for kind in ["no-low-contrast-text", "image-loaded", "native-appearance"] { + assert!(ok(json!({"kind": kind})).is_ok()); + assert!(ok(json!({"kind": kind, "subject": node("1:2")})).is_ok()); + assert!(ok(json!({"kind": kind, "container": node("1:2")})).is_err()); + } + assert!(ok(json!({"kind": "rendered-completeness", "widths": [1440], "completeness": {"frameHeight": 2000, "sections": ["1:2"], "images": ["1:3"]}})).is_ok()); + for completeness in [ + json!({"frameHeight": 0}), + json!({"frameHeight": 100, "minHeightRatio": 1.1}), + json!({"frameHeight": 100, "minContrast": 22}), + json!({"frameHeight": 100, "sections": ["1:2", "1:2"]}), + json!({"frameHeight": 100, "images": ["bad]id"]}), + ] { + assert!(ok(json!({"kind": "rendered-completeness", "widths": [1440], "completeness": completeness})).is_err()); + } + assert!(ok(json!({"kind": "rendered-completeness", "completeness": {"frameHeight": 100}})).is_err()); + assert!(ok(json!({"kind": "no-clipped-text"})).is_ok()); assert!(ok(json!({"kind": "interaction", "state": "hover"})).is_ok()); assert!( diff --git a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs index 3b27a486..23d1c6f4 100644 --- a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs +++ b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs @@ -443,6 +443,7 @@ fn ingest_references( &environment_errors, &outcomes, ); + require_completeness(&mut row, screen, viewport, &judged); row["columns"] = columns(screen, viewport, &judged, &row); // A screen that never rendered fails as a runtime error, not as // the checks that could not measure it. @@ -916,6 +917,9 @@ fn array_text(value: &Value) -> String { pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { match kind { "page-health" => "runtime-error", + "image-loaded" | "stale-resources" => "asset-mismatch", + "no-low-contrast-text" | "native-appearance" => "section-visual", + "rendered-completeness" => "page-visual", "no-horizontal-overflow" | "no-clipped-text" => "overflow", "bounds" if reason.starts_with("position") => "layout-position", "bounds" | "dimensions" | "visible" => "layout-size", diff --git a/crates/devup-mcp/src/server/screen_plan/actions.rs b/crates/devup-mcp/src/server/screen_plan/actions.rs index aa39e03c..2bba9c7b 100644 --- a/crates/devup-mcp/src/server/screen_plan/actions.rs +++ b/crates/devup-mcp/src/server/screen_plan/actions.rs @@ -1,5 +1,3 @@ - - /// `confirm`: every question that applies answered, in one call, or nothing /// changes. pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { @@ -280,6 +278,14 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< { row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); } + let node_routes = routes + .iter() + .flat_map(|route| { + array(&route["nodeIds"]) + .iter() + .map(move |node| (text(node).to_owned(), route["url"].clone())) + }) + .collect::>(); if let Some(rows) = plan["screens"].as_array_mut() { for screen in rows { let id = text(&screen["id"]).to_owned(); @@ -290,7 +296,30 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< link["resolved"] = json!({"url": action["link"], "evidence": "confirmed answer"}); } else if let Some(target) = link["resolved"]["screen"].as_str() { - link["resolved"]["url"] = json!(screen_routes.get(target)); + link["resolved"]["url"] = + if let Some(node) = link["resolved"]["nodeId"].as_str() { + node_routes.get(node).cloned().unwrap_or(Value::Null) + } else { + let urls = routes + .iter() + .filter(|route| { + screens + .iter() + .find(|screen| screen["id"] == target) + .is_some_and(|screen| { + array(&screen["frames"]).iter().any(|frame| { + array(&route["nodeIds"]).contains(&frame["nodeId"]) + }) + }) + }) + .filter_map(|route| route["url"].as_str()) + .collect::>(); + if urls.len() == 1 { + json!(urls.first()) + } else { + Value::Null + } + }; } resolved.push(link); } @@ -329,6 +358,7 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< plan["designFixes"] = json!(design_fixes); plan["status"] = json!("confirmed"); plan["confirmedAt"] = json!(now()); + refresh_inventory_checks(&mut plan); if !plan["assumptions"].is_array() { plan["assumptions"] = json!([]); } @@ -338,6 +368,8 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result< "planId": plan["planId"], "routes": routes, "markdownTable": route_markdown(&routes), + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], "excluded": excluded, "designFixes": design_fixes, "site": plan["site"], @@ -527,7 +559,7 @@ pub(super) async fn audit_plan( for written in array(&result["plans"]) { entries.retain(|entry| entry["url"] != written["url"]); entries.push(json!({"url": written["url"], "screen": written["screen"], - "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); + "missingReferences": written["missingReferences"], "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); } // A dynamic route sampled from its Figma frame is an assumption, and is // reported as one. @@ -704,6 +736,33 @@ fn markdown(plan: &Value) -> String { cell(&screen["openDecisions"]), )); } + if plan["frameInventory"].is_object() { + out.push_str( + "\n## Frame inventory\n\n| Frame | nodeId | disposition | route |\n|---|---|---|---|\n", + ); + for frame in array(&plan["frameInventory"]["frames"]) { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + cell(&frame["name"]), + cell(&frame["nodeId"]), + cell(&frame["disposition"]), + cell(&frame["route"]) + )); + } + out.push_str(&format!("\n{}\n", text(&plan["frameInventory"]["rule"]))); + out.push_str( + "\n## Link labels\n\n| On frame | Label | state | resolved |\n|---|---|---|---|\n", + ); + for label in array(&plan["linkLabelCheck"]["labels"]) { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + cell(&label["on"]), + cell(&label["label"]), + cell(&label["state"]), + cell(&label["resolved"]) + )); + } + } let questions = array(&plan["questions"]); if !questions.is_empty() { out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); @@ -853,4 +912,4 @@ pub(super) async fn status( report["inconclusiveComparisons"] = round["inconclusiveComparisons"].clone(); } Ok(report) -} \ No newline at end of file +} diff --git a/crates/devup-mcp/src/server/screen_plan/draft.rs b/crates/devup-mcp/src/server/screen_plan/draft.rs index dc87990c..dd47df4b 100644 --- a/crates/devup-mcp/src/server/screen_plan/draft.rs +++ b/crates/devup-mcp/src/server/screen_plan/draft.rs @@ -498,6 +498,8 @@ pub(super) async fn draft_with( "heuristics": plan["heuristics"], "excluded": plan["excluded"], "outOfScope": plan["outOfScope"], + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], "config": plan["config"], "unverifiedRoutes": unverified, "inventoryNote": plan["inventoryNote"], diff --git a/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs b/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs index b61329d0..79cba36e 100644 --- a/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs +++ b/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs @@ -9,6 +9,23 @@ pub(super) fn draft_plan_with( source: Value, notes: Option<&str>, ) -> Result { + if ["projection", "candidates", "atCeiling"] + .iter() + .any(|field| source["truncation"][field] == true) + { + return Err(invalid( + "The frame inventory is incomplete: exploration was truncated or reached its ceiling. Explore a smaller Section or supply the complete frame list before drafting.", + )); + } + let mut ids = BTreeSet::new(); + if screens + .iter() + .any(|screen| screen.node_id.trim().is_empty() || !ids.insert(&screen.node_id)) + { + return Err(invalid( + "Frame inventory requires a non-empty, unique nodeId for every frame.", + )); + } if screens.is_empty() { return Err(invalid( "draft found no screens. Link a Section or frame, or pass screens from devup_figma_explore.", @@ -520,12 +537,15 @@ pub(super) fn draft_plan_with( { *asked = with_when(asked.clone(), &base_when); } - let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes}); + let inventory = frame_inventory(screens, &out_of_scope, &excluded_rows, &kept); + let labels = link_label_check(screens, &out_of_scope, &excluded_rows, &kept); + let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes, + "frameInventory": inventory, "linkLabelCheck": labels}); let plan_id = format!( "sp-{}", &sha256(&serde_json::to_vec(&body).unwrap_or_default())[..16] ); - Ok(json!({ + let mut plan = json!({ "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": plan_id, @@ -539,6 +559,8 @@ pub(super) fn draft_plan_with( "heuristics": heuristics, "excluded": excluded_rows, "outOfScope": out_of_scope, + "frameInventory": body["frameInventory"], + "linkLabelCheck": body["linkLabelCheck"], "site": site, "defaults": defaults, "apps": evidence.apps.iter().map(super::framework::App::json).collect::>(), @@ -548,5 +570,216 @@ pub(super) fn draft_plan_with( "answers": {}, "routes": [], "assumptions": [], - })) + }); + refresh_inventory_checks(&mut plan); + Ok(plan) +} + +/// What became of one listed frame: planned, excluded, outOfScope or +/// siteAsset (a logo or share image taken out of the page list). +fn disposition_of( + node_id: &str, + out_of_scope: &[Value], + excluded_rows: &[Value], + kept: &[PlanScreenInput], +) -> &'static str { + let listed = |rows: &[Value]| rows.iter().any(|row| text(&row["nodeId"]) == node_id); + if listed(out_of_scope) { + "outOfScope" + } else if listed(excluded_rows) { + "excluded" + } else if kept.iter().any(|screen| screen.node_id == node_id) { + "planned" + } else { + "siteAsset" + } +} + +/// The rule every inventory and label check carries, so an agent reading +/// either one alone still meets it. +const ABSENCE_RULE: &str = "Say a screen does not exist only after searching frameInventory and running devup_figma_search for its name. A name missing from this list was not seen, which is not the same as absent: report it as unverified. outOfScope frames exist in the file but sit outside the linked node."; + +/// Every frame the explore listed and what became of it, so "there is no +/// screen for X" is answered from a list that stays in the plan file and in +/// `status`, never from memory of an earlier call. +fn frame_inventory( + screens: &[PlanScreenInput], + out_of_scope: &[Value], + excluded_rows: &[Value], + kept: &[PlanScreenInput], +) -> Value { + let frames = screens + .iter() + .map(|screen| { + json!({"nodeId": screen.node_id, "name": screen.name, "width": screen.width, + "section": screen.section_name.clone().or_else(|| screen.section.clone()), + "disposition": disposition_of(&screen.node_id, out_of_scope, excluded_rows, kept)}) + }) + .collect::>(); + json!({"total": frames.len(), "frames": frames, "rule": ABSENCE_RULE}) +} + +/// Whether a link label names a frame: the same words with widths and +/// device words dropped, or the same letters once spacing is removed. +fn label_names_frame(label: &str, frame: &str) -> bool { + let label_compact = compact(label); + if label_compact.is_empty() { + return false; + } + let label_words = base_words(label); + let frame_words = base_words(frame); + (!label_words.is_empty() && label_words == frame_words) + || compact(&frame_words.join("")) == compact(&label_words.join("")) + || compact(frame) == label_compact +} + +/// Link and menu labels with no URL. A tab "Core technology" with no +/// destination hints that a frame for it exists somewhere in the file, so +/// each label is matched against every frame the explore listed, in scope or +/// not. An unresolved label is never pointed at `/`. +fn link_label_check( + screens: &[PlanScreenInput], + out_of_scope: &[Value], + excluded_rows: &[Value], + kept: &[PlanScreenInput], +) -> Value { + let mut rows = Vec::new(); + for screen in screens { + for link in screen.links.iter().filter(|link| link.url.is_none()) { + if compact(&link.label).is_empty() { + continue; + } + let matches = screens + .iter() + .filter(|other| { + match link.destination_node_id.as_deref() { + Some(destination) => destination == other.node_id, + None => label_names_frame(&link.label, &other.name), + } + }) + .map(|other| { + json!({"nodeId": other.node_id, "name": other.name, + "disposition": disposition_of(&other.node_id, out_of_scope, excluded_rows, kept)}) + }) + .collect::>(); + let state = if !matches.is_empty() { + "frame-found" + } else if link.destination_node_id.is_some() { + "destination-not-listed" + } else { + "unverified" + }; + let next = match state { + "frame-found" => { + "Plan the matched frame, or bind the label to its route with navigationTargets." + } + "destination-not-listed" => { + "The prototype names a destination the explore did not list: export or search that node before deciding anything about it." + } + _ => { + "Run devup_figma_search for this label before saying no screen exists; until then report it as unverified and leave the link unresolved." + } + }; + rows.push(json!({"on": screen.node_id, "onName": screen.name, + "label": link.label, "destinationNodeId": link.destination_node_id, + "state": state, "frames": matches, "next": next})); + } + } + let unresolved = rows + .iter() + .filter(|row| row["state"] != "route-bound") + .count(); + json!({"labels": rows, "unresolved": unresolved, + "rule": "A link or menu label with no URL remains unresolved until it is bound to a route or explicitly left unlinked. Search for a matching frame with devup_figma_search before claiming it is absent. Not listed means unverified, not absent. Never point an unresolved label at / or any other guessed route."}) +} + +/// Reconcile draft diagnostics with confirmed placements and actual link URLs. +fn refresh_inventory_checks(plan: &mut Value) { + if !plan["frameInventory"].is_object() { + return; + } + let confirmed = plan["status"] == "confirmed"; + let screens = array(&plan["screens"]).to_vec(); + let routes = array(&plan["routes"]).to_vec(); + let excluded = array(&plan["excluded"]).to_vec(); + let fixes = array(&plan["designFixes"]).to_vec(); + if confirmed && let Some(frames) = plan["frameInventory"]["frames"].as_array_mut() { + for frame in frames { + let node = frame["nodeId"].clone(); + if frame["disposition"] == "outOfScope" || frame["disposition"] == "siteAsset" { + continue; + } + let placement = routes + .iter() + .find(|route| array(&route["nodeIds"]).contains(&node)); + frame["disposition"] = json!(if let Some(route) = placement { + text(&route["implementation"]) + } else if excluded.iter().any(|row| row["nodeId"] == node) { + "excluded" + } else if fixes.iter().any(|fix| array(&fix["frames"]) + .iter() + .any(|row| row["nodeId"] == node)) + { + "designFix" + } else { + "notBuilt" + }); + frame["route"] = placement.map_or(Value::Null, |route| route["url"].clone()); + } + } + let inventory = array(&plan["frameInventory"]["frames"]).to_vec(); + if let Some(labels) = plan["linkLabelCheck"]["labels"].as_array_mut() { + for label in labels.iter_mut() { + let mut matches = array(&label["frames"]).to_vec(); + for frame in &mut matches { + if let Some(current) = inventory + .iter() + .find(|row| row["nodeId"] == frame["nodeId"]) + { + frame["disposition"] = current["disposition"].clone(); + frame["route"] = current["route"].clone(); + } + } + label["frames"] = json!(matches); + label["state"] = json!(if !array(&label["frames"]).is_empty() { + "frame-found" + } else if label["destinationNodeId"].is_string() { + "destination-not-listed" + } else { + "unverified" + }); + label["next"] = json!( + "Bind the label to a verified route or leave it unlinked. Run devup_figma_search before claiming that no destination frame exists." + ); + let link = screens + .iter() + .find(|screen| { + array(&screen["frames"]) + .iter() + .any(|frame| frame["nodeId"] == label["on"]) + }) + .and_then(|screen| { + array(&screen["links"]).iter().find(|link| { + link["label"] == label["label"] + && link["destinationNodeId"] == label["destinationNodeId"] + }) + }); + label["resolved"] = link.map_or(Value::Null, |link| link["resolved"].clone()); + if confirmed + && link.is_some_and(|link| link["resolved"]["evidence"] == "confirmed answer") + && (label["resolved"]["url"].is_null() || label["resolved"]["url"] == "remove") + { + label["state"] = json!("left-unlinked"); + label["next"] = json!("Keep this label unlinked as confirmed."); + } else if label["resolved"]["url"].is_string() { + label["state"] = json!("route-bound"); + label["next"] = json!("Use the resolved URL for this label."); + } + } + let unresolved = labels + .iter() + .filter(|label| label["state"] != "route-bound" && label["state"] != "left-unlinked") + .count(); + plan["linkLabelCheck"]["unresolved"] = json!(unresolved); + } } diff --git a/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs index 93838988..b6d26303 100644 --- a/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs +++ b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs @@ -536,20 +536,41 @@ fn plan_route_group(context: RouteGroupPlanning<'_, '_>, index: usize, screen: & let mut links = Vec::new(); for (link_index, link) in screen.links.iter().enumerate() { let resolved = link - .url - .as_deref() - .map(|url| json!({"url": url, "evidence": "figma-url"})) - .or_else(|| { - let destination = link.destination_node_id.as_deref()?; - targets - .get(destination) - .map(|url| json!({"url": url, "evidence": format!("navigationTargets[{destination}]")})) - .or_else(|| { - frame_owner.get(destination).map(|owner| { - json!({"screen": drafts[*owner].id(), "evidence": "destination frame in this plan"}) + .url + .as_deref() + .map(|url| json!({"url": url, "evidence": "figma-url"})) + .or_else(|| { + let inferred = if link.destination_node_id.is_none() { + let owners = drafts + .iter() + .enumerate() + .filter(|(_, draft)| { + screen_apps.get(draft.id()).map(|app| &app.dir) + == screen_apps.get(screen.id()).map(|app| &app.dir) + && draft.frames.iter().any(|frame| { + label_names_frame(&link.label, &frame.name) + }) + }) + .map(|(index, _)| index) + .collect::>(); + (owners.len() == 1).then(|| owners[0]) + } else { + None + }; + let destination = link + .destination_node_id + .as_deref() + .or_else(|| inferred.map(|owner| drafts[owner].id()))?; + frame_owner.get(destination).map(|owner| { + json!({"screen": drafts[*owner].id(), + "nodeId": link.destination_node_id, + "evidence": if inferred.is_some() { "label matches a frame in this plan" } else { "destination frame in this plan" }}) + }).or_else(|| { + targets.get(destination).map(|url| { + json!({"url": url, "evidence": format!("navigationTargets[{destination}]")}) }) }) - }); + }); if resolved.is_none() { decisions.push("unresolved-link"); let mut choices = vec![choice( diff --git a/crates/devup-mcp/src/server/screen_plan/status.rs b/crates/devup-mcp/src/server/screen_plan/status.rs index 1825ec64..44470e17 100644 --- a/crates/devup-mcp/src/server/screen_plan/status.rs +++ b/crates/devup-mcp/src/server/screen_plan/status.rs @@ -1,5 +1,25 @@ +/// A page file on disk is only written. It is implemented once the browser +/// rendered it complete (rendered-completeness passed) at every width its +/// frame was drawn at. +fn implemented_state(page_exists: bool, drawn: &Value, per_width: &[Value]) -> &'static str { + if !page_exists { + return "no"; + } + let drawn = array(drawn) + .iter() + .filter_map(Value::as_u64) + .collect::>(); + let complete = !drawn.is_empty() + && drawn.iter().all(|width| { + per_width.iter().any(|row| { + row["width"] == *width && row["result"] == "pass" && row["columns"]["completeness"] == "pass" + }) + }); + if complete { "yes" } else { "written-unverified" } +} + /// The pure half of `status`, so the not-run rule is tested on its own. pub(super) fn status_report( plan: &Value, @@ -33,19 +53,10 @@ pub(super) fn status_report( .iter() .find(|(pattern, _)| pattern == url || super::navigation::matches_route(pattern, url)) .map(|(_, page)| page.clone()); - let mut row_widths = if audited { - widths.clone() - } else { - DEFAULT_WIDTHS - .iter() - .map(|width| u64::from(*width)) - .chain( - array(&route["drawnWidths"]) - .iter() - .filter_map(Value::as_u64), - ) - .collect::>() - }; + let mut row_widths = DEFAULT_WIDTHS.iter().map(|width| u64::from(*width)) + .chain(widths.iter().copied()) + .chain(array(&route["drawnWidths"]).iter().filter_map(Value::as_u64)) + .collect::>(); row_widths.sort_unstable_by(|a, b| b.cmp(a)); row_widths.dedup(); let per_width = row_widths @@ -65,14 +76,24 @@ pub(super) fn status_report( return json!({"width": width, "result": "not-run", "reasons": [if audited { "no runtime audit item for this URL at this width" } else { "no runtime audit report supplied" }]}); } - let result = matching + let mut result = matching .iter() .fold("pass", |acc, row| worst(acc, text(&row["status"]))); + let mut reasons = matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(); + let missing_reference = array(&plan["audit"]["plans"]).iter().filter(|entry| entry["url"] == url) + .flat_map(|entry| array(&entry["missingReferences"])) + .any(|reference| reference["width"].is_null() || reference["width"] == *width); + let pending_visual = array(&plan["audit"]["visualPending"]).iter() + .any(|comparison| comparison["routePath"] == url && comparison["viewport"]["width"] == *width); + if missing_reference || pending_visual { + if result == "pass" { result = "not-run"; } + reasons.push(json!("Figma page, section or asset visual comparison missing, blocked or not yet judged")); + } json!({ "width": width, "result": result, "columns": matching[0]["columns"], - "reasons": matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(), + "reasons": reasons, "screenshots": matching.iter().filter_map(|row| row["screenshot"].as_str()).collect::>(), }) }) @@ -83,7 +104,7 @@ pub(super) fn status_report( "figmaScreen": route["name"], "nodeIds": route["nodeIds"], "file": page.clone().map_or_else(|| route["file"].clone(), Value::from), - "implemented": if page.is_some() { "yes" } else { "no" }, + "implemented": implemented_state(page.is_some(), &route["drawnWidths"], &per_width), "widths": per_width, })); } @@ -137,10 +158,19 @@ pub(super) fn status_report( problems.push(format!("{} {kind} at {}", text(&row["url"]), at.join(", "))); } } + let written = rows + .iter() + .filter(|row| row["implemented"] == "written-unverified") + .count(); let mut summary = format!( "{} URLs: {implemented} implemented, {verified} verified at all widths", rows.len() ); + if written > 0 { + summary.push_str(&format!( + ", {written} written but not rendered complete at every drawn width" + )); + } for problem in &problems { summary.push_str(&format!(", {problem}")); } @@ -186,10 +216,12 @@ pub(super) fn status_report( .filter(|width| width["result"] == "fail") .map(|width| width["width"].to_string()) .collect::>(); - let state = if row["implemented"] != "yes" { + let state = if row["implemented"] == "no" { "not implemented".to_owned() } else if !failing.is_empty() { format!("fails at {}", failing.join(", ")) + } else if row["implemented"] == "written-unverified" { + "written, not rendered complete".to_owned() } else if passed.len() == row_widths.len() { "verified".to_owned() } else { @@ -294,8 +326,10 @@ pub(super) fn status_report( "defaults": plan["defaults"], "designFixes": design_fixes, "excluded": excluded, + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], "inventoryNote": evidence.inventory_note, - "rule": "A width without a runtime audit item is not-run, never pass. Implemented means an App Router page exists for the URL, not that it renders.", + "rule": "A width without a runtime audit item is not-run, never pass. Implemented requires rendered completeness at every drawn width; done requires every URL at every required width to pass.", }) } diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 0ed79910..d882b926 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -3,3 +3,4 @@ include!("screen_plan_tests/fixtures_and_cases.rs"); include!("screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs"); include!("screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs"); +include!("screen_plan_tests/frame_inventory_and_link_labels.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs index f98b4f10..70f7785b 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs @@ -570,7 +570,7 @@ async fn a_pending_decision_is_taken_at_the_end_and_a_destructive_one_is_left_un ); assert!( text_report - .contains("| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction |") + .contains("| URL | width | result | completeness | health | geometry | drift / converter bugs | page | section | asset | interaction |") ); assert!(text_report.contains("Dependencies: the update step has not been recorded.")); } diff --git a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs index 45bdbc1e..8a40d144 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs @@ -634,18 +634,42 @@ fn status_marks_widths_without_an_audit_item_not_run() { let report = status_report(&confirmed_plan(), &evidence(), Some(&audit)); let rows = array(&report["rows"]); assert_eq!(rows.len(), 2); - assert_eq!(rows[0]["implemented"], "yes"); + assert_eq!( + rows[0]["implemented"], "written-unverified", + "a page file without a passing rendered-completeness check is only written" + ); assert_eq!(rows[1]["implemented"], "no"); assert_eq!(rows[0]["widths"][0]["result"], "pass"); assert_eq!(rows[0]["widths"][1]["result"], "not-run"); - assert_eq!(rows[1]["widths"][1]["result"], "fail"); + assert_eq!(rows[1]["widths"][3]["result"], "fail"); assert!( text(&report["summary"]).starts_with( - "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" + "2 URLs: 0 implemented, 0 verified at all widths, 1 written but not rendered complete at every drawn width, /about overflows at 390" ), "{}", report["summary"] ); + assert!(text(&report["markdownTable"]).contains("written, not rendered complete")); + + let complete = json!({ + "verdict": "pass", + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + {"screen": "main", "routePath": "/", "width": 390, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + ], + }); + let rendered = status_report(&confirmed_plan(), &evidence(), Some(&complete)); + assert_eq!(rendered["rows"][0]["implemented"], "yes"); + let half = json!({ + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + {"screen": "main", "routePath": "/", "width": 390, "status": "fail", "reasons": ["rendered-completeness: the page is 300px tall"], "columns": {"completeness": "fail"}}, + ], + }); + let blank = status_report(&confirmed_plan(), &evidence(), Some(&half)); + assert_eq!(blank["rows"][0]["implemented"], "written-unverified"); assert_eq!(report["states"][0]["hostUrl"], "/"); assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); @@ -868,6 +892,41 @@ async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); assert_eq!(confirmed["routes"][0]["url"], "/home"); } +#[test] +fn a_partial_report_cannot_drop_required_widths_or_claim_done() { + let report = status_report(&confirmed_plan(), &evidence(), Some(&json!({ + "widthTable":[{"width":1440}], + "screenTable":[{"routePath":"/", "width":1440, "status":"pass", "columns":{"completeness":"pass"}}] + }))); + let widths = array(&report["rows"][0]["widths"]); + assert_eq!(widths.iter().map(|row| row["width"].as_u64().unwrap()).collect::>(), [1440,1100,858,390]); + assert!(widths[1..].iter().all(|row| row["result"] == "not-run")); + assert!(text(&report["finalReport"]).contains("not-run")); + assert_eq!(report["workQueue"][0]["done"], false); +} + +#[test] +fn site_structure_and_url_questions_are_all_in_the_initial_rounds() { + let mut project = evidence(); + project.config = PlanConfig::default(); + let plan = draft_plan(&main_widths(), &BTreeMap::new(), &project, json!({}), None).unwrap(); + let ids = question_ids(&plan); + assert!(ids.iter().any(|id| id.starts_with("site:"))); + assert!(ids.iter().any(|id| id.starts_with("group:"))); + assert!(ids.iter().any(|id| id.starts_with("split-route:"))); + assert_rounds_cover(&plan); +} + +#[test] +fn visual_comparisons_and_missing_asset_references_block_a_passing_cell() { + let mut plan = confirmed_plan(); + plan["audit"] = json!({"visualPending":[{"routePath":"/", "viewport":{"width":1440}}]}); + let audit = json!({"widthTable":[{"width":1440}],"screenTable":[{"routePath":"/", "width":1440,"status":"pass","columns":{"completeness":"pass"}}]}); + assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); + plan["audit"] = json!({"plans":[{"url":"/", "missingReferences":[{"width":1440,"kind":"nativeRender"}]}]}); + assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); +} + #[tokio::test] async fn accepting_the_proposal_confirms_with_no_answers() { @@ -925,4 +984,4 @@ async fn draft_returns_one_proposal_row_per_question() { text(&drafted["proposalMarkdown"]).starts_with("| # | topic | decision | recommended") ); assert_eq!(drafted["nextAction"]["arguments"]["acceptRecommended"], true); -} \ No newline at end of file +} diff --git a/crates/devup-mcp/src/server/screen_plan_tests/frame_inventory_and_link_labels.rs b/crates/devup-mcp/src/server/screen_plan_tests/frame_inventory_and_link_labels.rs new file mode 100644 index 00000000..22ab6ec8 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests/frame_inventory_and_link_labels.rs @@ -0,0 +1,281 @@ +/// Every listed frame stays in the plan with what became of it, and a menu +/// label with no URL is matched against those frames instead of being +/// pointed at `/`. +#[test] +fn frame_inventory_lists_every_frame_and_link_labels_are_matched() { + let mut home = screen("40:1", "Home", 1440.0); + home.links = vec![ + PlanLinkInput { + label: "Core technology".into(), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Pricing".into(), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Company".into(), + url: Some("/company".into()), + ..PlanLinkInput::default() + }, + ]; + let plan = draft_plan_with( + &[home, screen("40:2", "Core Technology - 1440", 1440.0)], + &[], + &[], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + + let inventory = &plan["frameInventory"]; + assert_eq!(inventory["total"], 2); + assert!( + array(&inventory["frames"]) + .iter() + .all(|frame| frame["disposition"] == "planned"), + "{inventory}" + ); + assert!(text(&inventory["rule"]).contains("unverified")); + + let check = &plan["linkLabelCheck"]; + let labels = array(&check["labels"]); + assert_eq!(labels.len(), 2, "a link with a URL is not checked: {check}"); + let core = labels + .iter() + .find(|row| row["label"] == "Core technology") + .unwrap(); + assert_eq!(core["state"], "frame-found"); + assert_eq!(core["frames"][0]["nodeId"], "40:2"); + let pricing = labels.iter().find(|row| row["label"] == "Pricing").unwrap(); + assert_eq!(pricing["state"], "unverified"); + assert!(text(&pricing["next"]).contains("devup_figma_search")); + assert_eq!( + check["unresolved"], 2, + "a found frame still needs a confirmed route" + ); + assert_eq!(plan["screens"][0]["links"][0]["resolved"]["screen"], "40:2"); +} + +#[test] +fn frame_inventory_retains_excluded_assets_and_out_of_scope_frames() { + let screens = vec![ + sectioned("41:1", "Home", 1440.0, "Site"), + PlanScreenInput { + visible: Some(false), + ..sectioned("41:2", "Hidden", 1440.0, "Site") + }, + sectioned("41:3", "Logo", 300.0, "Site"), + sectioned("41:4", "Other", 1440.0, "Elsewhere"), + ]; + let plan = draft_plan_with( + &screens, + &[], + &[], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Site"}), + None, + ) + .unwrap(); + let frames = array(&plan["frameInventory"]["frames"]); + assert_eq!(frames.len(), 4); + assert_eq!( + frames + .iter() + .map(|frame| text(&frame["disposition"])) + .collect::>(), + ["planned", "excluded", "siteAsset", "outOfScope"] + ); + assert_eq!( + status_report(&plan, &evidence(), None)["frameInventory"], + plan["frameInventory"] + ); + let md = markdown(&plan); + for screen in screens { + assert!(md.contains(&screen.node_id)); + } +} + +#[test] +fn incomplete_or_duplicate_frame_inventories_are_refused() { + for field in ["projection", "candidates", "atCeiling"] { + let mut source = json!({"truncation": {}}); + source["truncation"][field] = json!(true); + assert!( + draft_plan( + &[screen("42:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence(), + source, + None + ) + .unwrap_err() + .message + .contains("incomplete") + ); + } + assert!( + draft_plan( + &[ + screen("42:1", "Home", 1440.0), + screen("42:1", "Other", 390.0) + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None + ) + .is_err() + ); +} + +#[test] +fn explicit_destinations_override_labels_and_ambiguous_labels_stay_unresolved() { + let mut home = screen("43:1", "Home", 1440.0); + home.links = vec![ + PlanLinkInput { + label: "Pricing".into(), + destination_node_id: Some("missing".into()), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Pricing".into(), + ..PlanLinkInput::default() + }, + ]; + let plan = draft_plan( + &[ + home, + sectioned("43:2", "Pricing", 1440.0, "A"), + sectioned("43:3", "Pricing", 390.0, "B"), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + assert_eq!( + plan["linkLabelCheck"]["labels"][0]["state"], + "destination-not-listed" + ); + assert!(array(&plan["linkLabelCheck"]["labels"][0]["frames"]).is_empty()); + assert!(plan["screens"][0]["links"][0]["resolved"].is_null()); + for link in array(&plan["screens"][0]["links"]) { + assert!(link["resolved"]["url"].is_null()); + } + assert_eq!(plan["linkLabelCheck"]["unresolved"], 2); +} + +#[tokio::test] +async fn link_labels_follow_confirmed_routes_and_inventory_survives_status() { + let fixture = Fixture::new(); + let mut home = screen("44:1", "Home", 1440.0); + home.links = vec![PlanLinkInput { + label: "Core technology".into(), + ..PlanLinkInput::default() + }]; + let mut draft_input = input(&fixture, "draft"); + draft_input.navigation_targets = BTreeMap::from([("44:1".into(), "/".into())]); + let result = draft_with( + &draft_input, + &[home, screen("44:2", "Core Technology - 1440", 1440.0)], + &[], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + assert_eq!(result["frameInventory"]["total"], 2); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(text(&plan["planId"]).into()); + confirm_input.answers = array(&plan["questions"]) + .iter() + .map(|q| { + ( + text(&q["id"]).into(), + AnswerInput::One(first_label(q).into()), + ) + }) + .collect(); + confirm_input + .answers + .insert("route:44:2".into(), AnswerInput::One("/technology".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let check = &confirmed["linkLabelCheck"]; + assert_eq!(check["labels"][0]["state"], "route-bound"); + assert_eq!(check["labels"][0]["resolved"]["url"], "/technology"); + assert_eq!(check["unresolved"], 0); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(plan["frameInventory"]["frames"][1]["route"], "/technology"); + assert_eq!( + status_report(&plan, &evidence(), None)["linkLabelCheck"], + *check + ); +} + +#[tokio::test] +async fn prototype_links_follow_the_selected_frame_when_a_group_is_split() { + let fixture = Fixture::new(); + let mut home = screen("45:1", "Landing", 1440.0); + home.links = vec![ + PlanLinkInput { + label: "Pricing".into(), + destination_node_id: Some("45:3".into()), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Pricing".into(), + ..PlanLinkInput::default() + }, + PlanLinkInput { + label: "Unknown".into(), + ..PlanLinkInput::default() + }, + ]; + let draft_input = input(&fixture, "draft"); + draft_with( + &draft_input, + &[ + home, + screen("45:2", "Pricing", 1440.0), + screen("45:3", "Pricing", 390.0), + ], + &[], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(text(&plan["planId"]).into()); + confirm_input.answers = array(&plan["questions"]) + .iter() + .map(|q| { + ( + text(&q["id"]).into(), + AnswerInput::One(first_label(q).into()), + ) + }) + .collect(); + confirm_input.answers.insert( + "group:45:2".into(), + AnswerInput::One("/desktop, /mobile".into()), + ); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!( + confirmed["linkLabelCheck"]["labels"][0]["resolved"]["url"], + "/mobile" + ); + assert_eq!( + confirmed["linkLabelCheck"]["labels"][2]["state"], + "left-unlinked" + ); + assert!(confirmed["linkLabelCheck"]["labels"][1]["resolved"]["url"].is_null()); + assert_eq!(confirmed["linkLabelCheck"]["unresolved"], 1); +} diff --git a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md index cdc04d73..ee265919 100644 --- a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -382,6 +382,13 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. with the app running. If the runner cannot be installed, call status with `auditPlanPath` and `toolingUnavailable: ""`: that URL's audit is inconclusive and the rest of the work goes on. + This real-browser step is required before claiming any page is done: build + and serve the actual generated app, not a hand-written stand-in. Static + checks and TSX snapshots cannot detect missing background images, wrong + colors or assets that never load. Run `image-loaded` and `page-health`, read + the browser report, and retain screenshots with their viewport and reference + identities. A report's `completed` means execution finished; a capture check's + `pass` means only that pixels were captured. Neither is a visual verdict. 9. **Read the status, fix, re-run.** `devup_screen_plan` `action: "status"` with `auditPlanPath` and `auditReportPath`. Run every `visualComparisons` call (`devup_visual_compare`, each with its `threshold` and `diffPath`) @@ -425,10 +432,15 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. with `assume` (`about`: the decision id, `decision`: the recommended option). A destructive one (deleting, overwriting, reaching outside the project) stays unbuilt and is listed in the report. -11. **Done means zero fails.** A URL is done when every check and comparison - passes at its drawn widths and health and overflow pass at every width - - or when each remaining finding is a converter bug with its repro filed, or - listed as unresolved with its evidence. Before reporting done, also: +11. **Done means zero fails.** A URL is done only after its actual Chromium + screenshots have been compared with independently exported Figma originals, + every check and comparison passes at its drawn widths, and health and + overflow pass at every width. Inspect the page/section/asset diffs for + background images, colors and loaded assets; feed every result back to + status. Missing references, unavailable browser tooling, not-run comparisons, + converter bugs and unresolved findings remain incomplete with evidence. + Preserve screenshots, reports, Figma reference identities and diffs. + Before reporting done, also: - every confirmed URL is implemented; - the build passes: `bun run build` (`vinext build` for every app); @@ -457,7 +469,8 @@ Hard rules, after confirm: - Visually verify every screen, section and asset at every drawn width. - Never stop to wait; reorder the work. -- Done = zero fails, or listed as unresolved with evidence. +- Done = a real browser render and zero failed or missing comparisons; + unresolved findings are reported as incomplete with evidence. - Layout is exact; a mismatch is drift or a converter bug, never tuned. ## What the questions mean diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index a0995720..59064136 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -182,9 +182,18 @@ the full rules; these are the steps. 11. **Never stop to wait.** A decision mid-run: `action: "defer"` with the recommended option, keep working, take it with `assume` at the end; a destructive one stays unbuilt. -12. **Done = zero fails.** Every check and comparison passes at the drawn - widths and health and overflow pass at every width, or each remaining - finding is a filed converter bug or unresolved with evidence. Also: every +12. **Done = zero fails.** A real Chromium render is mandatory before claiming + any page is done. Build and serve the generated app, execute the Playwright + runner, read its report and compare the actual screenshots with independent + Figma references. Static checks, TSX snapshots, a generated audit plan and a + capture check's `pass` do not prove visual correctness. Verify background + images, colors and loaded assets; inspect page/section/asset diffs and feed + every visual result back to status. Missing references, unavailable browser + tooling, failed assets and not-run comparisons mean incomplete. Preserve the + screenshots, reference identity, report and diffs in the final evidence. + Every check and comparison passes at the drawn + widths and health and overflow pass at every required width. Report filed + converter bugs and unresolved findings with evidence as incomplete. Also: every URL implemented, the production build free of `data-figma-node-id` (the strip plugin in vite.config; `grep -rl data-figma-node-id dist` finds nothing), `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` @@ -196,6 +205,8 @@ the full rules; these are the steps. assumptions, design fixes, exclusions, defaults and the dependencies line. Hard rules: visually verify every screen, section and asset at every drawn -width; never stop to wait, reorder the work; done = zero fails, or listed as -unresolved with evidence; layout is exact, a mismatch is drift or a +width; never stop to wait, reorder the work; done = every required cell passes; +report unresolved cells with evidence as incomplete; layout is exact, a mismatch is drift or a converter bug. + +Run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read its report, fix and rerun until every cell passes. Missing cells are not-run and block done; require rendered completeness and page/section/asset comparisons at drawn widths. Ask all site, structure and URL questionRounds back to back up front before implementation; never ask a later round during the build. diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index dbe2dfab..3272bdc3 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -71,8 +71,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 35488, - "sha256": "9b41c4e4cd15e00509df6d3348efdf5f9d8f756c9185b38679cfbc9e87716793" + "bytes": 36456, + "sha256": "9813b017ffc0c79084fc5f62fc5e36f005e28d744cb546699744769b911d8c50" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", @@ -89,8 +89,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 13647, - "sha256": "ac9bb6b339019249911bbcbcb956809dddf8daabbc08c8f7dfa5a64dc310dbb7" + "bytes": 14805, + "sha256": "277854f8d5d7cfb282415d4af23c9038a4f29efa32d48f218847977ae5f09052" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", diff --git a/crates/devup-mcp/src/server/text/devup-figma-export.md b/crates/devup-mcp/src/server/text/devup-figma-export.md index d82f87eb..af083b20 100644 --- a/crates/devup-mcp/src/server/text/devup-figma-export.md +++ b/crates/devup-mcp/src/server/text/devup-figma-export.md @@ -1 +1,3 @@ -Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name="Cards"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} \ No newline at end of file +Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name="Cards"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} +An asset request targeting a frame by nodeId defaults to the original IMAGE fill when one is available. This keeps hero headings and buttons live instead of baking them into the background image. Explicit renderMode node retains whole-node capture; its renderInfo warns when child text would be baked into the asset. +Exports report captured sections/assets with no generated element as DEVUP_CODEGEN_SECTION_COVERAGE projection issues, including without includeDiagnostics. With projectRoot and Figma node identities enabled, each TSX outputResults entry also returns sectionCoverage: pass this original contract to devup_ui_validate to detect deleted sections and asset references. Hidden subtrees, flattened asset internals and component-reference internals are excluded. sectionCoverage is null when identity attributes are disabled or no projectRoot is supplied; static coverage never proves runtime visibility. diff --git a/crates/devup-mcp/src/server/text/devup-runtime-audit.md b/crates/devup-mcp/src/server/text/devup-runtime-audit.md index 06e69460..ea38d1d2 100644 --- a/crates/devup-mcp/src/server/text/devup-runtime-audit.md +++ b/crates/devup-mcp/src/server/text/devup-runtime-audit.md @@ -1 +1,4 @@ -Plan, then verify, a browser run of generated screens at several viewport widths that the consumer project executes (action: plan | ingest). This server never launches a browser or runs a command. Optional templatePath transactionally writes the fixed Playwright runner and package files alongside outputPath. Dimension checks bind explicit Figma artifactId/nodeId references to CSS logical DOM sizes at viewports (default 1440/1100/858/390); width tables disclose missing references and report tolerance exceedances only, never multiplying zoom or DPR. plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible, hidden, and bounds - size and position against the Figma frame, exact up to 0.5px of rounding) plus page-health, no-clipped-text, interaction, capture and view-transition (follow a link and confirm a view transition ran) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. The consumer runs harness/runtime-audit against it in fresh isolated browser contexts. ingest reads the plan and the report.json it wrote, rejects a report not bound to that plan by id and file hash, recomputes every count from the items, verifies each screenshot's SHA-256, length and decoded width, and separates what it verified from what the executor only declared. A missing item is not-run, never a pass; a viewport with an environment error makes the verdict inconclusive. A visual original exists only at the widths a screen's Figma frame was drawn at. \ No newline at end of file +Plan, then verify, a browser run of generated screens at several viewport widths that the consumer project executes (action: plan | ingest). This server never launches a browser or runs a command. Optional templatePath transactionally writes the fixed Playwright runner and package files alongside outputPath. Dimension checks bind explicit Figma artifactId/nodeId references to CSS logical DOM sizes at viewports (default 1440/1100/858/390); width tables disclose missing references and report tolerance exceedances only, never multiplying zoom or DPR. plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible, hidden, and bounds - size and position against the Figma frame, exact up to 0.5px of rounding) plus page-health, no-clipped-text, interaction, capture and view-transition (follow a link and confirm a view transition ran) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. The consumer runs harness/runtime-audit against it in fresh isolated browser contexts. ingest reads the plan and the report.json it wrote, rejects a report not bound to that plan by id and file hash, recomputes every count from the items, verifies each screenshot's SHA-256, length and decoded width, and separates what it verified from what the executor only declared. A missing item is not-run, never a pass; a viewport with an environment error makes the verdict inconclusive. A visual original exists only at the widths a screen's Figma frame was drawn at. +Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. + +Rendered completeness requires the frame height (default minimum 90%), listed sections and images, visible text contrast (default minimum 1.5:1), and decoded image resources. no-low-contrast-text checks computed text colours against composited ancestor backgrounds, including browser default control backgrounds. Image or unsupported colour backgrounds are inconclusive, never silently passed. image-loaded checks every img and CSS background URL (including pseudo-elements), requiring naturalWidth > 0; lazy images are loaded for the audit. Both checks accept an optional subject scope and run at every audit width in generated plans. native-appearance fails any visible button whose computed appearance is not none without an author-set background and border, or that paints the browser default grey. stale-resources fails any image, script, stylesheet, font or media response with an HTTP status of 400 or more, and any difference in stylesheet content, image URLs or first-section height from a fresh, cache-free load of the same URL; image-loaded also fails images with no painted box or a failed image request. diff --git a/crates/devup-mcp/src/server/text/devup-screen-plan.md b/crates/devup-mcp/src/server/text/devup-screen-plan.md index ad1bfba5..2dd48c41 100644 --- a/crates/devup-mcp/src/server/text/devup-screen-plan.md +++ b/crates/devup-mcp/src/server/text/devup-screen-plan.md @@ -1 +1,4 @@ -Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `proposal` (and `proposalMarkdown`): every open decision as one table row with the recommended answer and the alternatives. Do not interview the user one decision at a time: show the table and ask ONE question with your question tool - accept it, or name the rows to change - in the language the user first wrote in (translate for display only; answers stay keyed by question id with the original labels or custom text). confirm with `acceptRecommended: true` fills every unanswered applicable question with its recommended option, so send only the changed rows in `answers`; without it, confirm refuses and names the missing ids. `questions`/`questionRounds` remain for reviewing individual rows. A confirm that puts two screens on one URL writes nothing and returns `details.conflict` (both screens and the questions placing them) and `details.retryAnswers` with a distinct suggested URL to retry with. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. \ No newline at end of file +Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a "the Figma structure is wrong" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `proposal` (and `proposalMarkdown`): every open decision as one table row with the recommended answer and the alternatives. Do not interview the user one decision at a time: show the table and ask ONE question with your question tool - accept it, or name the rows to change - in the language the user first wrote in (translate for display only; answers stay keyed by question id with the original labels or custom text). confirm with `acceptRecommended: true` fills every unanswered applicable question with its recommended option, so send only the changed rows in `answers`; without it, confirm refuses and names the missing ids. `questions`/`questionRounds` remain for reviewing individual rows. A confirm that puts two screens on one URL writes nothing and returns `details.conflict` (both screens and the questions placing them) and `details.retryAnswers` with a distinct suggested URL to retry with. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it. +Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. +Ask all site, structure and URL questions from the draft questions/questionRounds together up front, back to back before implementing. Never ask another round during the build; record conservative assumptions after confirmation. +The saved plan, draft, confirm and status expose frameInventory (every listed frame, including excluded, out-of-scope and site assets) and linkLabelCheck. Incomplete exploration is refused. A unique link-label match follows its frame's confirmed route; explicit prototype destinations take priority and ambiguous labels stay unresolved. A found frame without a bound route is still unresolved. Search frameInventory and run devup_figma_search before claiming a screen does not exist; an unlisted name is unverified. Never bind an unresolved label to a guessed route. diff --git a/crates/devup-mcp/src/server/text/devup-ui-validate.md b/crates/devup-mcp/src/server/text/devup-ui-validate.md index 27715675..d777e4d2 100644 --- a/crates/devup-mcp/src/server/text/devup-ui-validate.md +++ b/crates/devup-mcp/src/server/text/devup-ui-validate.md @@ -1 +1 @@ -Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use ). `themeNotes` summarizes each encountered empty token category once. Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio. \ No newline at end of file +Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use ), and a root-relative asset URL (src or url(...)) with no file under any public directory is an error (`asset-missing`), always failing `ok`. `themeNotes` summarizes each encountered empty token category once. Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio. Supply sectionCoverage from outputResults.tsx.sectionCoverage (or frames[].outputResults.tsx.sectionCoverage) to report omitted sections, images, masks and backgrounds as section-coverage errors. This compares literal JSX node identity and asset references across the supplied sources; it does not prove runtime visibility or component use. Omitted sectionCoverage leaves design coverage unverified. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md index b509c631..a40d29f8 100644 --- a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md +++ b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md @@ -302,5 +302,8 @@ routes at concrete URLs, and production builds strip the ids unless overflow, layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its node, file and fix hint. Never stop to wait; reorder the work: a decision after confirm goes to -`defer` and is taken with `assume` at the end. Done = zero fails, or listed as -unresolved with evidence after `maxFixAttempts`. +`defer` and is taken with `assume` at the end. Done = every required URL x width cell passes. Report unresolved findings +with evidence after `maxFixAttempts` as incomplete. + +Before reporting done, run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read the report, fix failures and rerun until every cell passes. Missing cells are not-run and block completion. At drawn widths require rendered completeness and page, section and asset visual comparisons. Check sequence-order and section-layout at every width. +Ask all site, structure and URL questions from the draft questions/questionRounds together up front, back to back before implementing. Never ask another round during the build; record conservative assumptions after confirmation. diff --git a/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md b/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md index 501adb6a..c3089452 100644 --- a/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md +++ b/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md @@ -1 +1 @@ -A URL is done when every check and comparison passes at its drawn widths and health and overflow pass at every width, or each remaining finding is a converter bug with its repro filed, or unresolved with its evidence after maxFixAttempts fix-and-rerun rounds. Layout is exact: a geometry mismatch is code drift (restore the export) or a converter bug, never tuned by hand. \ No newline at end of file +A URL is done only after a runtime audit loads it at every required width (default 1440/1100/858/390), every cell passes, and every drawn-width completeness check and page/section/asset comparison passes. Missing cells are not-run and block done. Report converter bugs with repros and unresolved findings with evidence after the fix budget, but do not call failing or unverified cells done. Read the report, fix and rerun until every cell passes. Layout permits only half a pixel of rounding. diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index 6b94cb80..73bac285 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -351,6 +351,11 @@ pub struct ProjectContextInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct UiValidateInput { + /// Copy outputResults.tsx.sectionCoverage (or a Section frame's equivalent) + /// from devup_figma_export to check that captured sections/assets remain. + /// This is static JSX identity coverage, not proof of runtime visibility. + #[serde(default)] + pub section_coverage: Option, /// Actual acquisition policies keyed by assetId, including public assetPath /// and effectsBaked. Missing baking evidence remains unverified. #[serde(default)] @@ -405,6 +410,15 @@ pub struct UiValidateInput { pub reference_width: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SectionCoverageInput { + pub node_ids: Vec, + /// Asset references in the original generated output, including masks and backgrounds. + #[serde(default)] + pub asset_urls: Vec, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SourceMapInput { diff --git a/crates/devup-mcp/src/server/visual_compare.rs b/crates/devup-mcp/src/server/visual_compare.rs index f07fa072..0c557480 100644 --- a/crates/devup-mcp/src/server/visual_compare.rs +++ b/crates/devup-mcp/src/server/visual_compare.rs @@ -837,7 +837,16 @@ mod tests { let f = fixture(); let outside = fixture(); #[cfg(windows)] - std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")).unwrap(); + // Like output_policy's directory-symlink test, this requires the host + // to allow symlink creation. Windows reports missing privilege as 1314 + // (ErrorKind::Uncategorized on some toolchains), not PermissionDenied. + if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { + if error.raw_os_error() == Some(1314) { + eprintln!("symlink escape test unavailable: Windows symlink privilege is missing"); + return; + } + panic!("could not create test symlink: {error}"); + } #[cfg(unix)] std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); assert!( diff --git a/crates/devup-mcp/tests/bridge_first.rs b/crates/devup-mcp/tests/bridge_first.rs index 771f8627..aab97108 100644 --- a/crates/devup-mcp/tests/bridge_first.rs +++ b/crates/devup-mcp/tests/bridge_first.rs @@ -207,6 +207,9 @@ fn serve(mut socket: Socket) -> Arc>> { tokio::spawn(async move { while let Some(Ok(Message::Text(text))) = socket.next().await { let job: Value = serde_json::from_str(&text).expect("a job is JSON"); + if job["kind"] == "server-info" { + continue; + } let data = if job["script"] == "measureNodes" { Ok( json!({"kind":"devup-measure","schemaVersion":1,"coordinateSpace":"figma-logical", @@ -243,6 +246,9 @@ fn serve_with_context(mut socket: Socket, context: Option) -> Arc an "{}", result["figmaNodeIds"] ); + assert_eq!( + result["outputResults"]["tsx"]["sectionCoverage"]["nodeIds"], + json!(["3879:35481"]) + ); // What the export wrote for the node, kept to tell code drift from a // converter bug later. let record: Value = @@ -278,6 +282,7 @@ async fn an_export_into_a_project_marks_its_elements_with_the_figma_node() -> an .contains("data-figma-node-id") ); assert!(result.get("figmaNodeIds").is_none()); + assert!(result["outputResults"]["tsx"]["sectionCoverage"].is_null()); let result = export(json!({"url": URL, "outputs": ["tsx"]})).await?; assert!( diff --git a/crates/devup-mcp/tests/output_policy.rs b/crates/devup-mcp/tests/output_policy.rs index 346bcd22..00780c25 100644 --- a/crates/devup-mcp/tests/output_policy.rs +++ b/crates/devup-mcp/tests/output_policy.rs @@ -245,7 +245,9 @@ fn rejects_a_directory_symlink_parent_that_escapes_the_root() -> anyhow::Result< let root = unique_temp_dir("symlink-root")?; let outside = unique_temp_dir("symlink-outside")?; if let Err(error) = symlink_dir(&outside, root.join("linked")) { - if error.kind() == std::io::ErrorKind::PermissionDenied { + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(1314) + { fs::remove_dir_all(root)?; fs::remove_dir_all(outside)?; return Ok(()); diff --git a/crates/devup-mcp/tests/screen_plan_tool.rs b/crates/devup-mcp/tests/screen_plan_tool.rs index 945448ae..67e02c45 100644 --- a/crates/devup-mcp/tests/screen_plan_tool.rs +++ b/crates/devup-mcp/tests/screen_plan_tool.rs @@ -212,7 +212,7 @@ async fn draft_asks_everything_at_once_and_confirm_needs_every_answer() -> anyho assert!(!failed, "{status}"); assert_eq!( status["summary"], - "3 URLs: 2 implemented, 0 verified at all widths; no runtime audit report, so every width is not-run; site completeness: 5 missing" + "3 URLs: 0 implemented, 0 verified at all widths, 2 written but not rendered complete at every drawn width; no runtime audit report, so every width is not-run; site completeness: 5 missing" ); std::fs::remove_dir_all(root).ok(); Ok(()) diff --git a/crates/devup-mcp/tests/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index da1e1dde..cc11f7b8 100644 --- a/crates/devup-mcp/tests/ui_validate_response.rs +++ b/crates/devup-mcp/tests/ui_validate_response.rs @@ -11,3 +11,5 @@ // Source sections share this module to preserve privacy and test names. include!("ui_validate_response/fixtures_and_cases.rs"); include!("ui_validate_response/a_page_named_module_outside_the_router_is_not_judged_as_a_route.rs"); + +include!("ui_validate_response/section_coverage.rs"); diff --git a/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs b/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs new file mode 100644 index 00000000..a6d799d1 --- /dev/null +++ b/crates/devup-mcp/tests/ui_validate_response/section_coverage.rs @@ -0,0 +1,42 @@ +#[tokio::test] +async fn missing_sections_and_asset_files_fail_even_without_strict() -> anyhow::Result<()> { + let result = validate(json!({ + "tsx": "", + "projectRoot": fixture_project_root(), + "sectionCoverage": {"nodeIds":["section","picture"]} + })) + .await?; + assert_eq!(result["ok"], false); + assert_eq!(result["okReason"], "error-violations"); + let violations = result["violations"].as_array().unwrap(); + for rule in ["asset-missing", "section-coverage"] { + assert!( + violations + .iter() + .any(|v| v["rule"] == rule && v["severity"] == "error"), + "{result}" + ); + assert!( + result["verificationScope"]["static"]["rules"] + .as_array() + .unwrap() + .contains(&json!(rule)) + ); + } + let result = validate(json!({ + "files":[ + {"path":"Section.tsx","content":"export function Section() { return ; }"}, + {"path":"Picture.tsx","content":"export function Picture() { return ; }"} + ], + "sectionCoverage":{"nodeIds":["section","picture"]} + })).await?; + assert!( + !result["violations"] + .as_array() + .unwrap() + .iter() + .any(|v| v["rule"] == "section-coverage"), + "{result}" + ); + Ok(()) +} diff --git a/fixtures/devup-figma-plugin/manifest.json b/fixtures/devup-figma-plugin/manifest.json index 82874c11..7456ea0e 100644 --- a/fixtures/devup-figma-plugin/manifest.json +++ b/fixtures/devup-figma-plugin/manifest.json @@ -1631,7 +1631,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-123-179129a02c.snap", @@ -1639,7 +1639,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-124-bdedf14335.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-125-4a4a93c4c9.snap", @@ -1647,7 +1647,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-126-14073e1999.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-127-1d5ead088d.snap", @@ -1671,7 +1671,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap", - "sha256": "fa160e7ac15e5b3f8f1c441ff03960bff3743a51b320441bfe90b0c8c8d01e13" + "sha256": "d3752dc22fd731f727869c6b8f7bea9bdbf21d1f17b9c581a03d5d5eac6aa55a" }, { "path": "snapshots/codegen/upstream-codegen-133-f425fa006c.snap", @@ -1715,7 +1715,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-143-52b441f9f4.snap", - "sha256": "a3a2a629306c0b50c80cd4e3db7c9bd410e5d5c97dd2d87652a998a5b108403c" + "sha256": "8d1079d0572575cf6144f86be4442d2c503fc30221853125e552400a7388870d" }, { "path": "snapshots/codegen/upstream-codegen-144-042a6de403.snap", @@ -1799,7 +1799,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-164-33e4c6591c.snap", - "sha256": "eebaa0436df2c1bd77b33ae594c7e933874a87bd5167f438eb049ec22dfb35e6" + "sha256": "194f06f9eb7ed13b8f1393737b3a1a91805422d0b2658cf23b8d756134ec0e22" }, { "path": "snapshots/codegen/upstream-codegen-165-501ee1df12.snap", @@ -2087,31 +2087,31 @@ }, { "path": "snapshots/codegen/upstream-codegen-236-621c6d6a9e.snap", - "sha256": "3b2ec85d78cb9730bb9ce463b380c35426a800abe6993201a762f015817350ed" + "sha256": "09b73b6e9ce938be1e0f32b7ce651182cc5fd5cf2b5c1ca6a15692aaf4e1e1aa" }, { "path": "snapshots/codegen/upstream-codegen-237-01ee478666.snap", - "sha256": "fb11d0e3d3c5d7dc8b2c4e84f207e1284eb3902a1020bc8ba8e171954c262181" + "sha256": "6a0cf91ec198452b8696fb591d269cbb7af94842077ee95bbdaf85b222526ec6" }, { "path": "snapshots/codegen/upstream-codegen-238-1a8cf8c870.snap", - "sha256": "c027c04b4adc0cf327d8bdd60f0cfaf3529b60c85cd6955d07e919f1d4f8a1bb" + "sha256": "21ac84461a0cbacd84248dc4fc8c83633ce53f050f3d76b2557331a1c4d61c70" }, { "path": "snapshots/codegen/upstream-codegen-239-a6dada3682.snap", - "sha256": "9cf29f9bc192169ef5d677b3d7592a97c21d24cfc86930666286539a30025628" + "sha256": "4e7d5594ab96b2874e9ab27267c80d49f31ba57699b96af142d193d6777c0f1a" }, { "path": "snapshots/codegen/upstream-codegen-240-77d56a1585.snap", - "sha256": "c027c04b4adc0cf327d8bdd60f0cfaf3529b60c85cd6955d07e919f1d4f8a1bb" + "sha256": "21ac84461a0cbacd84248dc4fc8c83633ce53f050f3d76b2557331a1c4d61c70" }, { "path": "snapshots/codegen/upstream-codegen-241-e171ef72ce.snap", - "sha256": "9cf29f9bc192169ef5d677b3d7592a97c21d24cfc86930666286539a30025628" + "sha256": "4e7d5594ab96b2874e9ab27267c80d49f31ba57699b96af142d193d6777c0f1a" }, { "path": "snapshots/codegen/upstream-codegen-242-5c198a8490.snap", - "sha256": "4d523a1399ab5eb29d7d1cc1a9a398c6e96d73fd6bbf4d99b0ea07fc8a8dfca7" + "sha256": "01f026b07182e959a23c9d2cf1fec0fa90ee2b342e47274a34a9aaad099a1c28" }, { "path": "snapshots/codegen/upstream-codegen-243-14366a47ff.snap", @@ -2143,7 +2143,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-250-935ccdb80f.snap", - "sha256": "f8a7b08cb2be01fa31ec8836a11e6e9e2be951c1315028fa22c807e262b29597" + "sha256": "81f4a447d189627568f6d397cbfbeef4b409fab6f447404ebc2408689f8ce0ef" }, { "path": "snapshots/codegen/upstream-codegen-251-50acddfb96.snap", diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-122-7ddad74cc1.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-124-bdedf14335.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-126-14073e1999.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap index c3d7df23..caa033fe 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-132-9a7b0243b6.snap @@ -3,7 +3,7 @@ source: crates/devup-mcp-devup-ui/tests/compat_fixtures.rs expression: actual --- -" - - +" + + " diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap index fcfe3fde..44ae1781 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-143-52b441f9f4.snap @@ -13,7 +13,7 @@ expression: actual export function Button({ size, leftIcon, showLeftIcon, rightIcon, showRightIcon }: ButtonProps) { return ( - + {showLeftIcon && leftIcon} +
+
+
) diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap index b66c064a..679e1523 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-239-a6dada3682.snap @@ -5,7 +5,13 @@ expression: actual "export function SvgDetail() { return ( -
+
+
) diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap index b66c064a..679e1523 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-241-e171ef72ce.snap @@ -5,7 +5,13 @@ expression: actual "export function SvgDetail() { return ( -
+
+
) diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap index 54410e80..b4205053 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-250-935ccdb80f.snap @@ -23,6 +23,7 @@ export function Button({ size, variant }: ButtonProps) { white: [null, null, null, null, "$background"] }[variant] }} + appearance="none" bg={{ primary: "$primary", white: "$containerBackground" diff --git a/harness/runtime-audit/FAILURES-AUDIT.txt b/harness/runtime-audit/FAILURES-AUDIT.txt new file mode 100644 index 00000000..9fff9580 --- /dev/null +++ b/harness/runtime-audit/FAILURES-AUDIT.txt @@ -0,0 +1,30 @@ +Runtime failure coverage + +1. Error page / missing URL x width execution + page-health records vinext/Next error text, navigation failures, page and console errors, and a document narrower than the viewport. Screen status keeps default 1440/1100/858/390 widths plus drawn/reported widths even when a report is partial. Missing cells are not-run; written files are written-unverified until drawn-width completeness passes. Done requires every required cell to pass. + +2. Numbered cards in column order + Exports and devup_ui_validate flag column-reading-order risks rather than silently treating nested columns as responsive reading order. sequence-order checks numbered and STEP labels in visual row order at every width and names reversed elements. The converter reports the risk; it does not automatically rewrite every masonry layout. + +3. Narrow unstyled layouts and internal clipping + section-layout checks visible mapped top-level sections at every required width, requires at least half the container width, and rejects long text columns below 120px. Existing exact bounds, overflow and clipped-text checks remain. no-clipped-content catches clipped header navigation, technology lists and portfolio rows even when the document itself fits; carousel exceptions require an explicit audit marker and visible controls or scrollbar. Static warnings flag fixed multi-column rows and wide-only responsive sizing without a narrow layout rule. + +4. Flattened icons and wrong alignment + Generated plans compare each asset with its native Figma render at its drawn width. Pending comparisons and missing native references now block a passing cell. Text alignment is checked against Figma textAlignHorizontal in addition to geometry and section/page comparisons. Inspected commit 06241ab on devfive-relay-c70423/multicolor-icon-mask: its fill/stroke colour analysis covers purple outlines with light lavender fills and keeps them as Image; this fix is absent from main. No branch was merged. + +5. Questions during implementation + Draft already assembles site, structure and URL questions in its initial questions/questionRounds. Added a regression asserting all categories are covered and strengthened tool, usage-guide and /devup instructions to ask those rounds back to back before implementation, then make recorded conservative assumptions instead of asking again. + +Retained and completed the prior rendered-completeness work: frame height, visible sections/assets, decoded images and effective text contrast. Unsupported image/colour backgrounds remain inconclusive rather than passing. + +Validation + cargo fmt --check and cargo clippy --all-targets -- -D warnings pass. + npm test in harness/runtime-audit: 34 passed, including Chromium end-to-end checks at all four widths. + Serial Rust suite with the two known Windows symlink tests filtered: 1646 passed, 2 ignored, 2 filtered. + Final unfiltered cargo test -j 2 --no-fail-fast -- --test-threads=1: 1648 passed, 0 failed, 2 ignored, 0 filtered. + Windows symlink tests retain their privilege checks and return early when the host cannot create symlinks (error 1314); those checks remain unavailable on this host. + Generator concurrency regression: 1 passed; concurrent wrapper publication is now atomic and avoids rewriting identical output. + An unrelated asset-job timeout test failed once under contention, passed alone, and passed in the serial full-suite run. A second existing race exposed partially written generated JavaScript modules; atomic publication and a concurrent import/generation regression fix that failure. + +Scope + No apps/prime changes, no push, no PR and no branch merges. Prime itself was not rebuilt or visually re-audited; browser regressions use controlled fixtures. Patch changepacks were added and changes are committed on the requested branch. diff --git a/harness/runtime-audit/README.md b/harness/runtime-audit/README.md new file mode 100644 index 00000000..22bf7a50 --- /dev/null +++ b/harness/runtime-audit/README.md @@ -0,0 +1,79 @@ +# Real-render verification + +Static validation and a successful screenshot capture do not establish visual +correctness. With the generated app running, execute its runtime audit, compare +the actual Chromium screenshots against the independently acquired Figma PNGs, +and inspect the diffs before calling the page done. + +The existing `run.mjs` + `devup_runtime_audit ingest` + `devup_visual_compare` +workflow supports page, section and asset comparisons. A capture check's `pass` +means **captured**, not **visually matched**. Feed every comparison result back +to screen-plan status; missing, failed or inconclusive cells block completion. + +For a command-line page gate (including CI): + +```sh +npm ci +npx playwright install chromium +cargo build -p devup-mcp-visual +node verify-render.mjs --plan plan.json --references references.json --out out/page --visual-bin ../../target/debug/devup-mcp-visual +``` + +On Windows append `.exe` to the comparator path. With a custom Cargo target +directory use the binary under `cargo metadata`'s `target_directory`. + +Use an audit plan containing the drawn viewports at deviceScaleFactor 1, with +`page-health`, `image-loaded` and the relevant geometry checks. Use the normal +runtime audit separately for widths without Figma originals. Each planned +screen/viewport must have exactly one entry in `references.json`: + +```json +{ + "references": [{ + "screen": "home", + "width": 1440, + "height": 900, + "path": "figma/home-1440.png", + "sha256": "", + "threshold": 0.03 + }] +} +``` + +Paths are relative to the manifest. Export the original frame at scale 1 and +the drawn dimensions, without resizing it. Never create a reference from the +generated page, replace an original to pass a test, or raise thresholds to hide +wrong backgrounds, colors or assets. Pin the original hash and retain its Figma +file/node/version provenance alongside the manifest. + +`verify-render.mjs` launches the runtime-audit Chromium runner and compares every +page screenshot with `devup-mcp-visual`. It writes the runtime report, screenshots, +`real-render.json` and `visual-diff-*.png`. Exit codes: 0 means runtime and pixels +passed; 1 means a runtime or visual failure; 2 means tooling, input or reference +failure. Missing/duplicate references, changed reference bytes, missing captures, +browser errors and invalid image dimensions cannot pass. Do not infer success +from `report.status: completed`, which describes execution, not correctness. + +## Offline regression + +`npm test` runs all tests, including `test/real-render.test.mjs`. This needs Rust, +Node and Chromium; it does not silently skip when they are missing. The test +replays `fixtures/render-snapshot.json` through production Rust codegen, builds +the result with the real DevupUI plugin, serves the production app, and compares +the Chromium capture through the production Rust pixel comparator. The correct +page must match exactly. Removing its background, changing a card color or +requesting an unavailable asset must fail while the geometry still passes. + +The small checked-in PNG oracle is **synthetic**, not a live Figma acquisition: +its independent pixel construction represents the snapshot's 320x240 frame, +with `#123456` above y=120, `#abcdef` below, and a red 80x60 card at (32,32). +`render-hero.png` is the same background without the card. The oracle is never +rendered from the generated code or updated by the test. This deterministic +offline contract proves the gate detects rendering failures; verification of an +actual Figma page requires that page's exported original as described above. + +Set `DEVUP_KEEP_RENDER_EVIDENCE=1` to retain regression artifacts under `out/` +for diagnosis or upload by a CI caller. Run `npm ci`, +`npx playwright install --with-deps chromium`, and `npm test` to use this suite +in Linux CI. The repository workflow is not wired to this browser suite yet: +the GitHub proxy rejected pushes containing that workflow-file modification. diff --git a/harness/runtime-audit/checks.mjs b/harness/runtime-audit/checks.mjs index ebb246a1..0181d7ac 100644 --- a/harness/runtime-audit/checks.mjs +++ b/harness/runtime-audit/checks.mjs @@ -163,6 +163,9 @@ export function evaluatePageHealth(observed) { ...observed.errorTexts.map((text) => `the page shows "${text}"`), ...observed.errors.map((error) => `${error.kind}: ${String(error.message).split('\n')[0]}`), ]; + if (Number.isFinite(observed.documentWidth) && observed.documentWidth < observed.viewportWidth - 1) { + problems.push(`document is ${round(observed.documentWidth)}px wide in a ${observed.viewportWidth}px viewport`); + } return problems.length === 0 ? { status: 'pass', reason: 'no error page, page error, console error or failed navigation', measured: {} } : { status: 'fail', reason: problems.slice(0, 5).join('; '), measured: { problems } }; @@ -297,6 +300,154 @@ export function evaluateViewTransition(observed) { return { status: 'fail', reason: `navigating from ${observed.from} to ${observed.to} ran no view transition`, measured }; } +/** Parses a computed CSS colour (rgb/rgba, as getComputedStyle returns it). */ +export function parseColor(text) { + const match = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/.exec(String(text)); + if (!match) return null; + let alpha = match[4] === undefined ? 1 : parseFloat(match[4]); + if (String(match[4]).endsWith('%')) alpha /= 100; + return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: alpha }; +} + +function over(top, bottom) { + const a = top.a + bottom.a * (1 - top.a); + if (a === 0) return { r: 0, g: 0, b: 0, a: 0 }; + const mix = (key) => (top[key] * top.a + bottom[key] * bottom.a * (1 - top.a)) / a; + return { r: mix('r'), g: mix('g'), b: mix('b'), a }; +} + +function luminance({ r, g, b }) { + const channel = (value) => { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +/** WCAG contrast ratio between two opaque colours. */ +export function contrastRatio(first, second) { + const [light, dark] = [luminance(first), luminance(second)].sort((a, b) => b - a); + return (light + 0.05) / (dark + 0.05); +} + +/** + * The contrast of text against what is painted behind it: its own colour + * over the stack of ancestor background colours (nearest first), on the + * white canvas. Text over a background image cannot be judged from colours + * and returns null. + */ +export function textContrast(sample) { + if (sample.imageBehind) return null; + const color = parseColor(sample.color); + const canvas = parseColor(sample.canvasColor ?? 'rgb(255, 255, 255)'); + if (!color || !canvas) return null; + if (sample.layers) { + let ink = color; + let backdrop = { r: 0, g: 0, b: 0, a: 0 }; + for (const layer of sample.layers) { + const background = parseColor(layer.color); + if (!background && (ink.a < 1 || backdrop.a < 1)) return null; + if (background) { + ink = over(ink, background); + backdrop = over(backdrop, background); + } + ink.a *= layer.opacity; + backdrop.a *= layer.opacity; + } + return contrastRatio(over(ink, canvas), over(backdrop, canvas)); + } + let backdrop = canvas; + for (const layer of [...sample.backgrounds].reverse()) { + const parsed = parseColor(layer); + if (!parsed) return null; + backdrop = over(parsed, backdrop); + } + const ink = over({ ...color, a: color.a * (sample.opacity ?? 1) }, backdrop); + return contrastRatio(ink, backdrop); +} + +export const DEFAULT_MIN_HEIGHT_RATIO = 0.9; +export const DEFAULT_MIN_CONTRAST = 1.5; + +/** Text uses computed colours, including browser default button backgrounds. */ +export function evaluateNoLowContrastText(options, observed) { + const minContrast = options.minContrast ?? DEFAULT_MIN_CONTRAST; + const invisible = []; + let judged = 0; + let skipped = 0; + for (const sample of observed.texts) { + const contrast = textContrast(sample); + if (contrast === null) { skipped += 1; continue; } + judged += 1; + if (contrast < minContrast) invisible.push({ text: sample.text, tag: sample.tag, contrast: round(contrast) }); + } + return { + status: invisible.length ? 'fail' : skipped ? 'inconclusive' : 'pass', + reason: invisible.length + ? `${invisible.length} texts cannot be seen against their background: ${invisible.slice(0, 3).map((item) => `"${item.text}" (${item.contrast}:1)`).join(', ')}` + : skipped ? `${skipped} text samples have backgrounds or colours that cannot be judged` : `${judged} texts have visible contrast`, + measured: { judged, skipped, invisible: invisible.slice(0, 20), minContrast }, + }; +} + +/** Every img and CSS background URL must decode to a nonzero natural width. */ +export function evaluateImageLoaded(observed) { + const resources = observed.resources ?? []; + const broken = resources.filter((resource) => !resource.loaded); + return { + status: broken.length ? 'fail' : 'pass', + reason: broken.length ? `${broken.length} images did not load: ${broken.slice(0, 5).map((item) => item.source).join(', ')}` : `${resources.length} image resources loaded`, + measured: { listed: resources.length, broken }, + }; +} + +/** + * Whether the page the browser rendered is the frame it was built from, not + * merely code on disk: tall enough, every section and image present and + * shown, images decoded, and no text a person cannot see. + * + * @param {object} completeness the plan's { frameHeight, minHeightRatio, sections, images, minContrast } + * @param {object} observed { pageHeight, sections: [{ id, count, visible, width, height }], + * images: [{ id, count, visible, width, height, broken }], texts: [{ text, tag, color, backgrounds, imageBehind, opacity }] } + */ +export function evaluateRenderedCompleteness(completeness, observed) { + const ratio = completeness.minHeightRatio ?? DEFAULT_MIN_HEIGHT_RATIO; + const needed = completeness.frameHeight * ratio; + const problems = []; + if (!(observed.pageHeight >= needed)) { + problems.push(`the page is ${round(observed.pageHeight)}px tall, under ${Math.round(ratio * 100)}% of the ${round(completeness.frameHeight)}px frame`); + } + const absent = (items) => items.filter((item) => item.count !== 1 || !item.visible || !(item.width > 0) || !(item.height > 0)); + const missingSections = absent(observed.sections); + if (missingSections.length) { + problems.push(`${missingSections.length} of ${observed.sections.length} sections not rendered: ${missingSections.slice(0, 5).map((item) => item.id).join(', ')}`); + } + const missingImages = absent(observed.images); + if (missingImages.length) { + problems.push(`${missingImages.length} of ${observed.images.length} images not rendered: ${missingImages.slice(0, 5).map((item) => item.id).join(', ')}`); + } + const broken = observed.images.filter((item) => !missingImages.includes(item) && item.broken); + if (broken.length) { + problems.push(`${broken.length} images did not load: ${broken.slice(0, 5).map((item) => item.id).join(', ')}`); + } + const contrast = evaluateNoLowContrastText(completeness, observed); + const resources = evaluateImageLoaded(observed); + if (contrast.status === 'fail') problems.push(contrast.reason); + if (resources.status === 'fail') problems.push(resources.reason); + const measured = { + pageHeight: round(observed.pageHeight), + frameHeight: completeness.frameHeight, + minHeightRatio: ratio, + sections: { listed: observed.sections.length, missing: missingSections.map((item) => item.id) }, + images: { listed: observed.images.length, missing: missingImages.map((item) => item.id), broken: broken.map((item) => item.id) }, + texts: contrast.measured, + resources: resources.measured, + }; + return problems.length === 0 + ? { status: contrast.status, reason: contrast.status === 'inconclusive' ? contrast.reason : `rendered complete: ${observed.sections.length} sections, ${observed.images.length} images, ${contrast.measured.judged} texts visible, ${round(observed.pageHeight)}px tall`, measured } + : { status: 'fail', reason: problems.join('; '), measured }; +} + export function summarize(runs) { const summary = { pass: 0, fail: 0, inconclusive: 0, skipped: 0 }; for (const run of runs) { @@ -308,3 +459,35 @@ export function summarize(runs) { } return summary; } + +/** Read independent numbered groups by visual rows, then left to right. */ +export function evaluateSequenceOrder(observed) { + const failures = []; + for (const group of observed.groups) { + const remaining = [...group]; + const ordered = []; + while (remaining.length) { + const top = Math.min(...remaining.map((item) => item.y)); + const row = remaining.filter((item) => item.y <= top + 8).sort((a, b) => a.x - b.x); + ordered.push(...row); + for (const item of row) remaining.splice(remaining.indexOf(item), 1); + } + for (let index = 1; index < ordered.length; index += 1) { + if (ordered[index].number < ordered[index - 1].number) failures.push(`${ordered[index - 1].name} before ${ordered[index].name}`); + } + } + return { status: failures.length ? 'fail' : 'pass', reason: failures.length ? `sequence out of visual order: ${failures.join('; ')}` : 'numbered siblings follow increasing visual order', measured: observed }; +} + +export function evaluateSectionLayout(observed) { + const failures = observed.sections.filter((item) => !item.visible || item.width < item.containerWidth * 0.5 || item.height <= 0); + const narrow = observed.texts.filter((item) => item.width < 120); + const problems = [...failures.map((item) => `${item.name}: section ${round(item.width)}px wide in ${round(item.containerWidth)}px container or hidden`), ...narrow.map((item) => `${item.name}: text column only ${round(item.width)}px wide`)]; + return { status: problems.length ? 'fail' : 'pass', reason: problems.length ? problems.join('; ') : 'sections visible and text columns have room', measured: observed }; +} + +export function evaluateTextAlignment(check, observed) { + const actual = observed.align === 'start' ? (observed.direction === 'rtl' ? 'right' : 'left') : observed.align === 'end' ? (observed.direction === 'rtl' ? 'left' : 'right') : observed.align; + const pass = observed.count === 1 && observed.visible && actual === check.edge; + return { status: pass ? 'pass' : 'fail', reason: pass ? `text is ${actual} aligned as drawn` : `text alignment ${actual ?? 'missing'}, expected ${check.edge} (${observed.count} elements)`, measured: observed }; +} diff --git a/harness/runtime-audit/fixtures/render-hero.png b/harness/runtime-audit/fixtures/render-hero.png new file mode 100644 index 00000000..d0238a82 Binary files /dev/null and b/harness/runtime-audit/fixtures/render-hero.png differ diff --git a/harness/runtime-audit/fixtures/render-reference.png b/harness/runtime-audit/fixtures/render-reference.png new file mode 100644 index 00000000..cd99c343 Binary files /dev/null and b/harness/runtime-audit/fixtures/render-reference.png differ diff --git a/harness/runtime-audit/fixtures/render-snapshot.json b/harness/runtime-audit/fixtures/render-snapshot.json new file mode 100644 index 00000000..c3d6c79c --- /dev/null +++ b/harness/runtime-audit/fixtures/render-snapshot.json @@ -0,0 +1,28 @@ +{ + "fileKey": "synthetic-render-contract", + "version": "1", + "roots": ["1:1"], + "diagnostics": [], + "nodes": { + "1:1": { + "id": "1:1", "type": "FRAME", + "fields": { + "name": "Hero", "width": 320, "height": 240, + "layoutMode": "NONE", "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "childrenIds": ["1:2"], + "fills": [{"type": "IMAGE", "imageHash": "hero", "scaleMode": "FILL"}] + }, + "extra": {}, "fieldErrors": {} + }, + "1:2": { + "id": "1:2", "type": "FRAME", + "fields": { + "name": "Card", "parentId": "1:1", "x": 32, "y": 32, "width": 80, "height": 60, + "layoutMode": "NONE", "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "FIXED", + "childrenIds": [], + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}}] + }, + "extra": {}, "fieldErrors": {} + } + } +} diff --git a/harness/runtime-audit/package-lock.json b/harness/runtime-audit/package-lock.json index 795e7e34..69799e1a 100644 --- a/harness/runtime-audit/package-lock.json +++ b/harness/runtime-audit/package-lock.json @@ -10,6 +10,726 @@ }, "bin": { "devup-runtime-audit": "run.mjs" + }, + "devDependencies": { + "@devup-ui/react": "^1.0.41", + "@devup-ui/reset-css": "^1.0.28", + "@devup-ui/vite-plugin": "^1.0.69", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "vite": "^8.3.0" + } + }, + "node_modules/@devup-ui/plugin-utils": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@devup-ui/plugin-utils/-/plugin-utils-1.0.17.tgz", + "integrity": "sha512-jgsaYGrW5698I+bJRYTys4zJ9cbRs/vUnw5Ux5hGE+0ohrPQprGgLiv5zWFXMpFNQ1gyYcRzTKjcIKdngczWaA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@devup-ui/react": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@devup-ui/react/-/react-1.0.41.tgz", + "integrity": "sha512-RX8swgCcGRQN0WMQDtfgxMeis9iH7ALfQROJ9v5XdGzB1gLfrORPBsWHFzG4Qz1ZB6ULVp/Z0L0cTnW1UxP4Hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "csstype-extra": "latest", + "react": "^19.2" + }, + "peerDependencies": { + "csstype-extra": "*", + "react": "*" + } + }, + "node_modules/@devup-ui/reset-css": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@devup-ui/reset-css/-/reset-css-1.0.28.tgz", + "integrity": "sha512-81dZ0KEsY++tzBUTVJIbTNiTIdVKOFeTUhe32MiN66Y4u1Xm7zNrfCBpV0oXkBcictRjXLXwEDn0N27Uv7VdCA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@devup-ui/react": "^1.0.40" + }, + "peerDependencies": { + "@devup-ui/react": "^1.0.40" + } + }, + "node_modules/@devup-ui/vite-plugin": { + "version": "1.0.69", + "resolved": "https://registry.npmjs.org/@devup-ui/vite-plugin/-/vite-plugin-1.0.69.tgz", + "integrity": "sha512-HW+YjO53cSmu+qWQ2r6Tx5LmUb7XYPaJ9A3e/sH5Pdtg16lUMAAfbeMf5lSc1Vp9HU2l5mXE97vYCsTbwbnSbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@devup-ui/plugin-utils": "^1.0.13", + "@devup-ui/wasm": "^1.0.78" + }, + "peerDependencies": { + "@devup-ui/wasm": "*", + "vite": "*" + } + }, + "node_modules/@devup-ui/wasm": { + "version": "1.0.83", + "resolved": "https://registry.npmjs.org/@devup-ui/wasm/-/wasm-1.0.83.tgz", + "integrity": "sha512-zZyWIhu/BfcQzvFLtTkOM8CPhOXTI9Gnv5DmXm2kYqTS0PYSnf/jKcRp5GFn5aTf3OqKr2UX9NR2+MLX/P2tGg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype-extra": { + "version": "0.1.37", + "resolved": "https://registry.npmjs.org/csstype-extra/-/csstype-extra-0.1.37.tgz", + "integrity": "sha512-zrYj38Dm1DAMWM9lW1tto2oGewGfCqRNN80HEV5TVnkTTo1aSYPaiuGz8SXtFwd7534PSGLZ0zxW8+3X74Mcdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/playwright": { @@ -38,6 +758,219 @@ "engines": { "node": ">=20" } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } } } } diff --git a/harness/runtime-audit/package.json b/harness/runtime-audit/package.json index 1f3455a1..609d93fb 100644 --- a/harness/runtime-audit/package.json +++ b/harness/runtime-audit/package.json @@ -7,10 +7,19 @@ "devup-runtime-audit": "run.mjs" }, "scripts": { - "test": "node --test test/checks.test.mjs test/browser.test.mjs", + "test": "node --test test/*.test.mjs", + "verify:render": "node verify-render.mjs", "test:unit": "node --test test/checks.test.mjs" }, "dependencies": { "playwright": "1.63.0" + }, + "devDependencies": { + "@devup-ui/react": "^1.0.41", + "@devup-ui/reset-css": "^1.0.28", + "@devup-ui/vite-plugin": "^1.0.69", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "vite": "^8.3.0" } } diff --git a/harness/runtime-audit/resource-checks.mjs b/harness/runtime-audit/resource-checks.mjs new file mode 100644 index 00000000..c61642e5 --- /dev/null +++ b/harness/runtime-audit/resource-checks.mjs @@ -0,0 +1,290 @@ +// Checks for the failure a fresh headless profile hides: a page whose images +// never arrived, whose buttons kept the browser's native face, or whose +// stylesheet differs from the one served a moment later. Observers run inside +// the page; evaluators are pure so each rule is testable without a browser. + +const UA_GREY = new Set(['rgb(239, 239, 239)']); +const URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g; + +function round(value) { + return Math.round(value * 100) / 100; +} + +/** Refuse requests outside the plan's origins and remember what was refused. */ +export async function guardOrigins(context, allowed, blocked) { + await context.route('**/*', (route) => { + const url = route.request().url(); + const protocol = url.slice(0, url.indexOf(':')); + if (['data', 'blob', 'about'].includes(protocol) || allowed.has(new URL(url).origin)) { + return route.continue(); + } + blocked.push(url); + return route.abort('blockedbyclient'); + }); +} + +/** Every rendered img and CSS background url in scope, loaded and measured. */ +export function observeImages(scope) { + return scope.evaluate(async (root) => { + const rendered = (element) => !element.checkVisibility || element.checkVisibility({ visibilityProperty: true }); + const absolute = (source) => { + try { return new URL(source, document.baseURI).href; } catch { return source; } + }; + const label = (element) => element.id ? `#${element.id}` : `${element.tagName.toLowerCase()}${element.className && typeof element.className === 'string' ? `.${element.className.trim().split(/\s+/)[0]}` : ''}`; + const images = []; + const pending = []; + let hidden = 0; + for (const element of [root, ...root.querySelectorAll('*')]) { + const shown = rendered(element); + const rect = element.getBoundingClientRect(); + const box = { width: Math.round(rect.width * 100) / 100, height: Math.round(rect.height * 100) / 100 }; + if (element.tagName === 'IMG') { + if (!shown) hidden += 1; + else { + const item = { kind: 'img', element: label(element), source: element.currentSrc || element.src || '', loaded: false, ...box }; + images.push(item); + element.loading = 'eager'; + pending.push(new Promise((resolve) => { + const timer = setTimeout(resolve, 5000); + const done = () => { item.loaded = element.complete && element.naturalWidth > 0; clearTimeout(timer); resolve(); }; + element.decode().then(done, done); + })); + } + } + for (const pseudo of [null, '::before', '::after']) { + const style = getComputedStyle(element, pseudo); + if (pseudo && (style.content === 'none' || style.content === 'normal')) continue; + for (const match of style.backgroundImage.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g)) { + if (!shown) { hidden += 1; continue; } + const source = absolute(match[1] ?? match[2] ?? match[3]); + const item = { kind: `background${pseudo ?? ''}`, element: label(element), source, loaded: false, ...box }; + images.push(item); + pending.push(new Promise((resolve) => { + const picture = new Image(); + const timer = setTimeout(resolve, 5000); + const done = () => { item.loaded = picture.naturalWidth > 0; clearTimeout(timer); resolve(); }; + picture.onload = done; + picture.onerror = done; + picture.src = source; + if (picture.complete) done(); + })); + } + } + } + await Promise.all(pending); + return { images, hidden }; + }); +} + +/** + * @param {object} observed { images: [{ kind, element, source, loaded, width, height }], hidden } + * @param {object} requests { responses: [{ url, status }], failed: [{ url, failure }], scoped } + * image requests made while the screen was open. When scoped, only requests + * for an image in scope count. + */ +export function evaluateImagesLoaded(observed, requests = {}) { + const images = observed.images ?? []; + const unpainted = images.flatMap((item) => { + const why = []; + if (!item.loaded) why.push('did not load'); + if (!(item.width > 0 && item.height > 0)) why.push('has no painted box'); + return why.length ? [{ ...item, problem: why.join(' and ') }] : []; + }); + const sources = new Set(images.map((item) => item.source)); + const inScope = (item) => !requests.scoped || sources.has(item.url); + const seen = new Set(); + const requestFailures = [ + ...(requests.responses ?? []).filter((item) => item.status >= 400).map((item) => ({ url: item.url, status: item.status })), + ...(requests.failed ?? []).map((item) => ({ url: item.url, status: null, failure: item.failure })), + ].filter(inScope).filter((item) => { + const key = `${item.url} ${item.status}`; + return seen.has(key) ? false : (seen.add(key), true); + }); + const problems = []; + if (unpainted.length) { + problems.push(`${unpainted.length} images ${unpainted.slice(0, 5).map((item) => `${item.source || '(no src)'} on ${item.element} ${item.problem}`).join(', ')}`); + } + if (requestFailures.length) { + problems.push(`${requestFailures.length} image requests failed: ${requestFailures.slice(0, 5).map((item) => `${item.status ?? item.failure} ${item.url}`).join(', ')}`); + } + return { + status: problems.length ? 'fail' : 'pass', + reason: problems.length ? problems.join('; ') : `${images.length} images and backgrounds loaded and painted`, + measured: { listed: images.length, hidden: observed.hidden ?? 0, unpainted: unpainted.slice(0, 20), requestFailures: requestFailures.slice(0, 20) }, + }; +} + +/** Buttons and their computed appearance, against what the browser paints by default. */ +export function observeNativeAppearance(scope) { + return scope.evaluate((root) => { + const isButton = (element) => element.tagName === 'BUTTON' + || element.getAttribute('role') === 'button' + || (element.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes(element.type)); + const frame = document.createElement('iframe'); + frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden'; + document.body.appendChild(frame); + const defaults = new Map(); + const defaultsFor = (element) => { + const key = `${element.tagName}:${element.type ?? ''}`; + if (!defaults.has(key)) { + const doc = frame.contentDocument; + const probe = doc.createElement(element.tagName.toLowerCase()); + if (element.tagName === 'INPUT') probe.type = element.type; + doc.body.appendChild(probe); + const style = frame.contentWindow.getComputedStyle(probe); + defaults.set(key, { background: style.backgroundColor, borderStyle: style.borderTopStyle, borderWidth: style.borderTopWidth, borderColor: style.borderTopColor }); + } + return defaults.get(key); + }; + const transparent = (color) => color === 'transparent' || /^rgba\(.*,\s*0\)$/.test(color); + const controls = []; + try { + for (const element of [root, ...root.querySelectorAll('button, input, [role="button"]')]) { + if (!isButton(element)) continue; + if (element.checkVisibility && !element.checkVisibility({ visibilityProperty: true })) continue; + const rect = element.getBoundingClientRect(); + if (!rect.width || !rect.height) continue; + const style = getComputedStyle(element); + const base = defaultsFor(element); + const authorBackground = (!transparent(style.backgroundColor) && style.backgroundColor !== base.background) + || (style.backgroundImage !== 'none'); + const authorBorder = style.borderTopStyle !== 'none' && parseFloat(style.borderTopWidth) > 0 + && (style.borderTopStyle !== base.borderStyle || style.borderTopWidth !== base.borderWidth || style.borderTopColor !== base.borderColor); + controls.push({ + tag: element.tagName.toLowerCase(), + type: element.type ?? null, + name: (element.getAttribute('aria-label') || element.value || element.textContent || '').trim().slice(0, 40), + appearance: style.appearance || style.webkitAppearance, + backgroundColor: style.backgroundColor, + borderStyle: style.borderTopStyle, + borderWidth: style.borderTopWidth, + authorBackground, + authorBorder, + }); + } + } finally { + frame.remove(); + } + return { controls }; + }); +} + +/** + * A button keeps the browser's native face unless the author set both a + * background and a border; and a computed background of the UA default grey + * is the native face leaking through whatever appearance says. + */ +export function evaluateNativeAppearance(observed) { + const controls = observed.controls ?? []; + const flagged = controls.flatMap((control) => { + const why = []; + if (control.appearance && control.appearance !== 'none' && !(control.authorBackground && control.authorBorder)) { + why.push(`appearance ${control.appearance} without an author-set background and border`); + } + if (UA_GREY.has(control.backgroundColor)) why.push(`UA default grey background ${control.backgroundColor}`); + return why.length ? [{ ...control, problems: why }] : []; + }); + return { + status: flagged.length ? 'fail' : 'pass', + reason: flagged.length + ? `${flagged.length} buttons show native browser chrome: ${flagged.slice(0, 5).map((item) => `<${item.tag}> "${item.name}" (${item.problems.join('; ')})`).join(', ')}` + : `${controls.length} buttons are drawn by the author`, + measured: { listed: controls.length, native: flagged.slice(0, 20) }, + }; +} + +/** Stylesheets (href + rule hash), image urls and the first section's height. */ +export function snapshotLoad(page) { + return page.evaluate(() => { + const hash = (text) => { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + h1 = Math.imul(h1 ^ code, 2654435761); + h2 = Math.imul(h2 ^ code, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); + }; + const stylesheets = [...document.styleSheets].map((sheet, index) => { + let text = null; + try { text = [...sheet.cssRules].map((rule) => rule.cssText).join('\n'); } catch { text = null; } + return { href: sheet.href ?? `inline#${index}`, hash: text === null ? 'unreadable' : hash(text) }; + }).sort((a, b) => a.href.localeCompare(b.href)); + const images = new Set(); + for (const image of document.images) if (image.currentSrc || image.src) images.add(image.currentSrc || image.src); + for (const element of document.querySelectorAll('*')) { + for (const pseudo of [null, '::before', '::after']) { + for (const match of getComputedStyle(element, pseudo).backgroundImage.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g)) { + images.add(match[1] ?? match[2] ?? match[3]); + } + } + } + const section = document.querySelector('section') || document.querySelector('main > *') || document.body.firstElementChild; + return { + stylesheets, + images: [...images].sort(), + firstSection: section + ? { name: section.id ? `#${section.id}` : section.tagName.toLowerCase(), height: Math.round(section.getBoundingClientRect().height * 100) / 100 } + : null, + }; + }); +} + +/** What changed between two loads of the same screen; empty when nothing did. */ +export function diffLoads(first, second, tolerance = 0.5) { + const diff = []; + const sheets = (load) => new Map(load.stylesheets.map((sheet) => [sheet.href, sheet.hash])); + const before = sheets(first); + const after = sheets(second); + for (const [href, hash] of before) { + if (!after.has(href)) diff.push(`stylesheet ${href} missing from the fresh load`); + else if (after.get(href) !== hash) diff.push(`stylesheet ${href} content changed (${hash} -> ${after.get(href)})`); + } + for (const href of after.keys()) if (!before.has(href)) diff.push(`stylesheet ${href} only in the fresh load`); + const images = new Set(first.images); + const fresh = new Set(second.images); + for (const url of images) if (!fresh.has(url)) diff.push(`image ${url} missing from the fresh load`); + for (const url of fresh) if (!images.has(url)) diff.push(`image ${url} only in the fresh load`); + const [a, b] = [first.firstSection, second.firstSection]; + if ((a === null) !== (b === null)) diff.push('first section present in only one load'); + else if (a && Math.abs(a.height - b.height) > tolerance) diff.push(`first section ${a.name} height ${round(a.height)}px -> ${round(b.height)}px`); + return diff; +} + +/** + * @param {object} observed { responses: [{ url, status }] subresources of the first load, + * first, second: snapshotLoad results, skip: why no reload was made, reloadError } + */ +export function evaluateStaleResources(observed) { + const errors = (observed.responses ?? []).filter((item) => item.status >= 400); + const problems = []; + if (errors.length) problems.push(`${errors.length} resources answered with an error: ${errors.slice(0, 5).map((item) => `${item.status} ${item.url}`).join(', ')}`); + const diff = observed.first && observed.second ? diffLoads(observed.first, observed.second) : []; + if (diff.length) problems.push(`the page differs from a fresh, cache-free load: ${diff.slice(0, 8).join('; ')}`); + const measured = { responses: (observed.responses ?? []).length, errors: errors.slice(0, 20), diff: diff.slice(0, 40) }; + if (problems.length) return { status: 'fail', reason: problems.join('; '), measured }; + if (!observed.second) { + return { status: 'inconclusive', reason: observed.skip ?? (observed.reloadError ? `the fresh load failed: ${observed.reloadError}` : 'no fresh load to compare'), measured }; + } + return { status: 'pass', reason: 'stylesheets, images and first section height match a fresh, cache-free load', measured }; +} + +/** Load a url in a new context with the HTTP cache disabled and snapshot it. */ +export async function freshLoad({ browser, contextOptions, install, url, timeout, ready }) { + const context = await browser.newContext(contextOptions); + try { + await install(context); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + await session.send('Network.enable'); + await session.send('Network.setCacheDisabled', { cacheDisabled: true }); + await page.goto(url, { waitUntil: 'load', timeout }); + await ready(page); + return await snapshotLoad(page); + } finally { + await context.close(); + } +} diff --git a/harness/runtime-audit/run.mjs b/harness/runtime-audit/run.mjs index f78387d6..77d54673 100644 --- a/harness/runtime-audit/run.mjs +++ b/harness/runtime-audit/run.mjs @@ -17,8 +17,11 @@ import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { ERROR_PAGE_TEXT, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, - evaluateInteraction, evaluatePageHealth, evaluateViewTransition, summarize, + evaluateInteraction, evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout, evaluateNoLowContrastText, evaluateRenderedCompleteness, evaluateViewTransition, summarize, } from './checks.mjs'; +import { + evaluateImagesLoaded, evaluateNativeAppearance, evaluateStaleResources, freshLoad, guardOrigins, observeImages, observeNativeAppearance, snapshotLoad, +} from './resource-checks.mjs'; const PLAN_KIND = 'devup-runtime-audit-plan'; const REPORT_KIND = 'devup-runtime-audit-report'; @@ -195,6 +198,170 @@ async function clippedText(page, subject) { }); } +// Internal overflow can hide cards and navigation even when the page fits. +export function observeClippedContent(page) { + return page.evaluate(() => { + const clipped = []; + for (const element of document.querySelectorAll('body *')) { + if (!element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + if (!box.width || !box.height) continue; + const horizontal = style.overflowX !== 'visible' && element.scrollWidth > element.clientWidth + 1; + const vertical = ['hidden','clip'].includes(style.overflowY) && element.scrollHeight > element.clientHeight + 1; + if (!horizontal && !vertical) continue; + if (style.textOverflow === 'ellipsis' || (style.webkitLineClamp && style.webkitLineClamp !== 'none')) continue; + const scrollbar = element.offsetHeight - element.clientHeight - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth) > 2; + const controls = element.parentElement?.querySelector('button[aria-label], [role="scrollbar"]'); + if (horizontal && !vertical && element.dataset.auditCarousel === 'true' && (scrollbar || controls)) continue; + clipped.push({ name: element.id || `${element.tagName.toLowerCase()} "${element.textContent.trim().slice(0,60)}"`, horizontal, vertical }); + } + return { clipped }; + }); +} + +// What the rendered page holds of its frame. A node is found by the id it +// carries itself, or, on a page merged from one frame per width, under this +// width in its data-figma-node-ids map. +export function observeCompleteness(page, completeness, width, subject) { + const scope = subject ? locate(page, subject) : page.locator('body'); + return scope.evaluate(async (root, { sections, images, width: drawnWidth }) => { + const find = (id) => document.querySelectorAll( + `[data-figma-node-id="${id}"], [data-figma-node-ids~="${drawnWidth}:${id}"]`, + ); + const shown = (element) => !element.checkVisibility + || element.checkVisibility({ opacityProperty: true, visibilityProperty: true }); + const describe = (id) => { + const found = find(id); + if (found.length !== 1) return { id, count: found.length }; + const element = found[0]; + const rect = element.getBoundingClientRect(); + const img = element.tagName === 'IMG' ? element : null; + return { + id, + count: 1, + visible: shown(element), + width: rect.width, + height: rect.height, + broken: img ? !(img.complete && img.naturalWidth > 0) : false, + }; + }; + const canvasProbe = document.createElement('span'); + canvasProbe.style.backgroundColor = 'Canvas'; + document.body.appendChild(canvasProbe); + const canvasColor = getComputedStyle(canvasProbe).backgroundColor; + canvasProbe.remove(); + const texts = []; + const walker = [...(root === document.body ? [document.documentElement] : []), root, ...root.querySelectorAll('*')]; + for (let index = 0; index < walker.length; index += 1) { + const element = walker[index]; + const own = [...element.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent.trim()).join(' ').trim(); + if (!own || !shown(element)) continue; + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + const style = getComputedStyle(element); + const backgrounds = []; + const layers = []; + let imageBehind = false; + let backgroundExposed = true; + let opacity = 1; + for (let node = element; node && node.nodeType === 1; node = node.parentElement) { + const nodeStyle = getComputedStyle(node); + opacity *= Number(nodeStyle.opacity); + layers.push({ color: nodeStyle.backgroundColor, opacity: Number(nodeStyle.opacity) }); + if (backgroundExposed) { + if (nodeStyle.backgroundImage && nodeStyle.backgroundImage !== 'none') imageBehind = true; + backgrounds.push(nodeStyle.backgroundColor); + // An opaque colour covers images and colours on more distant ancestors. + if (/^rgb\(/.test(nodeStyle.backgroundColor) || /,\s*1\)$/.test(nodeStyle.backgroundColor)) backgroundExposed = false; + } + } + texts.push({ text: own.slice(0, 60), tag: element.tagName.toLowerCase(), color: style.color, canvasColor, backgrounds, layers, imageBehind, opacity }); + } + const resources = []; + const pending = []; + const probe = (source, element, kind) => { + const item = { source, tag: element.tagName.toLowerCase(), kind, loaded: false }; + resources.push(item); + pending.push(new Promise((resolve) => { + const image = new Image(); + const timer = setTimeout(() => resolve(), 5000); + const done = () => { item.loaded = image.naturalWidth > 0; clearTimeout(timer); resolve(); }; + image.onload = done; + image.onerror = done; + image.src = source; + if (image.complete) done(); + })); + }; + for (const element of walker) { + if (element.tagName === 'IMG') { + const item = { source: element.currentSrc || element.src, tag: 'img', kind: 'img', loaded: false }; + resources.push(item); + element.loading = 'eager'; + pending.push(new Promise((resolve) => { + const timer = setTimeout(resolve, 5000); + const done = () => { item.loaded = element.naturalWidth > 0; clearTimeout(timer); resolve(); }; + element.decode().then(done, done); + })); + } + for (const pseudo of [null, '::before', '::after']) { + const style = getComputedStyle(element, pseudo); + if (pseudo && (style.content === 'none' || style.content === 'normal')) continue; + for (const match of style.backgroundImage.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g)) { + probe(match[1] ?? match[2] ?? match[3], element, `background${pseudo ?? ''}`); + } + } + } + await Promise.all(pending); + return { + resources, + pageHeight: document.documentElement.scrollHeight, + sections: sections.map(describe), + images: images.map(describe), + texts, + }; + }, { sections: completeness.sections ?? [], images: completeness.images ?? [], width }); +} + +export function observeSequence(page) { + return page.evaluate(() => { + const groups = new Map(); + for (const element of document.querySelectorAll('body *')) { + if (element.children.length || !element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; + const label = element.textContent.trim(); + const match = /^(?:STEP\s*(\d{1,3})|(0\d|[1-9]\d?))[.?:]?$/i.exec(label); + if (!match) continue; + const number = Number(match[1] ?? match[2]); + const scope = element.closest('section, [data-sequence-group], main') ?? document.body; + const box = element.getBoundingClientRect(); + if (!box.width || !box.height) continue; + const group = groups.get(scope) ?? []; + group.push({ number, name: `${label} <${element.tagName.toLowerCase()}>`, x: box.x, y: box.y }); + groups.set(scope, group); + } + return { groups: [...groups.values()].filter((group) => group.length > 1 && new Set(group.map((item) => item.number)).size === group.length) }; + }); +} + +export async function observeSectionLayout(page, subject, container) { + if (subject) { + const target = locate(page, subject); + const parent = locate(page, container); + const box = await target.count() === 1 ? await target.boundingBox() : null; + const containerBox = await parent.count() === 1 ? await parent.boundingBox() : null; + return { sections: [{ name: JSON.stringify(subject), visible: Boolean(box) && await target.evaluate((element) => element.checkVisibility({ opacityProperty: true, visibilityProperty: true })), width: box?.width ?? 0, height: box?.height ?? 0, containerWidth: containerBox?.width ?? page.viewportSize().width }], texts: [] }; + } + return page.evaluate(() => { + const shown = (element) => element.checkVisibility({ opacityProperty: true, visibilityProperty: true }); + const sections = [...document.querySelectorAll('main > section, body > section')].map((element) => { + const box = element.getBoundingClientRect(); + return { name: element.id || element.tagName, visible: shown(element), width: box.width, height: box.height, containerWidth: element.parentElement.getBoundingClientRect().width }; + }); + const texts = [...document.querySelectorAll('body *')].filter((element) => shown(element) && [...element.childNodes].filter((node) => node.nodeType === 3).map((node) => node.textContent.trim()).join(' ').length > 24).map((element) => ({ name: element.textContent.trim().slice(0, 60), width: element.getBoundingClientRect().width })); + return { sections, texts }; + }); +} + async function stateStyles(locator) { return locator.evaluate((element, properties) => { const style = getComputedStyle(element); @@ -280,6 +447,7 @@ async function pageMetrics(page) { const bodyStyle = document.body ? getComputedStyle(document.body) : style; return { viewportWidth, + documentWidth: document.documentElement.getBoundingClientRect().width, scrollWidth: document.documentElement.scrollWidth, maxRight, widest, @@ -320,7 +488,7 @@ async function checkExpectations(page, expect, timeout) { async function runViewport(browser, plan, viewport, out, deadline, references) { const allowed = new Set(plan.allowedOrigins.map((origin) => new URL(origin).origin)); - const context = await browser.newContext({ + const contextOptions = { viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor ?? 1, isMobile: Boolean(viewport.mobile), @@ -328,27 +496,29 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { locale: plan.locale ?? undefined, timezoneId: plan.timezone ?? undefined, serviceWorkers: 'block', - }); + }; + const context = await browser.newContext(contextOptions); const environmentErrors = []; const blocked = []; - await context.route('**/*', (route) => { - const url = route.request().url(); - const protocol = url.slice(0, url.indexOf(':')); - if (['data', 'blob', 'about'].includes(protocol) || allowed.has(new URL(url).origin)) { - return route.continue(); - } - blocked.push(url); - return route.abort('blockedbyclient'); - }); + await guardOrigins(context, allowed, blocked); await context.addInitScript(VIEW_TRANSITION_PROBE); const page = await context.newPage(); page.on('pageerror', (error) => environmentErrors.push({ kind: 'page-error', message: String(error?.message ?? error) })); page.on('console', (message) => { if (message.type() === 'error') environmentErrors.push({ kind: 'console-error', message: message.text() }); }); + const responseLog = []; + page.on('response', (response) => { + const type = response.request().resourceType(); + if (['image', 'script', 'stylesheet', 'font', 'media'].includes(type)) { + responseLog.push({ url: response.url(), status: response.status(), type }); + } + }); + const failedLog = []; page.on('requestfailed', (request) => { const failure = request.failure()?.errorText ?? 'failed'; if (failure.includes('ERR_BLOCKED_BY_CLIENT')) return; + if (request.resourceType() === 'image') failedLog.push({ url: request.url(), failure }); environmentErrors.push({ kind: 'request-failed', message: `${request.url()}: ${failure}` }); }); @@ -365,6 +535,8 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { const record = { id: screen.id, status: 'completed', checks: [] }; run.screens.push(record); const errorsAtStart = environmentErrors.length; + const responsesAtStart = responseLog.length; + const failedAtStart = failedLog.length; const notMeasured = (status, reason) => { record.checks = screen.checks.map((check, index) => ({ index, kind: check.kind, label: check.label, status, reason, measured: {} })); }; @@ -423,7 +595,27 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { const errors = environmentErrors .slice(errorsAtStart) .filter((error) => ['page-error', 'console-error', 'navigation-failed'].includes(error.kind)); - record.checks.push({ ...item, ...evaluatePageHealth({ errorTexts: ERROR_PAGE_TEXT.filter((needle) => text.includes(needle)), errors }) }); + record.checks.push({ ...item, ...evaluatePageHealth({ errorTexts: ERROR_PAGE_TEXT.filter((needle) => text.replaceAll("?", "'").includes(needle)), errors, documentWidth: metrics.documentWidth, viewportWidth: metrics.viewportWidth }) }); + continue; + } + if (check.kind === 'text-align') { + const target = locate(page, check.subject); + const count = await target.count(); + const observed = count === 1 ? await target.evaluate((element) => ({ count: 1, visible: element.checkVisibility({ opacityProperty: true, visibilityProperty: true }), align: getComputedStyle(element).textAlign, direction: getComputedStyle(element).direction })) : { count }; + record.checks.push({ ...item, ...evaluateTextAlignment(check, observed) }); + continue; + } + if (check.kind === 'no-clipped-content') { + const observed = await observeClippedContent(page); + record.checks.push({ ...item, status: observed.clipped.length ? 'fail' : 'pass', reason: observed.clipped.length ? `content cut off: ${observed.clipped.map((item) => item.name).join('; ')}` : 'no hidden internal content', measured: observed }); + continue; + } + if (check.kind === 'sequence-order') { + record.checks.push({ ...item, ...evaluateSequenceOrder(await observeSequence(page)) }); + continue; + } + if (check.kind === 'section-layout') { + record.checks.push({ ...item, ...evaluateSectionLayout(await observeSectionLayout(page, check.subject, check.container)) }); continue; } if (check.kind === 'hidden') { @@ -437,6 +629,60 @@ async function runViewport(browser, plan, viewport, out, deadline, references) { record.checks.push({ ...item, ...evaluateHidden({ subjectCount, visible }) }); continue; } + if (check.kind === 'image-loaded') { + const scope = check.subject ? locate(page, check.subject) : page.locator('body'); + if ((await scope.count()) !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); + continue; + } + const requests = { + responses: responseLog.slice(responsesAtStart).filter((entry) => entry.type === 'image'), + failed: failedLog.slice(failedAtStart), + scoped: Boolean(check.subject), + }; + record.checks.push({ ...item, ...evaluateImagesLoaded(await observeImages(scope), requests) }); + continue; + } + if (check.kind === 'no-low-contrast-text') { + if (check.subject && await locate(page, check.subject).count() !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); + continue; + } + const observed = await observeCompleteness(page, {}, viewport.width, check.subject); + const result = evaluateNoLowContrastText({}, observed); + record.checks.push({ ...item, ...result }); + continue; + } + if (check.kind === 'native-appearance') { + const scope = check.subject ? locate(page, check.subject) : page.locator('body'); + if ((await scope.count()) !== 1) { + record.checks.push({ ...item, status: 'inconclusive', reason: 'subject must match exactly one element', measured: {} }); + continue; + } + record.checks.push({ ...item, ...evaluateNativeAppearance(await observeNativeAppearance(scope)) }); + continue; + } + if (check.kind === 'stale-resources') { + const responses = responseLog.slice(responsesAtStart); + const first = await snapshotLoad(page); + let second = null; + let reloadError = null; + try { + second = await freshLoad({ + browser, contextOptions, url: page.url(), timeout: stepTimeout, + install: (fresh) => guardOrigins(fresh, allowed, blocked), + ready: (fresh) => waitReady(fresh, plan, stepTimeout), + }); + } catch (error) { + reloadError = String(error?.message ?? error).split('\n')[0]; + } + record.checks.push({ ...item, ...evaluateStaleResources({ responses, first, second, reloadError }) }); + continue; + } + if (check.kind === 'rendered-completeness') { + record.checks.push({ ...item, ...evaluateRenderedCompleteness(check.completeness, await observeCompleteness(page, check.completeness, viewport.width)) }); + continue; + } if (check.kind === 'no-clipped-text') { record.checks.push({ ...item, ...evaluateClippedText(await clippedText(page, check.subject)) }); continue; diff --git a/harness/runtime-audit/test/appearance.test.mjs b/harness/runtime-audit/test/appearance.test.mjs new file mode 100644 index 00000000..fda13a06 --- /dev/null +++ b/harness/runtime-audit/test/appearance.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + diffLoads, evaluateImagesLoaded, evaluateNativeAppearance, evaluateStaleResources, observeImages, observeNativeAppearance, +} from '../resource-checks.mjs'; + +const control = (over) => ({ tag: 'button', name: 'Go', appearance: 'none', backgroundColor: 'rgb(0, 0, 255)', authorBackground: true, authorBorder: true, ...over }); + +test('native appearance fails buttons that keep the browser face', () => { + assert.equal(evaluateNativeAppearance({ controls: [control()] }).status, 'pass'); + const result = evaluateNativeAppearance({ controls: [control({ appearance: 'button', authorBackground: false })] }); + assert.equal(result.status, 'fail'); + assert.match(result.reason, /'); + const observed = await observeNativeAppearance(page.locator('body')); + assert.deepEqual(evaluateNativeAppearance(observed).measured.native.map((item) => item.name), ['B']); + await page.setContent('
'); + const images = await observeImages(page.locator('body')); + assert.equal(evaluateImagesLoaded(images).status, 'fail'); + } finally { await browser.close(); } +}); diff --git a/harness/runtime-audit/test/browser.test.mjs b/harness/runtime-audit/test/browser.test.mjs index d33140b4..dbee0480 100644 --- a/harness/runtime-audit/test/browser.test.mjs +++ b/harness/runtime-audit/test/browser.test.mjs @@ -8,7 +8,7 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { extname, join } from 'node:path'; import test from 'node:test'; -import { runPlan } from '../run.mjs'; +import { observeCompleteness, runPlan } from '../run.mjs'; const FIXTURES = new URL('../fixtures/', import.meta.url); const TYPES = { '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml' }; @@ -353,3 +353,72 @@ test('a page merged from two width frames is found by its per-width node ids', a await rm(out, { recursive: true, force: true }); } }); + + +test('computed button backgrounds and every image resource are observed in Chromium', async () => { + const { chromium } = await import('playwright'); + const { evaluateNoLowContrastText, evaluateImageLoaded, evaluateRenderedCompleteness } = await import('../checks.mjs'); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('**/missing.svg', (route) => route.fulfill({ status: 404, body: '' })); + const good = 'data:image/svg+xml,'; + await page.setContent(` +
+ + `); + const observed = await observeCompleteness(page, {}, 800); + assert.equal(evaluateNoLowContrastText({}, observed).status, 'fail'); + const buttonBackground = await page.locator('button').evaluate((element) => getComputedStyle(element).backgroundColor); + assert.equal(observed.texts[0].backgrounds[0], buttonBackground); + assert.notEqual(buttonBackground, 'rgba(0, 0, 0, 0)'); + assert.equal(observed.resources.length, 4); + assert.equal(observed.resources.filter((item) => !item.loaded).length, 3); + assert.equal(evaluateImageLoaded(observed).status, 'fail'); + assert.equal(evaluateRenderedCompleteness({ frameHeight: 100 }, observed).status, 'fail'); + await page.setContent(``); + const fixed = await observeCompleteness(page, {}, 800); + assert.equal(evaluateNoLowContrastText({}, fixed).status, 'pass'); + assert.equal(evaluateImageLoaded(fixed).status, 'pass'); + } finally { await browser.close(); } +}); + +test('white outline no-fill button overrides the reset and retains contrast over a dark hero', async () => { + const { chromium } = await import('playwright'); + const { evaluateNoLowContrastText } = await import('../checks.mjs'); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setContent(`
`); + const style = await page.locator('button').evaluate((element) => { + const css = getComputedStyle(element); + return { appearance: css.appearance, webkitAppearance: css.webkitAppearance, + background: css.backgroundColor, border: css.borderTopWidth, color: css.color }; + }); + assert.deepEqual(style, { appearance: 'none', webkitAppearance: 'none', + background: 'rgba(0, 0, 0, 0)', border: '1px', color: 'rgb(255, 255, 255)' }); + // Computed transparent alone did not establish what the native face painted. + // Sample the rendered padding, away from the white border and glyphs. + const pixels = await page.locator('button').screenshot(); + const paintedBackground = await page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + return [...context.getImageData(4, 4, 1, 1).data]; + }, pixels.toString('base64')); + assert.deepEqual(paintedBackground, [16, 24, 32, 255]); + assert.equal(evaluateNoLowContrastText({}, await observeCompleteness(page, {}, 800)).status, 'pass'); + await page.locator('.hero').evaluate((element) => { element.style.background = 'white'; }); + assert.equal(evaluateNoLowContrastText({}, await observeCompleteness(page, {}, 800)).status, 'fail'); + } finally { await browser.close(); } +}); diff --git a/harness/runtime-audit/test/checks.test.mjs b/harness/runtime-audit/test/checks.test.mjs index fcbf872d..280bbba1 100644 --- a/harness/runtime-audit/test/checks.test.mjs +++ b/harness/runtime-audit/test/checks.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { GEOMETRY_ROUNDING_PX, STATE_PROPERTIES, evaluateBounds, evaluateHidden, evaluateCheck, evaluateClippedText, evaluateDimensions, evaluateInteraction, - evaluatePageHealth, evaluateViewTransition, summarize, + evaluatePageHealth, evaluateNoLowContrastText, evaluateImageLoaded, evaluateRenderedCompleteness, evaluateViewTransition, summarize, textContrast, } from '../checks.mjs'; import { validateDimensionsPlan } from '../run.mjs'; @@ -160,3 +160,57 @@ test('production builds lose the audit attributes; dev and audit builds keep the assert.equal(production.transform(source, '/app/src/style.css'), null); assert.equal(keepsAttributes('build', {}), false); }); + +test('a rendered page is complete only when tall enough, with every section, image and visible text', () => { + const completeness = { frameHeight: 2000, sections: ['1:2', '1:3'], images: ['1:4'] }; + const shown = (id) => ({ id, count: 1, visible: true, width: 100, height: 50, broken: false }); + const dark = { text: 'Hello', tag: 'p', color: 'rgb(17, 17, 17)', backgrounds: ['rgba(0, 0, 0, 0)', 'rgb(255, 255, 255)'], imageBehind: false, opacity: 1 }; + const complete = { pageHeight: 1900, sections: [shown('1:2'), shown('1:3')], images: [shown('1:4')], texts: [dark] }; + assert.equal(evaluateRenderedCompleteness(completeness, complete).status, 'pass'); + + const blank = evaluateRenderedCompleteness(completeness, { pageHeight: 900, sections: [{ id: '1:2', count: 0 }, shown('1:3')], images: [{ ...shown('1:4'), broken: true }], texts: [] }); + assert.equal(blank.status, 'fail'); + assert.match(blank.reason, /900px tall, under 90% of the 2000px frame/); + assert.match(blank.reason, /1 of 2 sections not rendered: 1:2/); + assert.match(blank.reason, /1 images did not load: 1:4/); + + const whiteOnWhite = { ...dark, text: 'Ghost', color: 'rgb(255, 255, 255)' }; + const ghost = evaluateRenderedCompleteness(completeness, { ...complete, texts: [dark, whiteOnWhite] }); + assert.equal(ghost.status, 'fail'); + assert.match(ghost.reason, /"Ghost" \(1:1\)/); + assert.equal(textContrast({ ...whiteOnWhite, imageBehind: true }), null, 'text over an image is not judged'); + assert.ok(textContrast({ ...whiteOnWhite, backgrounds: ['rgb(0, 0, 0)'] }) > 20); + assert.ok(textContrast({ ...dark, opacity: 0 }) < 1.01, 'fully transparent text cannot be seen'); +}); + + +test('no-low-contrast-text composites ancestor backgrounds and translucent ink', () => { + const sample = { text: 'Submit', tag: 'button', color: 'rgb(255, 255, 255)', backgrounds: ['rgb(239, 239, 239)'] }; + const check = (text) => evaluateNoLowContrastText({}, { texts: [text] }); + assert.equal(check(sample).status, 'fail'); + assert.equal(check({ ...sample, backgrounds: ['rgba(0, 0, 0, 0)', 'rgb(0, 0, 0)'] }).status, 'pass'); + assert.equal(check({ ...sample, color: 'rgba(0, 0, 0, 0.01)' }).status, 'fail'); + assert.equal(check({ ...sample, imageBehind: true }).status, 'inconclusive'); + assert.equal(check({ ...sample, color: 'unsupported' }).status, 'inconclusive'); +}); + +test('image-loaded fails for any undecoded img or background resource', () => { + const resources = [{ source: '/ok.png', kind: 'img', loaded: true }, { source: '/hero.png', kind: 'background', loaded: false }]; + assert.equal(evaluateImageLoaded({ resources: resources.slice(0, 1) }).status, 'pass'); + const broken = evaluateImageLoaded({ resources }); + assert.equal(broken.status, 'fail'); + assert.match(broken.reason, /hero.png/); + assert.equal(evaluateImageLoaded({ resources: [{ ...resources[0], loaded: false }] }).status, 'fail'); +}); + + +test('contrast composites opacity of text and background together through ancestor groups', () => { + const sample = { color: 'rgb(255, 255, 255)', layers: [{ color: 'rgb(0, 0, 0)', opacity: 0.01 }] }; + assert.ok(textContrast(sample) < 1.5, 'a faded black button on white loses its white text'); + assert.ok(textContrast({ ...sample, layers: [{ color: 'rgb(0, 0, 0)', opacity: 1 }] }) > 20); + assert.ok(textContrast({ color: 'rgb(0, 0, 0)', layers: [{ color: 'rgba(0, 0, 0, 0)', opacity: 1 }, { color: 'rgb(255, 255, 255)', opacity: 1 }] }) > 20); +}); + +test('transparent backgrounds use the browser canvas colour', () => { + assert.ok(textContrast({ color: 'rgb(255, 255, 255)', canvasColor: 'rgb(0, 0, 0)', backgrounds: ['rgba(0, 0, 0, 0)'] }) > 20); +}); diff --git a/harness/runtime-audit/test/layout.test.mjs b/harness/runtime-audit/test/layout.test.mjs new file mode 100644 index 00000000..52ff23cd --- /dev/null +++ b/harness/runtime-audit/test/layout.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { evaluatePageHealth, evaluateTextAlignment, evaluateSequenceOrder, evaluateSectionLayout } from '../checks.mjs'; +import { observeSequence, observeSectionLayout, observeClippedContent, runPlan } from '../run.mjs'; + +test('health rejects narrow documents and records vinext error text', () => { + const healthy = { errorTexts: [], errors: [], viewportWidth: 858, documentWidth: 858 }; + assert.equal(evaluatePageHealth(healthy).status, 'pass'); + assert.match(evaluatePageHealth({ ...healthy, documentWidth: 700 }).reason, /700px wide in a 858px viewport/); + assert.match(evaluatePageHealth({ ...healthy, errorTexts: ["This page couldn't load"] }).reason, /couldn't load/); +}); + +test('sequence order follows rows and rejects collapsed column DOM order', () => { + const group = [1, 3, 2, 4].map((number, index) => ({ number, name: `card ${number}`, x: 0, y: index * 100 })); + assert.match(evaluateSequenceOrder({ groups: [group] }).reason, /card 3 before card 2/); + assert.equal(evaluateSequenceOrder({ groups: [group.map((item) => ({ ...item, x: item.number % 2 ? 0 : 200, y: item.number <= 2 ? 0 : 100 }))] }).status, 'pass'); +}); + +test('collapsed sections, hidden sections and squashed text fail', () => { + const section = { name: 'hero', visible: true, width: 390, height: 300, containerWidth: 390 }; + assert.equal(evaluateSectionLayout({ sections: [section], texts: [] }).status, 'pass'); + assert.match(evaluateSectionLayout({ sections: [{ ...section, width: 150 }], texts: [] }).reason, /hero/); + assert.equal(evaluateSectionLayout({ sections: [{ ...section, visible: false }], texts: [] }).status, 'fail'); + assert.match(evaluateSectionLayout({ sections: [], texts: [{ name: 'description', width: 30 }] }).reason, /description/); +}); + +test('browser observes numbered column collapse and squashed sections at each width', async () => { + const { chromium } = await import('playwright'); + const browser = await chromium.launch({ headless: true }); + try { + for (const width of [390,858,1100,1440]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + await page.setContent('
01
03
02
04
'); + assert.equal(evaluateSequenceOrder(await observeSequence(page)).status, 'fail'); + assert.equal(evaluateSectionLayout(await observeSectionLayout(page)).status, 'fail'); + await page.setContent('
STEP 1STEP 2STEP 3
'); + assert.equal(evaluateSequenceOrder(await observeSequence(page)).status, 'pass'); + await page.close(); + } + } finally { await browser.close(); } +}); + +test('internal nav and thumbnail clipping fails even without page overflow', async () => { + const { chromium } = await import('playwright'); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 858, height: 900 } }); + try { + await page.setContent('
Devup UI / Vespera
'); + const observed = await observeClippedContent(page); + assert.deepEqual(observed.clipped.map((item) => item.name), ['nav','tech']); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 858); + await page.setContent('
Thumbnails
'); + assert.equal((await observeClippedContent(page)).clipped.length, 0); + } finally { await browser.close(); } +}); + +test('text alignment follows Figma even when its text box stays full width', () => { + assert.equal(evaluateTextAlignment({ edge: 'center' }, { count: 1, visible: true, align: 'left', direction: 'ltr' }).status, 'fail'); + assert.equal(evaluateTextAlignment({ edge: 'center' }, { count: 1, visible: true, align: 'center', direction: 'ltr' }).status, 'pass'); +}); + +test('runner executes the new checks at every required width', async () => { + const { createServer } = await import('node:http'); + const { mkdtemp, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const server = createServer((request, response) => { + response.setHeader('content-type', 'text/html; charset=utf-8'); + response.end(`
01
03
02

This page couldn't load

Title

Devup UI / Vespera
`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const dir = await mkdtemp(join(tmpdir(), 'devup-layout-')); + try { + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const path = join(dir, 'plan.json'); + const checks = ['page-health','sequence-order','section-layout','no-clipped-content','no-low-contrast-text','image-loaded'].map((kind) => ({ kind, label: kind })); + checks.push({ kind: 'text-align', label: 'title alignment', subject: { testId: 'title' }, edge: 'center' }); + await writeFile(path, JSON.stringify({ schemaVersion: 1, kind: 'devup-runtime-audit-plan', planId: 'layout', plan: { baseUrl, allowedOrigins: [baseUrl], viewports: [390,858,1100,1440].map((width) => ({ width, height: 900, deviceScaleFactor: 1 })), screens: [{ id: 'home', routePath: '/', checks }] } })); + const report = await runPlan({ plan: path, out: join(dir, 'out'), maxMinutes: 1 }); + for (const run of report.runs) { + for (const check of run.screens[0].checks) assert.equal(check.status, 'fail', `${run.viewport.width}: ${JSON.stringify(check)}`); + const health = run.screens[0].checks.find((check) => check.kind === 'page-health'); + assert.match(health.reason, /couldn't load/); + assert.match(health.reason, /300px wide/); + } + } finally { + await new Promise((resolve) => server.close(resolve)); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/test/real-render.test.mjs b/harness/runtime-audit/test/real-render.test.mjs new file mode 100644 index 00000000..2f94bfba --- /dev/null +++ b/harness/runtime-audit/test/real-render.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { execFile, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { build, preview } from 'vite'; +import { DevupUI } from '@devup-ui/vite-plugin'; +import { verifyRender } from '../verify-render.mjs'; + +const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repo = resolve(harness, '../..'); +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const execute = promisify(execFile); + +async function cli(args) { + // Keep the Vite preview server's event loop free while Chromium visits it. + try { + return { status: 0, ...await execute(process.execPath, [join(harness, 'verify-render.mjs'), ...args], { windowsHide: true, timeout: 60_000 }) }; + } catch (error) { + return { status: error.code, stdout: error.stdout, stderr: error.stderr }; + } +} + +function cargo(args) { + const result = spawnSync('cargo', args, { cwd: repo, encoding: 'utf8', windowsHide: true, timeout: 300_000 }); + assert.equal(result.status, 0, `${args.join(' ')}\n${result.error ?? ''}\n${result.stderr}`); + return result.stdout; +} + +test('production codegen renders in Chromium; geometry alone cannot pass broken pixels or assets', { timeout: 360_000 }, async () => { + const outRoot = join(harness, 'out'); + await mkdir(outRoot, { recursive: true }); + const dir = await mkdtemp(join(outRoot, 'real-render-')); + let server; + try { + cargo(['run', '--quiet', '-p', 'devup-mcp-devup-ui', '--example', 'render_fixture', '--', + join(harness, 'fixtures/render-snapshot.json'), join(dir, 'Generated.tsx')]); + cargo(['build', '--quiet', '-p', 'devup-mcp-visual']); + const target = JSON.parse(cargo(['metadata', '--format-version', '1', '--no-deps'])).target_directory; + const visualBin = join(target, 'debug', process.platform === 'win32' ? 'devup-mcp-visual.exe' : 'devup-mcp-visual'); + await mkdir(join(dir, 'public/images'), { recursive: true }); + const hero = await readFile(join(harness, 'fixtures/render-hero.png')); + await writeFile(join(dir, 'public/images/Hero.png'), hero); + await writeFile(join(dir, 'devup.json'), JSON.stringify({ theme: {} })); + await writeFile(join(dir, 'index.html'), '
'); + await writeFile(join(dir, 'main.tsx'), `import '@devup-ui/reset-css'; +import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; +import { RenderFixture } from './Generated'; +flushSync(() => createRoot(document.getElementById('root')!).render()); +const mode = new URLSearchParams(location.search).get('mode'); +const style = document.createElement('style'); +style.textContent = mode === 'missing-background' ? '[data-figma-node-id="1:1"] img{visibility:hidden!important}' + : mode === 'wrong-color' ? '[data-figma-node-id="1:2"]{background:#00ff00!important}' + : ''; +if (mode === 'unloaded-asset') document.querySelector('img')!.src = '/missing.png'; +document.head.append(style);`); + const config = { root: dir, configFile: false, logLevel: 'error', plugins: [DevupUI()], build: { outDir: 'dist' }, preview: { host: '127.0.0.1', port: 0 } }; + await build(config); + server = await preview(config); + const baseUrl = `http://127.0.0.1:${server.httpServer.address().port}`; + const reference = join(harness, 'fixtures/render-reference.png'); + const references = join(dir, 'references.json'); + await writeFile(references, JSON.stringify({ references: [{ screen: 'hero', width: 320, height: 240, + path: reference, sha256: sha256(await readFile(reference)), threshold: 0.005 }] })); + for (const mode of ['correct', 'missing-background', 'wrong-color', 'unloaded-asset']) { + const plan = join(dir, `${mode}.json`); + await writeFile(plan, JSON.stringify({ schemaVersion: 1, kind: 'devup-runtime-audit-plan', planId: mode, plan: { + baseUrl, allowedOrigins: [baseUrl], viewports: [{ width: 320, height: 240, deviceScaleFactor: 1 }], + readiness: { selector: '[data-figma-node-id="1:2"]' }, + screens: [{ id: 'hero', routePath: `/?mode=${mode}`, checks: [ + { kind: 'bounds', subject: { css: '[data-figma-node-id="1:1"]' }, container: { css: 'body' }, expected: { x: 0, y: 0, width: 320, height: 240 } }, + { kind: 'image-loaded' }, { kind: 'page-health' }, + ] }], + } })); + const output = join(dir, mode); + const command = await cli(['--plan', plan, '--references', references, '--out', output, '--visual-bin', visualBin]); + assert.equal(command.status, mode === 'correct' ? 0 : 1, `${mode}: ${command.stderr}\n${command.stdout}`); + const result = JSON.parse(await readFile(join(output, 'real-render.json'), 'utf8')); + const runtime = JSON.parse(await readFile(join(output, 'report.json'), 'utf8')); + assert.equal(runtime.runs[0].screens[0].checks[0].status, 'pass', JSON.stringify(runtime)); + assert.equal(result.verdict, mode === 'correct' ? 'pass' : 'fail', JSON.stringify(result)); + assert.equal(result.comparisons[0].status, mode === 'correct' ? 'exact' : 'mismatch', JSON.stringify(result)); + assert.ok((await readFile(join(output, 'visual-diff-0.png'))).length > 0); + const images = runtime.runs[0].screens[0].checks[1]; + assert.equal(images.status, mode === 'unloaded-asset' ? 'fail' : 'pass'); + } + const plan = join(dir, 'correct.json'); + const valid = JSON.parse(await readFile(references, 'utf8')); + await writeFile(references, JSON.stringify({ references: [valid.references[0], valid.references[0]] })); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'duplicate-reference'), visualBin }), /one Figma reference/); + valid.references[0].sha256 = '0'.repeat(64); + await writeFile(references, JSON.stringify(valid)); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'changed-reference'), visualBin }), /hash differs/); + await writeFile(references, '{"references":[]}'); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'missing-reference'), visualBin }), /manifest is empty/); + const invalid = await cli(['--plan', plan, '--references', references, '--out', join(dir, 'invalid'), '--visual-bin', visualBin]); + assert.equal(invalid.status, 2, invalid.stderr); + } finally { + if (server) await new Promise((done) => server.httpServer.close(done)); + // Preserve browser evidence on request for local diagnosis and CI uploads. + if (!process.env.DEVUP_KEEP_RENDER_EVIDENCE) await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/verify-render.mjs b/harness/runtime-audit/verify-render.mjs new file mode 100644 index 00000000..c33cf7d4 --- /dev/null +++ b/harness/runtime-audit/verify-render.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// A capture is evidence, not a visual verdict. Run Chromium, then compare every +// planned screen/viewport against an independently acquired, pinned Figma PNG. +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { runPlan } from './run.mjs'; + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +function compare(binary, args) { + return new Promise((resolveResult, reject) => { + const child = spawn(binary, args, { windowsHide: true }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { stdout += data; }); + child.stderr.on('data', (data) => { stderr += data; }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0 && code !== 1) return reject(new Error(`visual comparator exited ${code}: ${stderr}`)); + try { resolveResult({ exitCode: code, ...JSON.parse(stdout) }); } catch (error) { reject(error); } + }); + }); +} + +export async function verifyRender({ plan, references, out, visualBin, maxMinutes = 10 }) { + const document = JSON.parse(await readFile(plan, 'utf8')); + const manifest = JSON.parse(await readFile(references, 'utf8')); + const entries = manifest.references; + if (!Array.isArray(entries) || !entries.length) throw new Error('Figma reference manifest is empty'); + const expected = document.plan.screens.flatMap((screen) => document.plan.viewports.map((viewport) => ({ screen: screen.id, viewport }))); + if (entries.length !== expected.length) throw new Error('Require one Figma reference per planned screen and viewport'); + // Validate and pin all originals before opening the browser. Never derive an + // expected image from the generated page or silently accept missing widths. + const originals = []; + for (const screen of document.plan.screens) { + for (const kind of ['page-health', 'image-loaded']) { + if (!screen.checks.some((check) => check.kind === kind && !check.widths?.length)) { + throw new Error(`Screen ${screen.id} requires ${kind} at every viewport`); + } + } + } + for (const { screen, viewport } of expected) { + const matches = entries.filter((entry) => entry.screen === screen && entry.width === viewport.width && entry.height === viewport.height); + if (matches.length !== 1) throw new Error(`Require exactly one Figma reference for ${screen} ${viewport.width}x${viewport.height}`); + const entry = matches[0]; + if (viewport.deviceScaleFactor != null && viewport.deviceScaleFactor !== 1) throw new Error('Figma comparison requires deviceScaleFactor 1'); + if (!Number.isFinite(entry.threshold) || entry.threshold < 0 || entry.threshold >= 1) throw new Error('Reference threshold must be in [0, 1)'); + const path = resolve(dirname(references), entry.path); + if (!/^[a-f0-9]{64}$/.test(entry.sha256) || hash(await readFile(path)) !== entry.sha256) throw new Error(`Figma reference hash differs: ${path}`); + originals.push({ ...entry, path }); + } + const runtime = await runPlan({ plan, out, maxMinutes }); + const comparisons = []; + for (const [index, original] of originals.entries()) { + const run = runtime.runs.find((item) => item.viewport.width === original.width && item.viewport.height === original.height); + const screen = run?.screens.find((item) => item.id === original.screen); + if (!screen?.screenshot) { + comparisons.push({ screen: original.screen, width: original.width, status: 'not-run' }); + continue; + } + const actual = resolve(out, screen.screenshot.path); + if (hash(await readFile(actual)) !== screen.screenshot.sha256) throw new Error(`Screenshot hash differs: ${actual}`); + const result = await compare(visualBin, ['compare', '--reference', original.path, '--actual', actual, + '--diff', resolve(out, `visual-diff-${index}.png`), '--max-changed-ratio', String(original.threshold)]); + comparisons.push({ screen: original.screen, width: original.width, referenceSha256: original.sha256, actual, ...result }); + } + const passed = runtime.status === 'completed' + && runtime.runs.length === document.plan.viewports.length + && runtime.runs.every((run) => !run.environmentErrors.length && run.screens.length === document.plan.screens.length + && run.screens.every((screen) => screen.status === 'completed' && screen.checks.every((check) => check.status === 'pass'))) + && comparisons.every((item) => item.exitCode === 0 && ['exact', 'within-threshold'].includes(item.status)); + const result = { verdict: passed ? 'pass' : 'fail', runtimeReport: join(out, 'report.json'), comparisons }; + await writeFile(join(out, 'real-render.json'), `${JSON.stringify(result, null, 2)}\n`); + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const options = {}; + const names = { '--plan': 'plan', '--references': 'references', '--out': 'out', '--visual-bin': 'visualBin' }; + for (let index = 2; index < process.argv.length; index += 2) { + const name = names[process.argv[index]]; + if (!name || !process.argv[index + 1]) throw new Error('usage: node verify-render.mjs --plan --references --out --visual-bin '); + options[name] = resolve(process.argv[index + 1]); + } + if (Object.keys(options).length !== 4) throw new Error('plan, references, out and visual-bin are required'); + const result = await verifyRender(options); + console.log(`${result.verdict}: ${result.comparisons.length} real-browser comparisons`); + process.exitCode = result.verdict === 'pass' ? 0 : 1; + } catch (error) { + console.error(String(error?.message ?? error)); + process.exitCode = 2; + } +} diff --git a/plugin/dist/ui.html b/plugin/dist/ui.html index f25c4264..b887a5cb 100644 --- a/plugin/dist/ui.html +++ b/plugin/dist/ui.html @@ -45,4 +45,4 @@ padding-top: 12px; border-top: 1px solid #e6e6e6; color: #8c8c8c; - }

Devup Bridge

이 창을 열어 두면 devup-mcp 가 이 파일을 직접 읽습니다.

시작하는 중…

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 나갑니다.

\ No newline at end of file + }

Devup Bridge

이 창을 열어 두면 devup-mcp 가 이 파일을 직접 읽습니다.

시작하는 중…

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 나갑니다.

\ No newline at end of file diff --git a/plugin/rspack.config.js b/plugin/rspack.config.js index 1c79c880..d87cd8c9 100644 --- a/plugin/rspack.config.js +++ b/plugin/rspack.config.js @@ -4,9 +4,12 @@ import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' +import { readFileSync } from 'node:fs' +import { DefinePlugin } from '@rspack/core' import HtmlWebpackPlugin from 'html-webpack-plugin' const here = dirname(fileURLToPath(import.meta.url)) +const pluginVersion = JSON.parse(readFileSync(resolve(here, 'package.json'), 'utf8')).version /** @type {import('@rspack/core').Configuration[]} */ export default [ @@ -58,6 +61,7 @@ export default [ ], }, plugins: [ + new DefinePlugin({ __PLUGIN_VERSION__: JSON.stringify(pluginVersion) }), new HtmlWebpackPlugin({ template: resolve(here, 'src/ui.html'), filename: 'ui.html', diff --git a/plugin/scripts/gen-scripts.mjs b/plugin/scripts/gen-scripts.mjs index 24a56db0..d6049649 100644 --- a/plugin/scripts/gen-scripts.mjs +++ b/plugin/scripts/gen-scripts.mjs @@ -9,7 +9,9 @@ // 그래서 감싸기만 하면 그대로 동작한다. eval 은 쓰지 않는다 — Figma 플러그인 // 샌드박스에서 동적 코드 실행은 막혀 있고, 번들러가 정적으로 볼 수 있어야 한다. -import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -145,6 +147,32 @@ export declare const SCRIPTS: Record< ` mkdirSync(dirname(outFile), { recursive: true }) -writeFileSync(`${outFile}.js`, js, 'utf8') -writeFileSync(`${outFile}.d.ts`, dts, 'utf8') +// Several cargo/Node tests generate these wrappers concurrently. Readers +// must see a complete module, never the empty file between truncate/write. +async function publish(path, content) { + try { + if (readFileSync(path, 'utf8') === content) return + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp` + try { + writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx' }) + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(temporary, path) + break + } catch (error) { + // Windows readers and concurrent replacements can briefly hold the + // destination open. Retry the rename without truncating that file. + if (attempt >= 40 || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error + await delay(25) + } + } + } finally { + rmSync(temporary, { force: true }) + } +} +await publish(`${outFile}.js`, js) +await publish(`${outFile}.d.ts`, dts) console.log(`generated ${files.length} scripts -> ${outFile}.{js,d.ts}`) diff --git a/plugin/scripts/gen-scripts.test.mjs b/plugin/scripts/gen-scripts.test.mjs new file mode 100644 index 00000000..4c113ef2 --- /dev/null +++ b/plugin/scripts/gen-scripts.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { appendFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const generator = fileURLToPath(new URL('./gen-scripts.mjs', import.meta.url)); +const moduleUrl = new URL('../src/generated/scripts.js', import.meta.url); +const generate = async () => { + const child = spawn(process.execPath, [generator], { stdio: ['ignore', 'ignore', 'pipe'] }); + let error = ''; + child.stderr.on('data', (data) => { error += data; }); + const [code] = await once(child, 'exit'); + assert.equal(code, 0, error); +}; + +test('concurrent generation always publishes a complete importable module', async () => { + await generate(); + appendFileSync(moduleUrl, '\n// Force concurrent republishing of an otherwise complete module.\n'); + const outcomes = await Promise.allSettled([ + ...Array.from({ length: 6 }, generate), + (async () => { + for (let index = 0; index < 40; index += 1) { + const { SCRIPTS } = await import(`${moduleUrl.href}?read=${index}`); + assert.equal(typeof SCRIPTS?.assets, 'function'); + await new Promise((resolve) => setTimeout(resolve, 2)); + } + })(), + ]); + for (const result of outcomes) if (result.status === 'rejected') throw result.reason; +}); diff --git a/plugin/src/ui.html b/plugin/src/ui.html index 98b07fa1..451daeba 100644 --- a/plugin/src/ui.html +++ b/plugin/src/ui.html @@ -62,6 +62,7 @@

Devup Bridge

시작하는 중…
+

읽기 전용입니다. 문서를 바꾸지 않고, 데이터는 같은 기기의 devup-mcp 로만 diff --git a/plugin/src/ui.ts b/plugin/src/ui.ts index d1a509ea..cb0ccc6f 100644 --- a/plugin/src/ui.ts +++ b/plugin/src/ui.ts @@ -41,7 +41,15 @@ interface ResultMessage { error?: string } +declare const __PLUGIN_VERSION__: string + +interface ServerInfo { + version: string + buildId: string | null +} + const RETRY_MS = 2000 +let serverInfo: ServerInfo | null = null /** 이 창이 화면에 보이는지. Figma 가 가려지거나 최소화되면 거짓이 된다. */ const inView = () => document.visibilityState === 'visible' @@ -67,10 +75,14 @@ let served = 0 const dot = document.getElementById('dot') as HTMLElement const line = document.getElementById('line') as HTMLElement const detail = document.getElementById('detail') as HTMLElement +const versions = document.getElementById('versions') as HTMLElement function render(state: 'on' | 'off', text: string) { dot.className = `dot ${state}` line.textContent = text + versions.textContent = `플러그인 ${__PLUGIN_VERSION__} · ${ + serverInfo ? `devup-mcp ${serverInfo.version}` : 'devup-mcp 버전 미확인' + }` detail.textContent = bridgeStatus ? `${bridgeStatus.fileName} · 처리 ${served}건` : '파일 정보를 읽는 중…' @@ -100,6 +112,7 @@ function connect() { JSON.stringify({ kind: 'hello', sessionId: SESSION_ID, + pluginVersion: __PLUGIN_VERSION__, fileKey: bridgeStatus?.fileKey ?? null, fileName: bridgeStatus?.fileName ?? '', currentPage: bridgeStatus?.currentPage ?? null, @@ -122,12 +135,19 @@ function connect() { } catch { return } + const info = job as { kind?: string; version?: string; buildId?: string | null } + if (info?.kind === 'server-info') { + serverInfo = { version: String(info.version ?? ''), buildId: info.buildId ?? null } + render('on', 'devup-mcp 에 연결됨') + return + } // 작업은 그대로 메인 스레드로 넘긴다. UI 는 내용을 해석하지 않는다. parent.postMessage({ pluginMessage: job }, '*') } ws.onclose = () => { socket = null + serverInfo = null render('off', 'devup-mcp 연결 대기 중…') setTimeout(connect, RETRY_MS) }