fix(desktop): detach hosted destination for invalid local source

This commit is contained in:
2026-09-06 08:07:47 +03:00
parent 09b7ebddc4
commit 2c0f93839f
2 changed files with 119 additions and 0 deletions
+4
View File
@@ -3064,6 +3064,10 @@ impl App {
}
}
Err(error) => {
// Installing a local repair workspace changes its destination,
// even while the last valid graphical model stays visible. A
// failed/cancelled Open that installs nothing retains its binding.
self.hosted.leave_document();
self.canvas_document = Id::new_v4();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.path = Some(path);
+115
View File
@@ -443,3 +443,118 @@ async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reloa
.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);
}
}