makepad/libs/flow/tests/host_assets.rs
Admin 83a00d2801 flow: archive generated assets and add live source editing
Host or attach the asset service, archive generated and terminal outputs, and provide image/video/audio/text/3D viewers with fullscreen and gallery navigation. Load only visible bounded previews, generate video posters and text excerpts, and normalize legacy thumbnails off the UI thread.

Expose video frame counts and Sandbox chat providers, make failed queue cleanup idempotent, compact the panels, and correct short-wire direction. Replace App view with a movable, resizable CodeEditor overlay with debounced source saves, live graph updates, invalid-source preservation and close/reopen geometry.

Validation: release Flow, Flow UI and flowgraph suites passed 335 tests (two ignored). Updated Flow UI suite passed 107 tests; asset route suites passed nine tests after legacy preview fixes. The rebuilt release app passed remote editing, dragging, resizing, invalid-source, close/reopen and App removal checks; final All assets scrolling measured 16.5 ms median and 18.3 ms maximum over 12 inputs. Test instances were closed through the remote protocol.
2026-09-05 01:47:53 +02:00

203 lines
8.7 KiB
Rust

#![cfg(not(target_arch = "wasm32"))]
use makepad_asset_client::{ApiEndpoints, PublishFile, PublishRequest, PublishThumbnail};
use makepad_asset_data::{AssetAlias, AssetKind, FileRole, MediaType, ThumbnailMedia};
use makepad_flow::client::{ClientError, Endpoints, FlowClient};
use makepad_flow::host::{FlowServer, FlowServerConfig};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempRoot(PathBuf);
impl TempRoot {
fn new(label: &str) -> Self {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("flow-assets-route-{}-{label}-{nonce}", std::process::id()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
}
impl Drop for TempRoot { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } }
fn png() -> Vec<u8> {
makepad_ai_hub::testpattern::encode_png_rgba(&vec![180; 256 * 256 * 4], 256, 256).unwrap()
}
#[test]
fn proxy_lists_and_serves_flow_asset_thumbnail() {
let store_root = TempRoot::new("store");
let flow_root = TempRoot::new("flow");
let mut store_config = makepad_asset_store::ServerConfig::new(store_root.0.clone());
store_config.control_addr = "127.0.0.1:0".parse().unwrap();
store_config.data_addr = "127.0.0.1:0".parse().unwrap();
store_config.bootstrap_admin = true;
store_config.discovery = None;
store_config.log = false;
let mut store = makepad_asset_store::AssetServer::start(store_config).unwrap();
let token = std::fs::read_to_string(store_root.0.join("admin-token")).unwrap().trim().to_string();
let endpoints = ApiEndpoints { control: store.control_addr(), data: store.data_addr() };
let mut client_config = makepad_asset_client::ClientConfig::new(store_root.0.join("seed-cache"));
client_config.token = Some(token.clone());
let mut asset_client = makepad_asset_client::AssetClient::connect(client_config, endpoints, Some(store.server_id())).unwrap();
let image = png();
let mut request = PublishRequest::new(
"flows",
AssetKind::Texture,
"Route picture",
PublishFile { bytes: image.clone(), media: MediaType::Png, role: FileRole::Texture, media_millis: 0, dims: Some((256, 256)) },
PublishThumbnail::plain(image.clone(), ThumbnailMedia::Png, 256, 256),
);
request.alias = Some(AssetAlias::new("flows/route-picture").unwrap());
request.tags = vec!["flow".into(), "route".into()];
asset_client.publish_artifact(&request).unwrap();
let large_image = makepad_ai_hub::testpattern::encode_png_rgba(
&vec![180; 1024 * 512 * 4],
1024,
512,
)
.unwrap();
let mut oversized = PublishRequest::new(
"flows",
AssetKind::Texture,
"Oversized preview",
PublishFile {
bytes: large_image.clone(),
media: MediaType::Png,
role: FileRole::Texture,
media_millis: 0,
dims: Some((1024, 512)),
},
PublishThumbnail::plain(large_image, ThumbnailMedia::Png, 1024, 512),
);
oversized.alias = Some(AssetAlias::new("flows/oversized-preview").unwrap());
let oversized_id = asset_client.publish_artifact(&oversized).unwrap().asset_id;
let mut tagged = PublishRequest::new(
"elsewhere",
AssetKind::Texture,
"Tagged picture",
PublishFile {
bytes: image.clone(),
media: MediaType::Png,
role: FileRole::Texture,
media_millis: 0,
dims: Some((256, 256)),
},
PublishThumbnail::plain(image.clone(), ThumbnailMedia::Png, 256, 256),
);
tagged.alias = Some(AssetAlias::new("elsewhere/tagged-picture").unwrap());
tagged.tags = vec!["flow".into()];
asset_client.publish_artifact(&tagged).unwrap();
let mut unaliased = PublishRequest::new(
"flows",
AssetKind::Data,
"Unaliased data",
PublishFile {
bytes: b"original asset bytes".to_vec(),
media: MediaType::Bin,
role: FileRole::Source,
media_millis: 0,
dims: None,
},
PublishThumbnail::plain(image.clone(), ThumbnailMedia::Png, 256, 256),
);
unaliased.alias = None;
let unaliased_id = asset_client.publish_artifact(&unaliased).unwrap().asset_id;
drop(asset_client);
let mut flow_config = FlowServerConfig::new(flow_root.0.clone());
flow_config.log = Box::new(|_| {});
flow_config.asset.endpoints = Some(endpoints);
flow_config.asset.server_id = Some(store.server_id());
flow_config.asset.token = Some(token);
let flow_server = FlowServer::start(flow_config).unwrap();
let served = flow_server.endpoints();
let flow_client = FlowClient::connect(
Endpoints { control: served.control, data: served.data },
served.token.clone(),
Some(served.server_id),
).unwrap();
let rows = flow_client.assets("Route", Some("flows"), 10).unwrap().assets;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].alias.as_deref(), Some("flows/route-picture"));
assert_eq!(rows[0].tags, vec!["flow", "route"]);
let default_rows = flow_client.assets("", Some("flows"), 10).unwrap().assets;
assert_eq!(default_rows.len(), 4);
assert!(default_rows.iter().any(|asset| asset.namespace == "elsewhere"));
let mut page = flow_client.assets_page("", None, 1, None).unwrap();
assert_eq!(page.assets.len(), 1);
let first_cursor = page.cursor.clone().expect("more than one catalog result");
let mut ids = std::collections::HashSet::new();
let mut pages = 0;
loop {
pages += 1;
assert!(pages <= 5, "pagination failed to advance");
for row in &page.assets { assert!(ids.insert(row.id.clone()), "duplicate result on a later page"); }
let Some(cursor) = page.cursor else { break };
page = flow_client.assets_page("", None, 1, Some(&cursor)).unwrap();
}
assert_eq!(ids.len(), 4);
assert!(flow_client.assets_page("different", None, 1, Some(&first_cursor)).is_err());
let thumb = flow_client.asset_thumbnail("flows/route-picture").unwrap();
assert_eq!(thumb.content_type, "image/png");
assert_eq!(thumb.bytes.as_ref(), image.as_slice());
let content = flow_client.asset_content(&unaliased_id.to_string()).unwrap();
assert_eq!(content.content_type, "application/octet-stream");
assert_eq!(content.bytes.as_ref(), b"original asset bytes");
let preview = flow_client.asset_preview(&unaliased_id.to_string()).unwrap();
assert_eq!(preview.content_type, "text/plain; charset=utf-8");
assert_eq!(preview.bytes.as_ref(), "Binary data · 20 bytes".as_bytes());
let oversized_preview = flow_client.asset_preview(&oversized_id.to_string()).unwrap();
assert_eq!(oversized_preview.content_type, "image/png");
assert!(oversized_preview.bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(
u32::from_be_bytes(oversized_preview.bytes[16..20].try_into().unwrap()),
256
);
assert_eq!(
u32::from_be_bytes(oversized_preview.bytes[20..24].try_into().unwrap()),
128
);
let thumb_by_id = flow_client.asset_thumbnail(&unaliased_id.to_string()).unwrap();
assert_eq!(thumb_by_id.content_type, "image/png");
assert_eq!(thumb_by_id.bytes.as_ref(), image.as_slice());
let mut wrong_token = served.token.clone();
let last = wrong_token.pop().unwrap();
wrong_token.push(if last == '0' { '1' } else { '0' });
let unauthorized = FlowClient::connect(
Endpoints { control: served.control, data: served.data },
wrong_token,
Some(served.server_id),
);
assert!(matches!(unauthorized, Err(ClientError::Unauthorized)));
assert!(matches!(
flow_client.asset_content("not/an-asset-id"),
Err(ClientError::Protocol(_))
));
assert!(matches!(
flow_client.asset_thumbnail("../bad"),
Err(ClientError::Protocol(_))
));
flow_server.shutdown();
store.shutdown();
}
#[test]
fn proxy_reports_no_discovered_store_and_retries_per_request() {
let root = TempRoot::new("missing");
let mut config = FlowServerConfig::new(root.0.clone());
config.log = Box::new(|_| {});
config.asset.discovery_wait_ms = 0;
let server = FlowServer::start(config).unwrap();
let served = server.endpoints();
let client = FlowClient::connect(
Endpoints { control: served.control, data: served.data },
served.token,
Some(served.server_id),
).unwrap();
for _ in 0..2 {
let error = client.assets("", Some("flows"), 10).unwrap_err();
assert!(matches!(error, ClientError::Http { status: 503, .. }));
assert!(error.to_string().contains("no asset server discovered on this LAN"));
}
server.shutdown();
}