// Native static-store integration test uses blocking socket deadlines. #![allow(clippy::disallowed_types, clippy::disallowed_methods)] use makepad_asset_client::json::{obj, s, Value}; use makepad_asset_client::{ BaseUrl, BlobContent, ClientConfig, ClientError, ClientEvent, ClientOutput, ClientRequest, ClientRuntime, MemoryCacheStore, OwnedRequest, OwnedResponse, StaticStore, StaticStoreEvent, ClientLocation, SessionConfig, SessionConnector, SessionMsg, Transport, TransportCompletion, TransportError, TransportId, MAX_STATIC_MANIFEST_BYTES, }; use makepad_asset_data::*; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; struct ExportFixture { routes: BTreeMap>, asset: AssetId, revision: AssetRevisionId, game_revision: GameRevisionId, alias: AssetAlias, blob: BlobId, blob_bytes: Vec, thumbnail: BlobId, } fn hex(bytes: &[u8]) -> String { let mut out = String::new(); for byte in bytes { use std::fmt::Write; let _ = write!(out, "{byte:02x}"); } out } struct MusicFixtureRow { asset: AssetId, alias: AssetAlias, revision: AssetRevisionId, audio: BlobId, audio_bytes: Vec, manifest_bytes: Vec, document: Value, search: Value, } fn music_fixture_row( base: &AssetManifest, seed: u8, alias: &str, title: &str, creator: &str, ) -> MusicFixtureRow { let asset = AssetId::from_bytes([seed; 16]); let alias = AssetAlias::from_str(alias).unwrap(); let audio_bytes = format!("fixture mp3 {seed}").into_bytes(); let audio = BlobId::hash_of(&audio_bytes); let mut manifest = base.clone(); manifest.asset_id = asset; manifest.kind = AssetKind::Audio; manifest.files = vec![AssetFile { role: FileRole::Audio, tier: DeviceTier::Any, lod: 0, media: MediaType::Mp3, blob: audio, byte_len: audio_bytes.len() as u64, dims: None, }]; manifest.thumbnail = None; manifest.metrics = Metrics { total_bytes: audio_bytes.len() as u64, media_millis: 1_000, ..Default::default() }; manifest.bounds = Bounds { min: Vec3::ZERO, max: Vec3::ZERO }; manifest.capabilities.loopable = true; manifest.rights.credits = creator.to_string(); manifest.canonicalize(); manifest.validate().unwrap(); let manifest_bytes = manifest.to_canonical_bytes().unwrap(); let revision = AssetRevisionId::hash_of(&manifest_bytes); let document = obj(vec![ ("asset_id", s(asset.to_string())), ("kind", s("audio")), ("files", Value::Arr(vec![obj(vec![ ("role", s("audio")), ("tier", s("any")), ("lod", Value::Int(0)), ("media", s("mp3")), ("blob", s(audio.to_string())), ("byte_len", Value::Int(audio_bytes.len() as i64)), ])])), ("dependencies", Value::Arr(vec![])), ]); let mut terms = title .split(|ch: char| !ch.is_ascii_alphanumeric()) .filter(|term| !term.is_empty()) .map(str::to_ascii_lowercase) .collect::>(); terms.sort(); terms.dedup(); let search = obj(vec![ ("asset_id", s(asset.to_string())), ("namespace", s("music")), ("kind", s("audio")), ("title", s(title)), ("description", s("CC0 music fixture")), ("categories", Value::Arr(vec![s("music")])), ("tags", Value::Arr(vec![s("music"), s("stems")])), ("creator", s(creator)), ("artist", s(creator)), ("artist_url", s("https://freemusicarchive.org/artist/fixture")), ("album", s("FMA Fixture")), ("source_url", s(format!("https://freemusicarchive.org/music/{seed}"))), ("license", s("CC BY 4.0")), ("license_url", s("https://creativecommons.org/licenses/by/4.0/")), ("generator", s("makepad-dj-pack")), ("backend", s("fixture")), ("model", s("fixture")), ("live", Value::Bool(true)), ("updated_ms", Value::Int(1_700_000_000_000 + seed as i64)), ("aliases", Value::Arr(vec![s(alias.to_string())])), ("terms", Value::Arr(terms.into_iter().map(|term| obj(vec![ ("term", s(term)), ("weight", Value::Int(10)), ])).collect())), ]); MusicFixtureRow { asset, alias, revision, audio, audio_bytes, manifest_bytes, document, search, } } fn fixture() -> ExportFixture { let asset = AssetId::from_bytes([7; 16]); let alias = AssetAlias::from_str("stock/fixture/box").unwrap(); let blob_bytes = b"fixture glb payload".to_vec(); let thumbnail_bytes = b"fixture png payload".to_vec(); let blob = BlobId::hash_of(&blob_bytes); let thumbnail = BlobId::hash_of(&thumbnail_bytes); let manifest = AssetManifest { asset_id: asset, kind: AssetKind::Prop, files: vec![AssetFile { role: FileRole::RenderGlb, tier: DeviceTier::Any, lod: 0, media: MediaType::Glb, blob, byte_len: blob_bytes.len() as u64, dims: None, }], dependencies: Vec::new(), thumbnail: Some(ThumbnailMeta { blob: thumbnail, media: ThumbnailMedia::Png, width: 512, height: 512, byte_len: thumbnail_bytes.len() as u64, views: Vec::new(), }), metrics: Metrics { total_bytes: (blob_bytes.len() + thumbnail_bytes.len()) as u64, triangles: 12, vertices: 8, joints: 0, clips: 0, max_texture_dim: 0, media_millis: 0, }, coordinate_system: CoordinateSystem { units_per_meter: 1.0, up: Axis::YPos, forward: Axis::ZNeg, pivot: Pivot::Origin, }, bounds: Bounds { min: Vec3::new(-1.0, -1.0, -1.0), max: Vec3::new(1.0, 1.0, 1.0) }, anchors: Vec::new(), capabilities: Capabilities { rigged: false, animated: false, collidable: false, loopable: false, spawnable: false, }, spawn_recipe: None, provenance: None, rights: Rights { license: "CC0-1.0".into(), license_revision: String::new(), terms_digest: None, terms_url: String::new(), credits: "fixture".into(), source: String::new(), source_archive: None, redistribution: Redistribution::Allowed, derivatives: DerivativePolicy::Allowed, }, }; manifest.validate().unwrap(); let manifest_bytes = manifest.to_canonical_bytes().unwrap(); let revision = AssetRevisionId::hash_of(&manifest_bytes); let game_manifest = GameRevisionManifest { game_id: GameId::from_bytes([8; 16]), name: "Fixture Game".into(), description: "Static game manifest fixture".into(), author: "Fixture Author".into(), splash_blob: BlobId::hash_of(b"game splash"), manifest_blob: BlobId::hash_of(b"game manifest"), lock_blob: BlobId::hash_of(b"game lock"), thumbnail: ThumbnailMeta { blob: thumbnail, media: ThumbnailMedia::Png, width: 512, height: 512, byte_len: thumbnail_bytes.len() as u64, views: Vec::new(), }, catalog_snapshot: None, search_algorithm_version: 1, engine_version: 1, protocol_version: 1, splash_byte_len: 11, }; let game_bytes = game_manifest.to_canonical_bytes().unwrap(); let game_revision = GameRevisionId::hash_of(&game_bytes); let health = obj(vec![ ("server_id", s("11111111111111111111111111111111")), ("protocol_version", Value::Int(makepad_asset_client::wire::PROTOCOL_VERSION as i64)), ]).to_json().into_bytes(); let detail = obj(vec![ ("asset_id", s(asset.to_string())), ("namespace", s("stock")), ("retired", Value::Bool(false)), ("retired_ms", Value::Null), ("candidates", Value::Arr(vec![])), ]).to_json().into_bytes(); let alias_bytes = obj(vec![ ("alias", s(alias.to_string())), ("asset_id", s(asset.to_string())), ("head_revision", s(revision.to_string())), ]).to_json().into_bytes(); let music_rows = vec![ music_fixture_row( &manifest, 8, "music/marsel-minga/drum-machine-battle", "Drum Machine Battle", "Marsel Minga", ), music_fixture_row( &manifest, 9, "music/wilfredor-sample-dance-1/wilfredor-sample-dance-1", "Wilfredor Sample Dance 1", "Wilfredor", ), ]; let mut routes = BTreeMap::from([ ("/v1/health".into(), health), (format!("/v1/assets/{asset}"), detail), (format!("/v1/aliases/{alias}"), alias_bytes), (format!("/v1/revisions/{revision}"), manifest_bytes), (format!("/v1/game-revisions/{game_revision}"), game_bytes), (format!("/v1/blobs/{blob}"), blob_bytes.clone()), (format!("/v1/blobs/{thumbnail}"), thumbnail_bytes.clone()), (format!("/v1/thumbnails/revision/{revision}"), thumbnail_bytes), (format!("/v1/thumbnails/alias/{alias}"), routes_placeholder()), ]); // Alias and revision thumbnail routes are byte-identical in a real export. *routes.get_mut(&format!("/v1/thumbnails/alias/{alias}")).unwrap() = routes[&format!("/v1/blobs/{thumbnail}")].clone(); for row in &music_rows { routes.insert( format!("/v1/assets/{}", row.asset), obj(vec![ ("asset_id", s(row.asset.to_string())), ("namespace", s("music")), ("retired", Value::Bool(false)), ("retired_ms", Value::Null), ("candidates", Value::Arr(vec![])), ]).to_json().into_bytes(), ); routes.insert( format!("/v1/aliases/{}", row.alias), obj(vec![ ("alias", s(row.alias.to_string())), ("asset_id", s(row.asset.to_string())), ("head_revision", s(row.revision.to_string())), ]).to_json().into_bytes(), ); routes.insert( format!("/v1/revisions/{}", row.revision), row.manifest_bytes.clone(), ); routes.insert(format!("/v1/blobs/{}", row.audio), row.audio_bytes.clone()); } let files = Value::Arr(routes.iter().map(|(path, bytes)| obj(vec![ ("path", s(path.clone())), ("byte_len", Value::Int(bytes.len() as i64)), ("sha256", s(hex(&sha256(bytes)))), ("content_type", s("application/octet-stream")), ("content_encoding", Value::Null), ])).collect()); let document = obj(vec![ ("asset_id", s(asset.to_string())), ("kind", s("prop")), ("files", Value::Arr(vec![obj(vec![ ("role", s("render_glb")), ("tier", s("any")), ("lod", Value::Int(0)), ("media", s("glb")), ("blob", s(blob.to_string())), ("byte_len", Value::Int(blob_bytes.len() as i64)), ])])), ("dependencies", Value::Arr(vec![])), ("thumbnail", obj(vec![ ("blob", s(thumbnail.to_string())), ("media", s("png")), ("width", Value::Int(512)), ("height", Value::Int(512)), ("byte_len", Value::Int(routes[&format!("/v1/blobs/{thumbnail}")].len() as i64)), ])), ]); let mut assets = vec![obj(vec![ ("asset_id", s(asset.to_string())), ("namespace", s("stock")), ("created_ms", Value::Int(1_700_000_000_000)), ("revisions", Value::Arr(vec![s(revision.to_string())])), ])]; let mut aliases = vec![obj(vec![ ("alias", s(alias.to_string())), ("asset_id", s(asset.to_string())), ("head_revision", s(revision.to_string())), ("updated_ms", Value::Int(1_700_000_000_001)), ])]; let mut revisions = vec![obj(vec![ ("revision", s(revision.to_string())), ("document", document), ])]; let mut search_documents = vec![obj(vec![ ("asset_id", s(asset.to_string())), ("namespace", s("stock")), ("kind", s("prop")), ("title", s("Fixture Box")), ("description", s("A public fixture box")), ("categories", Value::Arr(vec![s("fixture")])), ("tags", Value::Arr(vec![s("public")])), ("creator", s("Fixture Author")), ("generator", s("fixture")), ("backend", s("fixture")), ("model", s("fixture")), ("live", Value::Bool(true)), ("updated_ms", Value::Int(1_700_000_000_001)), ("aliases", Value::Arr(vec![s(alias.to_string())])), ("terms", Value::Arr(vec![ obj(vec![("term", s("box")), ("weight", Value::Int(5))]), obj(vec![("term", s("fixture")), ("weight", Value::Int(10))]), ])), ])]; let mut blobs = vec![ blob_value(blob, blob_bytes.len() as u64), blob_value(thumbnail, routes[&format!("/v1/blobs/{thumbnail}")].len() as u64), ]; for row in &music_rows { assets.push(obj(vec![ ("asset_id", s(row.asset.to_string())), ("namespace", s("music")), ("created_ms", Value::Int(1_700_000_000_000 + row.asset.as_bytes()[0] as i64)), ("revisions", Value::Arr(vec![s(row.revision.to_string())])), ])); aliases.push(obj(vec![ ("alias", s(row.alias.to_string())), ("asset_id", s(row.asset.to_string())), ("head_revision", s(row.revision.to_string())), ("updated_ms", Value::Int(1_700_000_000_000 + row.asset.as_bytes()[0] as i64)), ])); revisions.push(obj(vec![ ("revision", s(row.revision.to_string())), ("document", row.document.clone()), ])); search_documents.push(row.search.clone()); blobs.push(blob_value(row.audio, row.audio_bytes.len() as u64)); } assets.sort_by_key(Value::to_json); aliases.sort_by_key(Value::to_json); revisions.sort_by_key(Value::to_json); search_documents.sort_by_key(Value::to_json); blobs.sort_by_key(Value::to_json); let unique_blob_bytes = blobs.iter().map(|value| { value.get("byte_len").and_then(Value::as_u64).unwrap() }).sum::(); let static_manifest = obj(vec![ ("static_version", Value::Int(1)), ("protocol_version", Value::Int(makepad_asset_client::wire::PROTOCOL_VERSION as i64)), ("snapshot_id", s("22222222222222222222222222222222")), ("server_id", s("11111111111111111111111111111111")), ("generated_ms", Value::Int(1_700_000_000_000)), ("assets", Value::Arr(assets)), ("aliases", Value::Arr(aliases)), ("revisions", Value::Arr(revisions)), ("search", obj(vec![ ("normalization", s("ascii-alnum-lower-v1")), ("ranking", s("public-weight-sum-v1")), ("documents", Value::Arr(search_documents)), ])), ("variants", Value::Arr(vec![])), ("blobs", Value::Arr(blobs)), ("files", files), ("policy", obj(vec![ ("namespace", Value::Null), ("kind", Value::Null), ("limit", Value::Null), ("max_bytes_per_asset", Value::Null), ("max_total_bytes", Value::Null), ("include_video_up_to", Value::Int(32 * 1024 * 1024)), ])), ("totals", obj(vec![ ("assets", Value::Int(3)), ("aliases", Value::Int(3)), ("revisions", Value::Int(3)), ("blobs_present", Value::Int(4)), ("blobs_omitted", Value::Int(0)), ("unique_blob_bytes", Value::Int(unique_blob_bytes as i64)), ])), ("exclusions", obj(vec![ ("rights", Value::Int(0)), ("budget", Value::Int(0)), ("kind_mismatch", Value::Int(0)), ])), ]).to_json().into_bytes(); routes.insert("/v1/static/manifest.json".into(), static_manifest); ExportFixture { routes, asset, revision, game_revision, alias, blob, blob_bytes, thumbnail, } } fn routes_placeholder() -> Vec { Vec::new() } fn blob_value(blob: BlobId, byte_len: u64) -> Value { obj(vec![ ("blob", s(blob.to_string())), ("path", s(format!("/v1/blobs/{blob}"))), ("byte_len", Value::Int(byte_len as i64)), ("sha256", s(hex(blob.as_bytes()))), ("present", Value::Bool(true)), ("reason", Value::Null), ("media", Value::Arr(vec![])), ("roles", Value::Arr(vec![])), ]) } struct StaticServer { addr: SocketAddr, stopping: Arc, requests: Arc>>, join: Option>, } fn socket_test_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() } impl StaticServer { fn start(routes: BTreeMap>) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let addr = listener.local_addr().unwrap(); let stopping = Arc::new(AtomicBool::new(false)); let requests = Arc::new(Mutex::new(Vec::new())); let stop = stopping.clone(); let log = requests.clone(); let join = std::thread::spawn(move || while !stop.load(Ordering::Acquire) { match listener.accept() { Ok((mut stream, _)) => serve(&mut stream, &routes, &log), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => std::thread::sleep(Duration::from_millis(1)), Err(_) => break, } }); Self { addr, stopping, requests, join: Some(join) } } } impl Drop for StaticServer { fn drop(&mut self) { self.stopping.store(true, Ordering::Release); let _ = TcpStream::connect(self.addr); if let Some(join) = self.join.take() { let _ = join.join(); } } } fn serve(stream: &mut TcpStream, routes: &BTreeMap>, log: &Mutex>) { stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); let mut request = Vec::new(); let mut buf = [0u8; 4096]; while !request.windows(4).any(|window| window == b"\r\n\r\n") { let Ok(read) = stream.read(&mut buf) else { return }; if read == 0 { return; } request.extend_from_slice(&buf[..read]); } let head = String::from_utf8_lossy(&request); assert!(!head.to_ascii_lowercase().contains("authorization:")); let path = head.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("/"); log.lock().unwrap().push(path.to_string()); let (status, body) = routes.get(path).map(|body| ("200 OK", body.as_slice())) .unwrap_or(("404 Not Found", b"missing")); let response = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); let _ = stream.write_all(response.as_bytes()); let _ = stream.write_all(body); } fn ready(runtime: &mut ClientRuntime) { let deadline = Instant::now() + Duration::from_secs(10); while !runtime.is_ready() { let _ = runtime.poll(); if let Some(error) = runtime.connect_error() { panic!("static connect failed: {error}"); } assert!(Instant::now() < deadline, "static connect timeout"); std::thread::sleep(Duration::from_millis(2)); } } fn complete(runtime: &mut ClientRuntime, request: ClientRequest) -> Result { let id = runtime.submit(request).unwrap(); let deadline = Instant::now() + Duration::from_secs(10); loop { for event in runtime.poll() { match event { ClientEvent::Done { id: found, output } if found == id => return Ok(output), ClientEvent::Failed { id: found, error } if found == id => return Err(error), _ => {} } } assert!(Instant::now() < deadline, "static request timeout"); std::thread::sleep(Duration::from_millis(2)); } } #[test] fn static_snapshot_accepts_the_vj_music_query() { let fixture = fixture(); let transport = MockTransport { next: 1, routes: fixture.routes, ready: VecDeque::new(), truncated_manifest: false, }; let mut store = StaticStore::start( BaseUrl::parse("https://static.example").unwrap(), Box::new(transport), Box::new(MemoryCacheStore::new(1024 * 1024)), ).unwrap(); for _ in 0..4 { let _ = store.poll(); if store.is_ready() { break; } } assert!(store.is_ready()); // Exact query emitted by BrowseModel