diff --git a/app/application.ml b/app/application.ml index 6b82a95..b8a2e35 100644 --- a/app/application.ml +++ b/app/application.ml @@ -3474,7 +3474,7 @@ let manager_page ?picker_signal state dispatch = then "Deleting local copy…" else ( match presence owner with - | Some (Ok Graph_service.Not_downloaded) -> "Not downloaded" + | Some (Ok Graph_service.Not_downloaded) -> "Download and open" | Some (Error _) -> "Unable to check local copy" | _ -> "") in @@ -3761,6 +3761,10 @@ let manager_page ?picker_signal state dispatch = then V.column ~spacing:12. [ actions; choose_graph ] else actions in + let actions = + V.of_lui + (Lui_elements.row ~gap:0 ~main:`center ~cross:`center [ Ui.mount actions ]) + in unavailable ~title:"Unable to open graph" ~symbol:"questionmark.folder" diff --git a/docs/agent-guide/implemented/bugfix/2026-10-21-2026-10-21-bootstrap-direct-actions.md b/docs/agent-guide/implemented/bugfix/2026-10-21-2026-10-21-bootstrap-direct-actions.md new file mode 100644 index 0000000..3edadea --- /dev/null +++ b/docs/agent-guide/implemented/bugfix/2026-10-21-2026-10-21-bootstrap-direct-actions.md @@ -0,0 +1,85 @@ +# Direct explicit bootstrap actions + +## Problem + +On main 6a3695c, selecting a graph marked Not downloaded enters an expected local-mirror failure page and requires a second Download graph tap. After canceling authentication and closing the sheet, Sign in again opens another explanation sheet that requires Continue before the system browser. Both were reproduced on the isolated 1446 Simulator, with before screenshots 10/11 and 03/01 outside the repository. + +## Decision + +Track an explicit graph selection until local mirror inspection confirms absence, then request scoped native online permission before continuing download. Keep automatic recent-graph restore local-only, wait for native permission and verified identity before network effects, retain key/password validation, cancellation and bounded failures. Launch native reauthentication once when the user chooses Sign in again, while retaining the initial explanation, visible busy/cancel/error UI and account preparation. + +The user explicitly authorized these two narrow fixes, before/after screenshots and a ready PR. Repeated Retry controls, title overlap and unrelated UX are excluded. No spec or Dune edit is planned. + +## Alternatives considered + +### Automatically retry every local failure + +Would conflate missing data, corrupt storage and missing keys, and weaken explicit network admission. Only confirmed absent mirrors after user graph selection qualify. + +### Permit networking for every graph selection + +Would make cached graph switches network-dependent before Timeline. Preserve the existing local path for available mirrors. + +## Acceptance criteria + +- A selected missing mirror requests scoped online permission without the error/Download intermediate page; it performs no remote request before identity confirmation. +- Automatic restore, available local mirrors, missing local keys and arbitrary failures retain existing protections and do not auto-retry. +- Cancellation, stale completions and switching selections cannot continue the retired download. +- Sign in again starts authentication with one action and retains cancel, return, error retry and account preparation. +- Same-baseline synthetic Simulator evidence records before/after clicks; successful production login/download remains unverified without user action. +- Independent review and final-head CI/comments are checked before handoff; create a ready PR, do not merge or deploy. + +## Risks + +- Permission can race graph-generation changes; the pure owner must retain and consume only current explicit selection intent. Native automatic authentication must run only for an explicit retry presentation, once per presentation, without restarting after cancel or view recomputation. Experimental host-restamp retains cross_abi_validated=false. + +## Questions + +None. The user has authorized the concrete scope and the existing host-restamp test route. + +## Consequences + +The pure core retains a graph-scope selection intent only for explicit selection, +consumes it on current mirror absence, and waits for native permission and +verified identity before key recovery or snapshot effects. Automatic recent-graph +restore and missing-key handling keep their local-only behavior. Cancellation +clears the intent, stale scope completions cannot revive it, and real failures +remain bounded with explicit Retry. The picker labels the action Download and open. + +An explicit Sign in again presentation starts authentication once. The initial +presentation still explains the secure sign-in flow; busy Cancel, Close, native +cancellation and visible errors remain. A failed attempt waits for manual retry. +No authentication, encryption, account-preparation or storage guard is bypassed. + +The user additionally identified the error-page recovery actions being off-center. +The shared empty-state composite puts actions inside a row with leading main-axis +alignment; a centered inner column does not center that group in the offered width. +Only the Failed route wraps its existing action group in a native main=center +row, preserving action order and handlers. Pure sync events cannot reproduce pixel +layout; the actual one-action native screenshot reproduces the defect and a +temporary screenshot assertion checks the narrowest rendering boundary. Single and +multiple actions, a 320-point offered viewport and accessibility text sizing are +verified on the same isolated device. The narrow fixture offers the actual native +view 320 points, rather than cropping a screenshot. Accessibility-medium retains +centering and actionable full semantic labels; visible button text ellipsizes at +that size, which this layout-only correction does not change. The original large +text-size setting was restored. Shared components are unchanged. + +Four regressions extend the existing public pure-core suite, with expected RED +failures before the change and 84 core cases passing afterward. The Swift owner +model suite passes. The native presentation defect cannot originate at the pure +OCaml boundary; its actual SwiftUI retry assertion failed on the baseline and +passed on the fixed synthetic host. Four existing graph-picker presentation +expectations were updated for the new action label; their 17 existing cases pass. +No duplicate UI regression cases were added. Independent review found no definite issue. + +Same-condition UI evidence on the existing isolated iOS 26.1 Simulator shows +both actions reduced from two clicks to one. Cancel/Close/retry, synthetic +network failures, existing local graphs, restart, background return, picker +cancel and missing-key protection passed. A six-page before/after report was +delivered separately through Library; all screenshots contain synthetic data. +Fresh native objects were produced for the modified source before building the +experimental Simulator host; cross_abi_validated remains false. Real server +login/download success, password unlock, network restoration and download +interruption recovery remain unverified. Repeated Retry controls and title +layout are outside this decision. diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index b98ec2e..4ed15c6 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -800,6 +800,7 @@ type t = ; graph_key : graph_key_handle option ; pending_graph_open : mirror_request option ; pending_mirror_inspection : mirror_request option + ; pending_selected_download : graph_scope option ; pending_attachment : mirror_request option ; pending_encrypted_graph_key : string option ; pending_private_key_package : string option @@ -875,6 +876,7 @@ let initial config = ; selected_graph_value = None ; deletion_scope = None ; current_graph_scope = None + ; pending_selected_download = None ; connection_generation = 0 ; graph_key = None ; pending_graph_open = None @@ -990,6 +992,7 @@ let fail core stage message = ; pull_wanted = None ; pull_retry_delay = 1. ; pending_mirror_inspection = None + ; pending_selected_download = None ; pending_attachment = None ; pending_encrypted_graph_key = None ; pending_private_key_package = None @@ -1198,6 +1201,7 @@ let authenticate_new_account core user_id = ; deletion_scope = None ; cached_selected_graph = None ; current_graph_scope = None + ; pending_selected_download = None ; graph_key = None ; pending_graph_open = None ; pending_mirror_inspection = None @@ -1285,6 +1289,7 @@ let restore_local core user_id = ; cached_selected_graph = None ; selected_graph_value = None ; current_graph_scope = None + ; pending_selected_download = None ; graph_key = None ; pending_graph_open = None ; pending_mirror_inspection = None @@ -1343,6 +1348,7 @@ let sign_out core = ; deletion_scope = None ; cached_selected_graph = None ; current_graph_scope = None + ; pending_selected_download = None ; graph_key = None ; pending_graph_open = None ; pending_mirror_inspection = None @@ -1434,6 +1440,7 @@ let select_graph_transition core ~persist graph_id = ; pending_effects = List.filter (fun pending -> not (is_graph_key_access pending)) core.pending_effects ; selected_graph_value = Some graph ; current_graph_scope = Some scope + ; pending_selected_download = (if persist then Some scope else None) ; graph_key = None ; pending_graph_open = None ; pending_mirror_inspection = Some mirror_request @@ -1464,11 +1471,26 @@ let select_graph_transition core ~persist graph_id = let select_graph core graph_id = select_graph_transition core ~persist:true graph_id +let begin_missing_mirror_bootstrap core scope = + let startup = + { core.public_state.snapshot.startup with restoring_local = false; bootstrapping = true } + in + let core = set_snapshot { core with pending_selected_download = None } + { core.public_state.snapshot with startup } in + match core.selected_graph_value with + | Some graph when graph.encrypted -> + let next, request = issue_request core (Load_and_unlock_graph_key scope) in + { next; effects = [ publish next; Run request ] } + | Some _ | None -> + let requested = begin_snapshot_bootstrap core scope in + { requested with effects = publish requested.next :: requested.effects } +;; + let mirror_inspected core = function | Mirror_available request when graph_scope_is_current core request.scope && core.pending_mirror_inspection = Some request -> - let core = { core with pending_mirror_inspection = None } in + let core = { core with pending_mirror_inspection = None; pending_selected_download = None } in (match core.selected_graph_value, core.graph_key with | Some graph, Some key when graph.encrypted && graph_key_handle_scope key = request.scope -> @@ -1494,22 +1516,19 @@ let mirror_inspected core = function core.pending_mirror_inspection -> let core = { core with pending_mirror_inspection = None } in if not (network_allowed core) then - local_unavailable core Local_mirror_unavailable "This graph is not downloaded. Download it to open it on this device." - else - let startup = - { core.public_state.snapshot.startup with - restoring_local = false - ; bootstrapping = true - } - in - let core = set_snapshot core { core.public_state.snapshot with startup } in - (match core.selected_graph_value with - | Some graph when graph.encrypted -> - let next, request = issue_request core (Load_and_unlock_graph_key scope) in - { next; effects = [ publish next; Run request ] } - | Some _ | None -> - let requested = begin_snapshot_bootstrap core scope in - { requested with effects = publish requested.next :: requested.effects }) + if core.pending_selected_download = Some scope then + (* Only the user's current selection may request online admission. Native + permission and verified identity still precede any remote effect. *) + let core = network_phase core Online_by_user in + let snapshot = core.public_state.snapshot in + let startup = { snapshot.startup with authenticated = false; + restoring_local = false; bootstrapping = true } in + let next = set_snapshot { core with snapshot_scope = Some scope } + { snapshot with startup } in + { next; effects = [publish next] } + else + local_unavailable core Local_mirror_unavailable "This graph is not downloaded. Download it to open it on this device." + else begin_missing_mirror_bootstrap core scope | _ -> unchanged core ;; @@ -2401,6 +2420,7 @@ let clear_selected_graph_for_catalog core catalog = ; cached_selected_graph = None ; selected_graph_value = None ; current_graph_scope = None + ; pending_selected_download = None ; graph_key = None ; pending_graph_open = None ; pending_mirror_inspection = None @@ -2801,6 +2821,7 @@ let return_to_graph_picker core = { core with selected_graph_value = None ; current_graph_scope = None + ; pending_selected_download = None ; graph_key = None ; pending_graph_open = None ; pending_mirror_inspection = None @@ -2974,8 +2995,10 @@ let step core event = presentation_generation = snapshot.startup.presentation_generation + 1; restoring_local = snapshot.startup.restoring_local && local_restore_pending; bootstrapping = false; catalog_loading = next.catalog_cache_loading; - awaiting_e2ee_password = false; local_access } in + awaiting_e2ee_password = false; local_access; + failure = (if Option.is_some next.pending_selected_download then Some During_local_restore else snapshot.startup.failure) } in let next = set_snapshot { next with + pending_selected_download = None; pending_effects = local_pending; websocket_live = false; deferred_catalog_reconciliation = false; pending_encrypted_graph_key = (if List.exists (fun (Pending_effect ticket) -> match ticket.kind with Unlock_private_key_kind -> true | _ -> false) local_pending @@ -2997,6 +3020,9 @@ let step core event = let authenticated=authenticate core user_id in let next=authenticated.next in let resumed = match next.current_graph_scope,next.sync_view,core.public_state.snapshot.startup.local_access with + | Some scope,None,_ when next.pending_selected_download = Some scope + && next.public_state.snapshot.startup.bootstrapping -> + begin_missing_mirror_bootstrap next scope | Some scope,Some _,_ when not next.websocket_live && (match next.websocket_owner with | None | Some {phase=Retiring;_} -> true | Some _ -> false) -> start_websocket next scope | Some scope,None,Local_unavailable Local_key_unavailable diff --git a/logseq_sync/test/core_contract.ml b/logseq_sync/test/core_contract.ml index 26004c6..4a1dbb9 100644 --- a/logseq_sync/test/core_contract.ml +++ b/logseq_sync/test/core_contract.ml @@ -5548,3 +5548,104 @@ let scenarios = scenarios @ ; Alcotest.test_case "local insufficiency stops loading without network fallback" `Quick missing_local_data_stops_loading ; Alcotest.test_case "native appearance and explicit online permissions are scoped" `Quick appearance_is_scoped_and_authentication_is_required ] ;; + +(* User graph selection owns the decision to download an absent mirror. A cold + restore and unrelated storage/key failures still require explicit recovery. *) +let user_selected_missing_mirror () = + let selected, _ = local_selected () in + let chosen = Core.step selected.next (Core.Graph_selected graph_id) in + let request = inspect_mirror_request chosen.effects in + let missing = Core.step chosen.next (Core.Mirror_inspected (Core.Mirror_absent request.scope)) in + no_network "selection requests native permission before remote work" missing.effects; + let facts = (Core.state missing.next).snapshot.startup in + Alcotest.(check bool) "absent user selection publishes online permission without error" true + (facts.network_phase = Core.Online_by_user && facts.failure = None + && facts.local_access = Core.Local_pending && not facts.authenticated); + let confirmed = Core.step missing.next (Core.Account_authenticated {user_id=Some "user"}) in + let downloading = confirmed in + Alcotest.(check bool) "verified selection starts one baseline request" true + (List.exists (function Core.Run(Core.Request(_,Core.Fetch_snapshot_baseline _))->true|_->false) downloading.effects); + let failure = List.find_map (function + | Core.Run(Core.Request(ticket,Core.Fetch_snapshot_baseline _)) -> + Some (Core.Runner_completed(Core.Completion(ticket,Error(Core.Effect_failed "synthetic offline")))) + | _ -> None) downloading.effects |> Option.get in + let failed = Core.step downloading.next failure in + Alcotest.(check bool) "download failure remains terminal instead of auto-retrying" true + ((Core.state failed.next).snapshot.startup.failure = Some Core.During_bootstrap); + no_network "failed request has no automatic retry" failed.effects; + let retry = Core.step failed.next (Core.Online_recovery_requested (timeline_ticket failed.next)) in + ignore (inspect_mirror_request retry.effects) +;; + +let selected_download_cancellation_and_stale_inspections () = + let selected, old_request = local_selected () in + let chosen = Core.step selected.next (Core.Graph_selected graph_id) in + let request = inspect_mirror_request chosen.effects in + let stale = Core.step chosen.next (Core.Mirror_inspected(Core.Mirror_absent old_request.scope)) in + Alcotest.(check bool) "old inspection cannot authorize selected graph" true + ((Core.state stale.next).snapshot.startup.network_phase = Core.Local_only); + let missing = Core.step stale.next (Core.Mirror_inspected(Core.Mirror_absent request.scope)) in + let cancelled = Core.step missing.next (Core.Online_cancelled(startup_ticket missing.next)) in + no_network "cancellation keeps remote work stopped" cancelled.effects; + let late = Core.step cancelled.next (Core.Account_authenticated {user_id=Some "user"}) in + no_network "late identity cannot resume cancelled download" late.effects; + Alcotest.(check bool) "cancelled download offers explicit mirror recovery" true + ((Core.state late.next).snapshot.startup.local_access = Core.Local_unavailable Core.Local_mirror_unavailable); + let retry=Core.step late.next (Core.Online_recovery_requested(timeline_ticket late.next)) in + let confirmed=Core.step retry.next (Core.Account_authenticated {user_id=Some "user"}) in + ignore(inspect_mirror_request confirmed.effects); + let replacement = Core.step chosen.next (Core.Graph_selected graph_id) in + let retired = Core.step replacement.next (Core.Mirror_inspected(Core.Mirror_absent request.scope)) in + no_network "replaced selection ignores previous inspection" retired.effects; + Alcotest.(check bool) "replacement still starts locally" true + ((Core.state retired.next).snapshot.startup.network_phase = Core.Local_only) +;; + +let selected_available_mirror_keeps_local_key_failures () = + let selected, _ = local_selected ~encrypted:true () in + let chosen = Core.step selected.next (Core.Graph_selected graph_id) in + let request = inspect_mirror_request chosen.effects in + let available = Core.step chosen.next (Core.Mirror_inspected(Core.Mirror_available request)) in + no_network "available selected mirror keeps local-only key access" available.effects; + let completion = List.find_map (function + | Core.Run(Core.Request(ticket,Core.Load_and_unlock_graph_key _)) -> + Some(Core.Runner_completed(Core.Completion(ticket,Error(Core.Effect_failed "key missing")))) + | _ -> None) available.effects |> Option.get in + let failed=Core.step available.next completion in + no_network "key failure is not a download request" failed.effects; + Alcotest.(check bool) "key failure stays explicit" true + ((Core.state failed.next).snapshot.startup.local_access=Core.Local_unavailable Core.Local_key_unavailable) +;; + +let scenarios = scenarios @ + [ Alcotest.test_case "user-selected missing mirror requests direct download" `Quick user_selected_missing_mirror + ; Alcotest.test_case "selected download cancellation and stale inspections are fenced" `Quick selected_download_cancellation_and_stale_inspections + ; Alcotest.test_case "selected existing mirror retains local key failure" `Quick selected_available_mirror_keeps_local_key_failures ] +;; + +let selected_encrypted_download_retains_crypto_and_failure () = + let selected, _ = local_selected ~encrypted:true () in + let chosen = Core.step selected.next (Core.Graph_selected graph_id) in + let request=inspect_mirror_request chosen.effects in + let missing=Core.step chosen.next (Core.Mirror_inspected(Core.Mirror_absent request.scope)) in + Alcotest.(check bool) "encrypted selection opens scoped permission" true + ((Core.state missing.next).snapshot.startup.network_phase=Core.Online_by_user); + let confirmed=Core.step missing.next (Core.Account_authenticated {user_id=Some "user"}) in + let keys=confirmed in + Alcotest.(check bool) "encrypted selection still requires local key validation" true + (List.exists (function Core.Run(Core.Request(_,Core.Load_and_unlock_graph_key _))->true|_->false) keys.effects); + let missing_key=List.find_map (function + | Core.Run(Core.Request(ticket,Core.Load_and_unlock_graph_key _)) -> + Some(Core.Runner_completed(Core.Completion(ticket,Error(Core.Effect_failed "key missing")))) + | _ -> None) keys.effects |> Option.get in + let remote=Core.step keys.next missing_key in + let failed=List.find_map (function + | Core.Run(Core.Request(ticket,Core.Fetch_e2ee_graph_key _)) -> + Some(Core.step remote.next (Core.Runner_completed(Core.Completion(ticket,Error(Core.Effect_failed "synthetic key server offline"))))) + | _ -> None) remote.effects |> Option.get in + Alcotest.(check bool) "key recovery failure remains visible" true + ((Core.state failed.next).snapshot.startup.failure=Some Core.During_e2ee); + no_network "key recovery failure does not auto-repeat" failed.effects +;; +let scenarios=scenarios @ [Alcotest.test_case "selected encrypted download retains key validation and bounded failure" `Quick selected_encrypted_download_retains_crypto_and_failure] +;; diff --git a/swift/JournalAuthenticationView.swift b/swift/JournalAuthenticationView.swift index 5d29f3c..528f228 100644 --- a/swift/JournalAuthenticationView.swift +++ b/swift/JournalAuthenticationView.swift @@ -33,9 +33,15 @@ struct JournalAccountRecoveryView: View { struct JournalAuthenticationView: View { @State private var model: JournalAuthentication @State private var operation: Task? + @State private var automaticStartConsumed = false + let startsImmediately: Bool + let cancelled: () -> Void let authenticated: () -> Void - init(api: any JournalAuthenticationAPI, authenticated: @escaping () -> Void) { + init(api: any JournalAuthenticationAPI, startsImmediately: Bool = false, + cancelled: @escaping () -> Void = {}, authenticated: @escaping () -> Void) { _model = State(initialValue: JournalAuthentication(api: api)) + self.startsImmediately = startsImmediately + self.cancelled = cancelled self.authenticated = authenticated } var body: some View { @@ -47,12 +53,7 @@ struct JournalAuthenticationView: View { if let message = model.message { Text(message).foregroundStyle(.red).accessibilityIdentifier("authentication-error") } - Button { - operation = Task { - await model.submit() - if model.complete && !Task.isCancelled { authenticated() } - } - } label: { + Button(action: submit) { HStack { if model.busy { ProgressView() } Text(model.busy ? "Opening sign-in…" : "Continue to sign in") @@ -61,11 +62,29 @@ struct JournalAuthenticationView: View { .buttonStyle(.borderedProminent).disabled(!model.canSubmit) .accessibilityIdentifier("authentication-submit") if model.busy { - Button("Cancel", role: .cancel) { cancel() } + Button("Cancel", role: .cancel) { + cancel() + if startsImmediately { cancelled() } + } } }.padding(24).frame(maxWidth: 480, alignment: .leading) } + .task { + guard startsImmediately && !automaticStartConsumed else { return } + automaticStartConsumed = true + submit() + } .onDisappear { cancel() } } + private func submit() { + guard operation == nil && model.canSubmit else { return } + operation = Task { + await model.submit() + guard !Task.isCancelled else { return } + operation = nil + if model.complete { authenticated() } + else if startsImmediately && model.message == nil { cancelled() } + } + } private func cancel() { operation?.cancel(); operation = nil; model.cancel() } } diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index 158fa69..7299afe 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -159,6 +159,7 @@ struct JournalHost: View { @State private var setup: Result? @State private var retry = 0 @State private var signInPresented = false + @State private var signInStartsImmediately = false @State private var clock = JournalStartupClock() var body: some View { @@ -182,7 +183,7 @@ struct JournalHost: View { if platform.authenticationRequired || platform.accountIssue != nil { JournalAccountRecoveryView(issue: platform.accountIssue, signInAvailable: platform.authenticationRequired || platform.accountIssue?.offersSignIn == true, - signIn: { signInPresented = true }, + signIn: { signInStartsImmediately = true; signInPresented = true }, retry: platform.accountIssue?.stage == .bindingRead ? nil : { platform.retryOnline() }) } } @@ -199,12 +200,16 @@ struct JournalHost: View { else if phase == .active { lifecycle(backgrounded: false) } } .onChange(of: platform.authenticationRequired) { _, required in - if required && platform.localAccountAvailable == false { signInPresented = true } + if required && platform.localAccountAvailable == false && !signInPresented { + signInStartsImmediately = false; signInPresented = true + } // The authentication owner closes its sheet after account preparation. // Closing on the intermediate native identity update can cancel that work. } .onChange(of: platform.localAccountAvailable) { _, available in - if available == false && platform.authenticationRequired { signInPresented = true } + if available == false && platform.authenticationRequired && !signInPresented { + signInStartsImmediately = false; signInPresented = true + } } .sheet(isPresented: $signInPresented) { NavigationStack { @@ -214,7 +219,8 @@ struct JournalHost: View { cancelPreparation: { platform.cancelAccountPreparation() }, preparationMessage: { platform.accountIssue.map { ($0.errorDescription ?? "") + "\n" + $0.diagnostic } - })) { + }), startsImmediately: signInStartsImmediately, + cancelled: { signInPresented = false }) { signInPresented = false } .toolbar { diff --git a/test/application_view_test.ml b/test/application_view_test.ml index aa92c17..d6440df 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -6039,7 +6039,7 @@ let test_graph_picker_controls scenario () = {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":0,\"last\":9223372036854775807}"}|}) in range (); wait "visible rows are inspected" (fun () -> Atomic.get inspections = 2 - && Option.is_some (find "text" "Not downloaded")); + && Option.is_some (find "text" "Download and open")); for _ = 1 to 10 do range (); consume (hooks.pump ()) done; Alcotest.(check int) "same viewport reuses presence observations" 2 (Atomic.get inspections); Alcotest.(check int) "presence does not reconstruct the graph catalog" before !root_renders @@ -6162,13 +6162,13 @@ let test_graph_picker_controls scenario () = | Service.Select_graph id -> id = second | _ -> false) (Atomic.get commands))) - else wait "deleted graph stays available to download" (fun () -> Option.is_some (find "text" "Not downloaded")); + else wait "deleted graph stays available to download" (fun () -> Option.is_some (find "text" "Download and open")); if scenario = `Late_presence then ( Atomic.set release_automatic_inspection true; wait "automatic inspection retires" (fun () -> Atomic.get automatic_inspection_returned && !inspection_terminals = 2); Alcotest.(check bool) "late pre-delete inspection cannot restore Downloaded" true - (Option.is_some (find "text" "Not downloaded"))); + (Option.is_some (find "text" "Download and open"))); if scenario = `Reopen then ( let switch id = let before = Atomic.get graph_opens in @@ -6182,7 +6182,7 @@ let test_graph_picker_controls scenario () = switch second; switch first; Alcotest.(check bool) "reopened copy does not keep the stale not-downloaded label" true - (Option.is_none (find "text" "Not downloaded"))))) + (Option.is_none (find "text" "Download and open"))))) | `Current_menu -> ignore (node ("graph-picker-options:" ^ Logseq_db_types.Graph_types.Uuid.to_string first)); Alcotest.(check bool) "current protection explains switching first" true