use super::*; use crate::hosted::client::Secret; fn client() -> Connection { Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap() } fn send(app: &mut App, event: Event) { let _ = app.update(Message::Hosted(event)); } fn rename(app: &mut App, name: &str) { app.document .execute(Command::Rename(app.project().id, name.into())) .unwrap(); app.sync(); } fn bind(app: &mut App) -> Id { let resource = Id::new_v4(); app.hosted.session = Some(Session { connection: client(), resource_id: resource, revision: 1, etag: "\"opaque-base\"".into(), }); resource } fn remote(snapshot: Arc, resource_id: Id, revision: u64) -> Remote { Remote { resource_id, revision, etag: format!("\"opaque-{revision}\""), compiled: snapshot, } } fn complete(app: &mut App, result: Result) { let r = app.hosted.request.as_ref().unwrap(); send(app, Event::Completed(r.id, r.document, result)); } fn failure(status: Option, uncertain: bool) -> Failure { Failure { detail: "Test failure".into(), code: "test".into(), status, uncertain, published: None, } } #[test] fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() { let mut app = App::with_project(Project::empty()); rename(&mut app, "Published revision"); app.path = Some(PathBuf::from("local.arcadia")); send(&mut app, Event::Publish); let captured = app.hosted.request.as_ref().unwrap().snapshot.clone(); assert!(app.busy); rename(&mut app, "Later local edit"); let resource = Id::new_v4(); complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1)))); assert!(!app.busy); assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource); assert!(app.path.is_none()); assert_eq!(app.project().name, "Later local edit"); assert!(app.document.dirty()); let _ = app.update(Message::Undo); assert_eq!(app.project().name, "Published revision"); assert!(!app.document.dirty()); } #[test] fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() { let mut app = App::with_project(Project::empty()); let resource = bind(&mut app); let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap(); other .execute(Command::Rename(other.project().id, "Other engineer".into())) .unwrap(); rename(&mut app, "Local engineering work"); let local = app.document.snapshot(); send(&mut app, Event::Save); complete(&mut app, Err(failure(Some(412), false))); assert_eq!( app.hosted.request.as_ref().unwrap().operation, Operation::Check ); assert!(Arc::ptr_eq(&local, &app.document.snapshot())); complete( &mut app, Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))), ); assert!(Arc::ptr_eq(&local, &app.document.snapshot())); assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\""); send(&mut app, Event::Save); assert!(!app.busy); assert!(app.hosted.request.is_none()); send(&mut app, Event::Reload); assert_eq!(app.project().name, "Other engineer"); assert!(!app.document.dirty()); let _ = app.update(Message::Undo); assert_eq!(app.project().name, "Local engineering work"); assert!(app.document.dirty()); } #[test] fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() { let mut app = App::with_project(Project::empty()); let resource = bind(&mut app); rename(&mut app, "Saved despite lost response"); send(&mut app, Event::Save); let captured = app.hosted.request.as_ref().unwrap().snapshot.clone(); complete(&mut app, Err(failure(None, true))); assert!(app.document.dirty()); assert!(!app.busy); assert!(app.hosted.request.is_none()); send(&mut app, Event::Check); complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2)))); assert!(!app.document.dirty()); assert!(app.hosted.uncertain.is_none()); assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2); } #[test] fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() { let mut app = App::with_project(Project::empty()); bind(&mut app); send(&mut app, Event::Save); let request = app.hosted.request.as_ref().unwrap().clone(); send( &mut app, Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))), ); assert!(app.busy); assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id); app.reset(Project::empty(), None); let current = app.document.snapshot(); send( &mut app, Event::Completed( request.id, request.document, Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))), ), ); assert!(Arc::ptr_eq(¤t, &app.document.snapshot())); assert!(app.hosted.session.is_none()); } #[test] fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() { let mut app = App::with_project(Project::empty()); bind(&mut app); app.source_workspace .edit("arcadia 5; ?".into(), app.source_editor.cursor()); let draft = app.source_workspace.bundle(); let accepted = app.document.snapshot(); send(&mut app, Event::Save); assert!(app.hosted.request.is_none()); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); assert_eq!(app.source_workspace.bundle(), draft); assert!(!app.busy); } #[test] fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() { let mut app = App::with_project(Project::empty()); rename(&mut app, "Keep this work"); app.hosted.connection = Some(client()); app.hosted.selected = Some(Id::new_v4()); send(&mut app, Event::OpenSelected); assert!(matches!(app.pending, Some(Pending::HostedOpen))); assert!(app.hosted.request.is_none()); let _ = app.update(Message::Discard); let previous = app.document.snapshot(); complete(&mut app, Err(failure(Some(401), false))); assert!(Arc::ptr_eq(&previous, &app.document.snapshot())); assert_eq!(app.project().name, "Keep this work"); assert!(app.document.dirty()); } #[test] fn hosted_only_successful_source_save_as_changes_the_active_destination() { let mut app = App::with_project(Project::empty()); let resource = bind(&mut app); rename(&mut app, "Local fallback"); let failure = SaveFailure { document: app.canvas_document, message: "Disk failed".into(), recovery: None, }; let _ = app.update(Message::Saved(Err(failure))); assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource); let directory = tempfile::tempdir().unwrap(); let target = directory.path().join(&app.document.bundle().entry); let receipt = write_snapshot( app.canvas_document, app.document.revision(), app.document.snapshot(), target.clone(), None, ) .unwrap(); let _ = app.update(Message::Saved(Ok(Some(receipt)))); assert!(app.hosted.session.is_none()); assert_eq!(app.path, Some(target)); assert!(!app.document.dirty()); assert!(app.document.can_undo()); } #[test] fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() { let mut app = App::with_project(Project::empty()); send(&mut app, Event::Show); let original = app.document.snapshot(); let _ = app.update(Message::Command(Command::Rename( app.project().id, "Underlying edit".into(), ))); assert!(Arc::ptr_eq(&original, &app.document.snapshot())); let token = "a-secret-never-in-a-diagnostic"; let event = Message::Hosted(Event::Token(Secret(token.into()))); assert!(!format!("{event:?}").contains(token)); let _ = app.update(event); send(&mut app, Event::Forget); assert!(app.hosted.token.0.is_empty()); } #[test] fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() { let mut app = App::with_project(Project::empty()); let mut bundle = app.document.bundle().clone(); bundle .files .insert("detached.arcadia".into(), "// kept draft".into()); let reachable = app.document.bundle().files.keys().cloned().collect(); app.source_workspace.discovered(&bundle, reachable); app.source_workspace .switch("detached.arcadia", app.source_editor.cursor()); app.source_workspace.edit( "// unsaved detached rationale".into(), app.source_editor.cursor(), ); send(&mut app, Event::Publish); assert!(app.hosted.request.is_none()); assert!(app.source_workspace.detached_dirty()); assert_eq!( app.source_workspace.files["detached.arcadia"].text, "// unsaved detached rationale" ); app.pending_recovery = Some(PathBuf::from("interrupted.arcadia")); send(&mut app, Event::Publish); assert!(app.hosted.request.is_none()); assert_eq!( app.pending_recovery, Some(PathBuf::from("interrupted.arcadia")) ); } #[test] fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() { let mut app = App::with_project(Project::empty()); let old_id = bind(&mut app); app.hosted.base = "http://127.0.0.1:9876".into(); rename(&mut app, "New publication"); send(&mut app, Event::Publish); let new_id = Id::new_v4(); let mut error = failure(None, true); error.published = Some(new_id); complete(&mut app, Err(error)); send(&mut app, Event::Check); let request = app.hosted.request.as_ref().unwrap(); assert_eq!(request.resource_id, Some(new_id)); assert_eq!(request.connection.base(), "http://127.0.0.1:9876/"); let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap(); changed .execute(Command::Rename( changed.project().id, "Changed after publication".into(), )) .unwrap(); complete( &mut app, Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))), ); assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id); send(&mut app, Event::Reload); let session = app.hosted.session.as_ref().unwrap(); assert_eq!(session.resource_id, new_id); assert_eq!(session.connection.base(), "http://127.0.0.1:9876/"); assert_eq!(app.project().name, "Changed after publication"); } async fn dispatch(app: &mut App, message: Message) { use iced::futures::StreamExt; let mut tasks = vec![app.update(message)]; while let Some(task) = tasks.pop() { if let Some(mut stream) = iced_runtime::task::into_stream(task) { while let Some(action) = stream.next().await { if let iced_runtime::Action::Output(message) = action { tasks.push(app.update(message)); } } } } } #[tokio::test] async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() { let directory = tempfile::tempdir().unwrap(); let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap()); let token = "current-secret-for-hosted-regression-0123456789"; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let (stop, done) = tokio::sync::oneshot::channel::<()>(); let server = tokio::spawn(async move { axum::serve(listener, oa_server::app(store, Some(token.into()))) .with_graceful_shutdown(async { let _ = done.await; }) .await .unwrap(); }); let mut app = App::with_project(Project::empty()); let mut bundle = app.document.bundle().clone(); bundle .files .get_mut(&bundle.entry) .unwrap() .insert_str(0, "// Hosted rationale\n"); app.document.apply_bundle(bundle).unwrap(); app.sync(); app.hosted.base = base.clone(); app.hosted.token = Secret(token.into()); dispatch(&mut app, Message::Hosted(Event::Publish)).await; let session = app .hosted .session .clone() .expect("publish binds a resource"); assert!(!app.busy); assert!(!app.document.dirty()); assert!( app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale") ); let project_id = app.project().id; dispatch( &mut app, Message::Command(Command::Rename(project_id, "Graphical update".into())), ) .await; dispatch(&mut app, Message::Save(false)).await; let external = Connection::new(&base, Secret(token.into())).unwrap(); let remote = external.open(session.resource_id).await.unwrap(); assert_eq!(remote.compiled.decoded.project.name, "Graphical update"); let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap(); other .execute(Command::Rename(project_id, "API engineering change".into())) .unwrap(); external .save(session.resource_id, &remote.etag, other.bundle().clone()) .await .unwrap(); dispatch( &mut app, Message::Command(Command::Rename(project_id, "Local pending change".into())), ) .await; dispatch(&mut app, Message::Save(false)).await; assert!(app.hosted.review.is_some()); assert_eq!(app.project().name, "Local pending change"); assert!(app.document.dirty()); // Simulate a retired credential in every preexisting reconciliation handle. let old = Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap(); app.hosted.session.as_mut().unwrap().connection = old.clone(); app.hosted.uncertain.as_mut().unwrap().connection = old.clone(); app.hosted.review.as_mut().unwrap().connection = old; dispatch(&mut app, Message::Hosted(Event::Connect)).await; let reconciled_write = app.hosted.uncertain.clone().unwrap(); // Reload the captured review directly. A fresh Check must not hide a stale // credential in that review by replacing it with a newer response. dispatch(&mut app, Message::Hosted(Event::Reload)).await; assert_eq!(app.project().name, "API engineering change"); dispatch(&mut app, Message::Undo).await; assert_eq!(app.project().name, "Local pending change"); dispatch(&mut app, Message::Save(false)).await; assert!(!app.document.dirty()); // Independently exercise the corrected uncertain-write connection. app.hosted.uncertain = Some(reconciled_write); dispatch(&mut app, Message::Hosted(Event::Check)).await; assert!(app.hosted.error.is_none()); assert!(app.hosted.uncertain.is_none()); let accepted = external.open(session.resource_id).await.unwrap(); assert_eq!(accepted.compiled.bundle, *app.document.bundle()); assert_eq!( accepted.compiled.decoded.project.name, "Local pending change" ); let mut second = App::with_project(Project::empty()); second.hosted.base = base; second.hosted.token = Secret(token.into()); dispatch(&mut second, Message::Hosted(Event::Connect)).await; assert!( second .hosted .catalog .iter() .any(|item| item.resource_id == session.resource_id) ); send(&mut second, Event::Select(session.resource_id)); dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await; assert_eq!(second.document.bundle(), app.document.bundle()); assert_eq!( second.hosted.session.as_ref().unwrap().resource_id, session.resource_id ); dispatch( &mut second, Message::Command(Command::Rename(project_id, "Saved before New".into())), ) .await; dispatch(&mut second, Message::Request(Pending::New)).await; assert!(second.pending.is_some()); dispatch(&mut second, Message::SaveAndContinue).await; assert!(second.hosted.session.is_none()); assert!(second.pending.is_none()); assert_ne!(second.project().id, project_id); assert_eq!( external .open(session.resource_id) .await .unwrap() .compiled .decoded .project .name, "Saved before New" ); drop(external); drop(app); drop(second); let _ = stop.send(()); tokio::time::timeout(std::time::Duration::from_secs(5), server) .await .unwrap() .unwrap(); } #[tokio::test] async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() { let directory = tempfile::tempdir().unwrap(); let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let (stop, done) = tokio::sync::oneshot::channel::<()>(); let server = tokio::spawn(async move { axum::serve(listener, oa_server::app(store, None)) .with_graceful_shutdown(async { let _ = done.await; }) .await .unwrap(); }); let mut app = App::with_project(Project::empty()); app.hosted.base = base.clone(); dispatch(&mut app, Message::Hosted(Event::Publish)).await; let session = app.hosted.session.clone().unwrap(); let accepted = app.document.snapshot(); let original_identity = app.canvas_document; let external = Connection::new(&base, Secret::default()).unwrap(); let old = external.open(session.resource_id).await.unwrap(); let local = tempfile::tempdir().unwrap(); let path = local.path().join("repair.arcadia"); let invalid = "arcadia 5; ? // unfinished local work"; std::fs::write(&path, invalid).unwrap(); let path = path.canonicalize().unwrap(); dispatch( &mut app, Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))), ) .await; assert_ne!(app.canvas_document, original_identity); assert_eq!(app.path.as_ref(), Some(&path)); assert!(!app.source_workspace.applied); assert_eq!(app.source_editor.text(), invalid); assert!( Arc::ptr_eq(&accepted, &app.document.snapshot()), "A repair workspace must preserve the last valid graphical model" ); assert!(!app.source_diagnostics.is_empty()); assert!( app.hosted.session.is_none(), "The installed local repair workspace must own its save destination" ); let mut repaired = Project::empty(); repaired.name = "Independent local repair".into(); let repaired_source = language::format(&repaired); dispatch( &mut app, Message::SourceEdit(text_editor::Action::SelectAll), ) .await; dispatch( &mut app, Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( Arc::new(repaired_source), ))), ) .await; dispatch(&mut app, Message::ApplySource).await; assert_eq!(app.project(), &repaired); assert!(!app.source_dirty()); dispatch(&mut app, Message::Save(false)).await; let remote_after = external.open(session.resource_id).await.unwrap(); assert_eq!( remote_after.etag, old.etag, "Saving a repaired local workspace must never target the old hosted resource" ); assert_eq!(remote_after.compiled.bundle, old.compiled.bundle); assert!(app.hosted.session.is_none()); assert!(app.hosted.review.is_none()); assert!(app.hosted.uncertain.is_none()); assert!(app.hosted.request.is_none()); assert_eq!(app.path.as_ref(), Some(&path)); assert!(!app.dirty()); let saved = source_files::load_project(&path, LIMITS).unwrap(); assert_eq!(saved.reachable_bundle(), *app.document.bundle()); assert_eq!( app.hosted.base, base, "Connection settings remain available in memory" ); drop(external); drop(app); let _ = stop.send(()); tokio::time::timeout(std::time::Duration::from_secs(5), server) .await .unwrap() .unwrap(); } #[test] fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() { let mut app = App::with_project(Project::empty()); let resource = bind(&mut app); let accepted = app.document.snapshot(); let identity = app.canvas_document; let source = app.source_workspace.bundle(); let directory = tempfile::tempdir().unwrap(); let unreadable = directory.path().join("missing.json"); let error = read_snapshot(unreadable).unwrap_err(); for result in [Ok(None), Err(error)] { let _ = app.update(Message::Loaded(result)); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); assert_eq!(app.canvas_document, identity); assert_eq!(app.source_workspace.bundle(), source); assert!(app.path.is_none()); assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource); } }