mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 02:16:14 +00:00
🐛 Fix stalled saving states (#11699)
This commit is contained in:
parent
1c8b0c1844
commit
ff88a055fd
@ -323,7 +323,7 @@
|
||||
// STATUS WIDGET
|
||||
--status-widget-background-color-success: var(--status-color-success-500);
|
||||
--status-widget-background-color-warning: var(--status-color-warning-500);
|
||||
--status-widget-background-color-pending: var(--status-color-info-500);
|
||||
--status-widget-background-color-pending: var(--status-color-warning-500);
|
||||
--status-widget-background-color-error: var(--status-color-error-500);
|
||||
--status-widget-icon-foreground-color: var(--color-background-primary);
|
||||
|
||||
|
||||
@ -245,8 +245,10 @@
|
||||
features (get state :features)
|
||||
permissions (get state :permissions)]
|
||||
|
||||
;; Prevent commit changes by a viewer team member (it really should never happen)
|
||||
(when (:can-edit permissions)
|
||||
;; Historical previews must not create edits to the live file. Check
|
||||
;; this when creating commits so previously queued edits can still save.
|
||||
(when (and (:can-edit permissions)
|
||||
(not (dm/get-in state [:workspace-global :preview-id])))
|
||||
(log/trace :hint "commit-changes" :redo-changes redo-changes)
|
||||
(let [selected (dm/get-in state [:workspace-local :selected])]
|
||||
(rx/of (-> params
|
||||
|
||||
@ -9,9 +9,13 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.logging :as log]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.common :as-alias dc]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace :as-alias dw]
|
||||
[app.main.errors :as errors]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.repo :as rp]
|
||||
[beicon.v2.core :as rx]
|
||||
@ -21,24 +25,46 @@
|
||||
|
||||
(log/set-level! :warn)
|
||||
|
||||
(def running (atom false))
|
||||
(def revn-data (atom {}))
|
||||
(defonce ^:private active-requests (atom #{}))
|
||||
(def queue-conj (fnil conj #queue []))
|
||||
|
||||
(def force-persist? #(= % ::force-persist))
|
||||
|
||||
(defn wait-persisted
|
||||
(def ^:private saving-stall-timeout-ms (* 5 60 1000))
|
||||
(def ^:private saving-check-interval-ms 30000)
|
||||
(def ^:private save-wait-timeout-ms (* 2 60 1000))
|
||||
|
||||
(defn wait-persisted-or-error
|
||||
"Returns an observable that emits the first terminal persistence status
|
||||
(nil | :saved) and completes. With a timeout-ms, if persistence doesn't
|
||||
settle in time the observable completes silently without emitting."
|
||||
(nil | :saved) and completes. Raises when the queue has failed and, with
|
||||
a timeout-ms, when persistence does not settle in time."
|
||||
([] (wait-persisted-or-error save-wait-timeout-ms))
|
||||
([timeout-ms]
|
||||
(let [base (->> (rx/from-atom refs/persistence {:emit-current-value? true})
|
||||
(rx/filter (fn [{:keys [status queue]}]
|
||||
(or (= status :error)
|
||||
(and (empty? queue)
|
||||
(or (nil? status) (= status :saved))))))
|
||||
(rx/take 1)
|
||||
(rx/mapcat (fn [{:keys [status error]}]
|
||||
(if (= status :error)
|
||||
(rx/throw (ex-info "Changes could not be saved"
|
||||
(merge {:type :persistence :code :save-failed} error)))
|
||||
(rx/of status)))))]
|
||||
(cond->> base
|
||||
timeout-ms
|
||||
(rx/timeout timeout-ms
|
||||
(rx/throw (ex-info "Timed out waiting for changes to be saved"
|
||||
{:type :persistence :code :save-timeout})))))))
|
||||
|
||||
(defn wait-persisted
|
||||
"Best-effort variant of `wait-persisted-or-error`: a failed or timed out
|
||||
save completes the observable silently instead of raising."
|
||||
([] (wait-persisted nil))
|
||||
([timeout-ms]
|
||||
(let [base (->> (rx/from-atom refs/persistence-state {:emit-current-value? true})
|
||||
(rx/filter #(or (nil? %) (= :saved %)))
|
||||
(rx/take 1))]
|
||||
(if timeout-ms
|
||||
(->> base (rx/timeout timeout-ms (rx/empty)))
|
||||
base))))
|
||||
(->> (wait-persisted-or-error timeout-ms)
|
||||
(rx/catch (fn [_] (rx/empty))))))
|
||||
|
||||
(defn force-persist-and-wait
|
||||
"Convenience that emits the force-persist event and then waits for
|
||||
@ -47,25 +73,75 @@
|
||||
([timeout-ms]
|
||||
(rx/concat (rx/of ::force-persist) (wait-persisted timeout-ms))))
|
||||
|
||||
(defn- next-status
|
||||
"Refuses downgrades: a save in progress stays :saving, and a failed save
|
||||
stays :error until persistence is resumed."
|
||||
[from to]
|
||||
(cond
|
||||
(and (= to :pending) (= from :saving)) from
|
||||
(and (= from :error) (#{:pending :saving} to)) from
|
||||
:else to))
|
||||
|
||||
(defn- update-status
|
||||
[status]
|
||||
(ptk/reify ::update-status
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :persistence (fn [pstate]
|
||||
(log/trc :hint "update-status"
|
||||
:from (:status pstate)
|
||||
:to status)
|
||||
(let [status (if (and (= status :pending)
|
||||
(= (:status pstate) :saving))
|
||||
(:status pstate)
|
||||
status)]
|
||||
(update state :persistence
|
||||
(fn [pstate]
|
||||
(log/trc :hint "update-status"
|
||||
:from (:status pstate)
|
||||
:to status)
|
||||
(let [status (next-status (:status pstate) status)]
|
||||
(cond-> (assoc pstate :status status)
|
||||
(#{:pending :saving} status)
|
||||
(update :last-progress-at d/nilv (inst-ms (ct/now)))
|
||||
|
||||
(-> (assoc pstate :status status)
|
||||
(cond-> (= status :error)
|
||||
(dissoc :run-id))
|
||||
(cond-> (= status :saved)
|
||||
(dissoc :run-id)))))))))
|
||||
(#{:error :saved} status)
|
||||
(dissoc :run-id :last-progress-at :stall-reported?))))))))
|
||||
|
||||
(defn- report-stalled-persistence
|
||||
[now]
|
||||
(ptk/reify ::report-stalled-persistence
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc-in state [:persistence :stall-reported?] true))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(let [{:keys [queue index status run-id last-progress-at]} (:persistence state)
|
||||
commit-id (peek queue)
|
||||
commit (get index commit-id)
|
||||
hint "File saving has made no progress for more than five minutes"
|
||||
cause (ex-info hint
|
||||
{:type :persistence
|
||||
:code :saving-stalled
|
||||
:file-id (or (:file-id commit) (:current-file-id state))
|
||||
:commit-id commit-id
|
||||
:run-id run-id
|
||||
:status status
|
||||
:queued-commits (count queue)
|
||||
:elapsed-ms (- now last-progress-at)
|
||||
:can-edit (dm/get-in state [:permissions :can-edit])
|
||||
:read-only? (dm/get-in state [:workspace-global :read-only?])
|
||||
:preview-id (dm/get-in state [:workspace-global :preview-id])
|
||||
:render-context-lost? (dm/get-in state [:render-state :lost])})]
|
||||
(errors/submit-report :event-name "handled-exception"
|
||||
:hint hint
|
||||
:report (errors/generate-report cause))))))
|
||||
|
||||
(defn- check-persistence
|
||||
[]
|
||||
(ptk/reify ::check-persistence
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [{:keys [status last-progress-at stall-reported?]} (:persistence state)
|
||||
now (inst-ms (ct/now))]
|
||||
(when (and (#{:pending :saving} status)
|
||||
last-progress-at
|
||||
(not stall-reported?)
|
||||
(> (- now last-progress-at) saving-stall-timeout-ms))
|
||||
(rx/of (report-stalled-persistence now)))))))
|
||||
|
||||
(defn- update-file-revn
|
||||
[file-id revn]
|
||||
@ -90,7 +166,9 @@
|
||||
(if (= commit-id (peek queue))
|
||||
(pop queue)
|
||||
(throw (ex-info "invalid state" {})))))
|
||||
(update :index dissoc commit-id)))))))
|
||||
(update :index dissoc commit-id)
|
||||
(assoc :last-progress-at (inst-ms (ct/now)))
|
||||
(dissoc :stall-reported?)))))))
|
||||
|
||||
(defn- append-commit
|
||||
"Event used internally to append the current change to the
|
||||
@ -104,62 +182,152 @@
|
||||
(update state :persistence
|
||||
(fn [pstate]
|
||||
(-> pstate
|
||||
(update :run-id d/nilv run-id)
|
||||
(cond-> (not= :error (:status pstate))
|
||||
(update :run-id d/nilv run-id))
|
||||
(update :queue queue-conj id)
|
||||
(update :index assoc id commit)))))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [pstate (:persistence state)]
|
||||
(when (= run-id (:run-id pstate))
|
||||
(rx/of (run-persistence-task)
|
||||
(update-status :saving))))))))
|
||||
(when (and (not= :error (:status pstate))
|
||||
(= run-id (:run-id pstate)))
|
||||
(rx/of (update-status :saving)
|
||||
(run-persistence-task))))))))
|
||||
|
||||
(defn- discard-persistence-state
|
||||
[]
|
||||
(ptk/reify ::discard-persistence-state
|
||||
(defn- persistence-failed
|
||||
[commit-id cause]
|
||||
(ptk/reify ::persistence-failed
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(dissoc state :persistence))))
|
||||
(let [data (ex-data cause)]
|
||||
(update state :persistence
|
||||
(fn [pstate]
|
||||
(-> pstate
|
||||
(assoc :status :error
|
||||
:error (assoc data
|
||||
:type :persistence
|
||||
:code (:code data :save-failed)
|
||||
:cause-type (:type data)
|
||||
:commit-id commit-id
|
||||
:hint (ex-message cause)
|
||||
::errors/handled? true))
|
||||
(dissoc :run-id :last-progress-at :stall-reported?))))))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (ptk/data-event ::error cause)))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
;; Report without invoking global handlers that may reload the file or
|
||||
;; navigate away before the user can recover the retained changes.
|
||||
(errors/flash-persistence cause))))
|
||||
|
||||
(defn- commit-persisted
|
||||
[commit]
|
||||
(ptk/reify ::commit-persisted
|
||||
IDeref
|
||||
(-deref [_] commit)
|
||||
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
;; Keep the acknowledgment even if the queue runner has stopped.
|
||||
(d/update-in-when state [:persistence :index (:id commit)]
|
||||
assoc ::acknowledged? true))))
|
||||
|
||||
(defn- update-file-request
|
||||
"Issues the `update-file` request, tracked as active for its lifetime."
|
||||
[request-id params]
|
||||
(rx/create
|
||||
(fn [subscriber]
|
||||
(swap! active-requests conj request-id)
|
||||
(let [source (try
|
||||
(rp/cmd! :update-file params)
|
||||
(catch :default cause
|
||||
(rx/throw cause)))
|
||||
subscription (.subscribe source subscriber)]
|
||||
(fn []
|
||||
(swap! active-requests disj request-id)
|
||||
(rx/dispose! subscription))))))
|
||||
|
||||
(defn- attempt-state
|
||||
"Classifies what should happen with a queued commit before sending it.
|
||||
The attempt stamp and the send decision both read this, so a commit is
|
||||
only ever stamped with a request that is actually going to be sent."
|
||||
[state commit-id request-id]
|
||||
(let [commit (dm/get-in state [:persistence :index commit-id])]
|
||||
(cond
|
||||
(= :error (dm/get-in state [:persistence :status])) :halted
|
||||
(nil? commit) :missing-commit
|
||||
(::acknowledged? commit) :acknowledged
|
||||
(contains? @active-requests (::request-id commit)) :in-flight
|
||||
(and (::request-id commit)
|
||||
(not= request-id (::request-id commit))) :unknown-outcome
|
||||
(not (dm/get-in state [:permissions :can-edit])) :permission-denied
|
||||
:else :ready)))
|
||||
|
||||
(defn- send-queued-commit
|
||||
"Sends one queued commit and maps its outcome to persistence events."
|
||||
[request-id session-id {:keys [id file-id file-revn file-vern changes features] :as commit}]
|
||||
(let [params {:id file-id
|
||||
:revn (max file-revn (get @revn-data file-id 0))
|
||||
:vern file-vern
|
||||
:session-id session-id
|
||||
:origin (:origin commit)
|
||||
:created-at (:created-at commit)
|
||||
:commit-id id
|
||||
:changes (vec changes)
|
||||
:features features}]
|
||||
;; UI read-only mode does not invalidate already queued edits.
|
||||
(->> (update-file-request request-id params)
|
||||
(rx/take 1)
|
||||
;; A response that carries no revision, including one that never
|
||||
;; arrived, is treated as a failed save rather than a saved file.
|
||||
(rx/if-empty nil)
|
||||
(rx/mapcat (fn [{:keys [revn]}]
|
||||
(if (and (int? revn) (<= 0 revn))
|
||||
(rx/of (update-file-revn file-id revn)
|
||||
(commit-persisted commit))
|
||||
(rx/throw (ex-info "The save response has no valid revision"
|
||||
{:type :persistence
|
||||
:code :invalid-save-response
|
||||
:file-id file-id})))))
|
||||
(rx/catch (fn [cause]
|
||||
(rx/of (persistence-failed id cause)))))))
|
||||
|
||||
(defn- persist-commit
|
||||
[commit-id]
|
||||
(ptk/reify ::persist-commit
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(log/dbg :hint "persist-commit" :commit-id (dm/str commit-id))
|
||||
(when-let [{:keys [file-id file-revn file-vern changes features] :as commit} (dm/get-in state [:persistence :index commit-id])]
|
||||
(let [sid (:session-id state)
|
||||
revn (max file-revn (get @revn-data file-id 0))
|
||||
params {:id file-id
|
||||
:revn revn
|
||||
:vern file-vern
|
||||
:session-id sid
|
||||
:origin (:origin commit)
|
||||
:created-at (:created-at commit)
|
||||
:commit-id commit-id
|
||||
:changes (vec changes)
|
||||
:features features}
|
||||
permissions (:permissions state)]
|
||||
(let [request-id (uuid/next)]
|
||||
(ptk/reify ::persist-commit
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(if (= :ready (attempt-state state commit-id request-id))
|
||||
;; Record the attempt before starting I/O. An interrupted request
|
||||
;; may have reached the server and must not be replayed blindly.
|
||||
(assoc-in state [:persistence :index commit-id ::request-id] request-id)
|
||||
state))
|
||||
|
||||
;; Prevent saving changes when in version preview (read-only) mode
|
||||
;; or when the user does not have edition permission.
|
||||
(when (and (:can-edit permissions)
|
||||
(not (get-in state [:workspace-global :read-only?])))
|
||||
(->> (rp/cmd! :update-file params)
|
||||
(rx/mapcat (fn [{:keys [revn lagged] :as response}]
|
||||
(log/debug :hint "changes persisted" :commit-id (dm/str commit-id) :lagged (count lagged))
|
||||
(rx/of (ptk/data-event ::commit-persisted commit)
|
||||
(update-file-revn file-id revn))))
|
||||
|
||||
(rx/catch (fn [cause]
|
||||
(rx/concat
|
||||
(if (= :authentication (:type cause))
|
||||
(rx/empty)
|
||||
(rx/of (ptk/data-event ::error cause)
|
||||
(update-status :error)))
|
||||
(rx/of (discard-persistence-state))
|
||||
(rx/throw cause)))))))))))
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [commit (dm/get-in state [:persistence :index commit-id])
|
||||
fail (fn [code hint]
|
||||
(rx/of (persistence-failed commit-id
|
||||
(ex-info hint {:type :persistence
|
||||
:code code
|
||||
:commit-id commit-id
|
||||
:file-id (:file-id commit)}))))]
|
||||
(case (attempt-state state commit-id request-id)
|
||||
:halted (rx/empty)
|
||||
:missing-commit (fail :missing-commit "A queued save has no change data")
|
||||
:acknowledged (rx/of (commit-persisted commit))
|
||||
;; The replacement runner listens for the original request's result.
|
||||
:in-flight (rx/empty)
|
||||
;; Even :network and :offline do not prove that the server
|
||||
;; skipped the write. Keep the attempt stamp to prevent replay.
|
||||
:unknown-outcome (fail :save-outcome-unknown "An interrupted save has an unknown outcome")
|
||||
:permission-denied (fail :save-permission-denied "Edit permission was lost before changes could be saved")
|
||||
:ready (send-queued-commit request-id (:session-id state) commit)))))))
|
||||
|
||||
|
||||
(defn- run-persistence-task
|
||||
@ -168,14 +336,18 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
(let [queue (-> state :persistence :queue)]
|
||||
(if-let [commit-id (peek queue)]
|
||||
(let [stoper-s (rx/merge
|
||||
(cond
|
||||
(= :error (dm/get-in state [:persistence :status]))
|
||||
(rx/empty)
|
||||
|
||||
(seq queue)
|
||||
(let [commit-id (peek queue)
|
||||
stoper-s (rx/merge
|
||||
(rx/filter (ptk/type? ::run-persistence-task) stream)
|
||||
(rx/filter (ptk/type? ::error) stream))]
|
||||
|
||||
(log/dbg :hint "run-persistence-task" :commit-id (dm/str commit-id))
|
||||
(->> (rx/merge
|
||||
(rx/of (persist-commit commit-id))
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::commit-persisted))
|
||||
(rx/map deref)
|
||||
@ -183,8 +355,47 @@
|
||||
(rx/take 1)
|
||||
(rx/mapcat (fn [_]
|
||||
(rx/of (discard-commit commit-id)
|
||||
(run-persistence-task))))))
|
||||
(run-persistence-task)))))
|
||||
(rx/of (persist-commit commit-id)))
|
||||
(rx/take-until stoper-s)))
|
||||
|
||||
:else
|
||||
(rx/of (update-status :saved)))))))
|
||||
|
||||
(defn- resume-persistence
|
||||
[]
|
||||
(ptk/reify ::resume-persistence
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(update state :persistence
|
||||
(fn [pstate]
|
||||
(-> pstate
|
||||
(dissoc :error)
|
||||
(assoc :run-id (uuid/next) :status :saving)
|
||||
(update :last-progress-at d/nilv (inst-ms (ct/now)))))))
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/of (run-persistence-task)))))
|
||||
|
||||
(defn- recover-persistence
|
||||
[]
|
||||
(ptk/reify ::recover-persistence
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [{:keys [queue index status error run-id]} (:persistence state)
|
||||
commit (get index (peek queue))]
|
||||
(cond
|
||||
(and (seq queue)
|
||||
(or (not= status :error)
|
||||
(and (= :save-permission-denied (:code error))
|
||||
(not (::request-id commit))
|
||||
(= (:file-id commit) (:current-file-id state))
|
||||
(dm/get-in state [:permissions :can-edit]))))
|
||||
(rx/of (resume-persistence))
|
||||
|
||||
(and (empty? queue)
|
||||
(not= status :error)
|
||||
(or run-id (#{:pending :saving} status)))
|
||||
(rx/of (update-status :saved)))))))
|
||||
|
||||
(def ^:private xf-mapcat-undo
|
||||
@ -229,8 +440,21 @@
|
||||
(rx/filter #(= % ::force-persist))))]
|
||||
|
||||
(rx/merge
|
||||
(rx/of (recover-persistence))
|
||||
|
||||
(->> stream
|
||||
(rx/filter #(or (ptk/type? ::dc/change-team-role %)
|
||||
(ptk/type? ::dw/workspace-initialized %)))
|
||||
(rx/map (fn [_] (recover-persistence)))
|
||||
(rx/take-until stoper-s))
|
||||
|
||||
(->> (rx/interval saving-check-interval-ms)
|
||||
(rx/map (fn [_] (check-persistence)))
|
||||
(rx/take-until stoper-s))
|
||||
|
||||
(->> notifier-s
|
||||
(rx/map #(ptk/data-event ::persistence-notification)))
|
||||
(rx/map #(ptk/data-event ::persistence-notification))
|
||||
(rx/take-until stoper-s))
|
||||
|
||||
(->> local-commits-s
|
||||
(rx/debounce 200)
|
||||
@ -242,10 +466,10 @@
|
||||
;; chunks (very near in time commits) and append them to the
|
||||
;; persistence queue
|
||||
(->> local-commits-s
|
||||
(rx/take-until stoper-s)
|
||||
(rx/buffer-until notifier-s)
|
||||
(rx/mapcat merge-commit)
|
||||
(rx/map append-commit)
|
||||
(rx/take-until (rx/delay 100 stoper-s))
|
||||
(rx/finalize (fn []
|
||||
(log/debug :hint "finalize persistence: changes watcher"))))
|
||||
|
||||
|
||||
@ -604,12 +604,11 @@
|
||||
:workspace-editor-state
|
||||
:workspace-wasm-editor-styles
|
||||
:workspace-media-objects
|
||||
:workspace-persistence
|
||||
:workspace-presence
|
||||
:workspace-tokens
|
||||
:workspace-undo
|
||||
:workspace-versions)
|
||||
(update :workspace-global dissoc :read-only? :default-font)
|
||||
(update :workspace-global dissoc :read-only? :preview-id :default-font)
|
||||
(assoc-in [:workspace-global :options-mode] :design)
|
||||
(update :files d/update-vals #(dissoc % :data))))
|
||||
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
(rx/of ::dwp/force-persist
|
||||
(ev/event {::ev/name "create-version"}))
|
||||
|
||||
(->> (dwp/wait-persisted)
|
||||
(->> (dwp/wait-persisted-or-error)
|
||||
(rx/mapcat #(rp/cmd! :create-file-snapshot {:file-id file-id :label label}))
|
||||
(rx/mapcat
|
||||
(fn [{:keys [id]}]
|
||||
@ -95,6 +95,15 @@
|
||||
(->> (rp/cmd! :update-file-snapshot {:id id :label label})
|
||||
(rx/map fetch-versions)))))))
|
||||
|
||||
(defn- clear-preview-state
|
||||
[]
|
||||
(ptk/reify ::clear-preview-state
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(-> state
|
||||
(update :workspace-versions dissoc :backup)
|
||||
(update :workspace-global dissoc :read-only? :preview-id)))))
|
||||
|
||||
(defn- initialize-version
|
||||
[]
|
||||
(ptk/reify ::initialize-version
|
||||
@ -108,7 +117,10 @@
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::dw/bundle-fetched))
|
||||
(rx/take 1)
|
||||
(rx/map #(dwpg/initialize-page file-id page-id)))
|
||||
;; Keep historical content read-only until the restored file
|
||||
;; has loaded, including when saving or loading fails.
|
||||
(rx/mapcat #(rx/of (clear-preview-state)
|
||||
(dwpg/initialize-page file-id page-id))))
|
||||
|
||||
(rx/of (ntf/hide :tag :restore-dialog)
|
||||
(dw/initialize-file team-id file-id)))))
|
||||
@ -200,12 +212,6 @@
|
||||
[id]
|
||||
(assert (uuid? id) "expected valid uuid for `id`")
|
||||
(ptk/reify ::restore-version
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
;; Clear preview state if we're restoring from preview mode
|
||||
(-> state
|
||||
(update :workspace-versions dissoc :backup)
|
||||
(update :workspace-global dissoc :read-only? :preview-id)))
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [file-id (:current-file-id state)]
|
||||
@ -213,9 +219,12 @@
|
||||
(rx/of ::dwp/force-persist
|
||||
(dw/remove-layout-flag :document-history))
|
||||
|
||||
(->> (dwp/wait-persisted)
|
||||
(->> (dwp/wait-persisted-or-error)
|
||||
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
|
||||
(rx/map #(initialize-version))))))))
|
||||
(rx/map #(initialize-version))
|
||||
(rx/catch (fn [cause]
|
||||
(rx/concat (rx/of (exit-preview))
|
||||
(rx/throw cause))))))))))
|
||||
|
||||
(defn enter-restore
|
||||
[id]
|
||||
@ -356,7 +365,7 @@
|
||||
(rx/of ::dwp/force-persist))
|
||||
|
||||
(->> (if (= file-id current-file-id)
|
||||
(dwp/wait-persisted)
|
||||
(dwp/wait-persisted-or-error)
|
||||
(rx/of :nothing))
|
||||
(rx/mapcat
|
||||
(fn [_]
|
||||
@ -386,7 +395,7 @@
|
||||
::ev/origin "plugins"})
|
||||
::dwp/force-persist)
|
||||
|
||||
(->> (dwp/wait-persisted)
|
||||
(->> (dwp/wait-persisted-or-error)
|
||||
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
|
||||
(rx/map #(initialize-version)))
|
||||
|
||||
@ -394,10 +403,11 @@
|
||||
(rx/tap resolve)
|
||||
(rx/ignore)))
|
||||
|
||||
;; On error reject the promise and empty the stream
|
||||
;; Restore the live file before rejecting the plugin promise.
|
||||
(rx/catch (fn [error]
|
||||
(reject error)
|
||||
(rx/empty)))))))
|
||||
|
||||
|
||||
|
||||
(rx/concat
|
||||
(rx/of (exit-preview))
|
||||
(->> (rx/of error)
|
||||
(rx/observe-on :async)
|
||||
(rx/tap reject)
|
||||
(rx/ignore)))))))))
|
||||
|
||||
@ -172,6 +172,7 @@
|
||||
|
||||
(defn flash
|
||||
"Show error notification banner and emit error report.
|
||||
A nil timeout keeps the notification visible until dismissed or replaced.
|
||||
|
||||
The notification is scheduled asynchronously (via tm/schedule) to
|
||||
avoid pushing a new event into the potok store while the store's own
|
||||
@ -179,7 +180,7 @@
|
||||
synchronously from inside an error handler creates a re-entrant
|
||||
event-processing cycle that can exhaust the JS call stack
|
||||
(RangeError: Maximum call stack size exceeded)."
|
||||
[& {:keys [type hint cause] :or {type :handled}}]
|
||||
[& {:keys [type hint cause timeout] :or {type :handled timeout 5000}}]
|
||||
(when (ex/exception? cause)
|
||||
(when-let [event-name (case type
|
||||
:handled "handled-exception"
|
||||
@ -195,7 +196,7 @@
|
||||
(ntf/show {:content (or ^boolean hint (tr "errors.generic"))
|
||||
:type :toast
|
||||
:level :error
|
||||
:timeout 5000}))))
|
||||
:timeout timeout}))))
|
||||
|
||||
(defmethod ptk/handle-error :network
|
||||
[error]
|
||||
@ -206,6 +207,21 @@
|
||||
(ex/print-throwable cause :prefix "Network Error"))
|
||||
(flash :cause (::instance error) :type :handled))
|
||||
|
||||
(defn flash-persistence
|
||||
[cause]
|
||||
(let [{:keys [type cause-type]} (ex-data cause)]
|
||||
;; Authentication has its own UI. `flash :silent` only skips reporting;
|
||||
;; it still shows a toast, so do not call it for these failures.
|
||||
(when-not (or (= :authentication type) (= :authentication cause-type))
|
||||
(flash :cause cause :type :handled :timeout nil :hint (tr "errors.save-failed")))))
|
||||
|
||||
(defmethod ptk/handle-error :persistence
|
||||
[error]
|
||||
;; The persistence failure event reports the original cause. Waiters still
|
||||
;; reject, but must not report that same incident again.
|
||||
(when-not (::handled? error)
|
||||
(flash-persistence (::instance error))))
|
||||
|
||||
(defmethod ptk/handle-error :internal
|
||||
[error]
|
||||
(st/emit! (rt/assign-exception error))
|
||||
|
||||
@ -669,9 +669,6 @@
|
||||
(def updating-library
|
||||
(l/derived :updating-library st/state))
|
||||
|
||||
(def persistence-state
|
||||
(l/derived (comp :status :persistence) st/state))
|
||||
|
||||
(def progress
|
||||
(l/derived :progress st/state))
|
||||
|
||||
|
||||
@ -34,8 +34,9 @@
|
||||
persistence
|
||||
(mf/deref refs/persistence)
|
||||
|
||||
;; Nothing queued to save means the file is up to date.
|
||||
persistence-status
|
||||
(get persistence :status)
|
||||
(or (:status persistence) :saved)
|
||||
|
||||
editing* (mf/use-state false)
|
||||
editing? (deref editing*)
|
||||
@ -109,7 +110,7 @@
|
||||
{:class (stl/css :file-name)
|
||||
:title file-name
|
||||
:on-double-click start-editing-name}
|
||||
;;-- Persistende state widget
|
||||
;; Persistence state widget
|
||||
[:div {:class (case persistence-status
|
||||
:pending (stl/css :status-notification :pending-status)
|
||||
:saving (stl/css :status-notification :saving-status)
|
||||
|
||||
@ -103,7 +103,6 @@
|
||||
border-radius: 50%;
|
||||
margin-right: deprecated.$s-4;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--status-widget-background-color-pending);
|
||||
|
||||
&.pending-status {
|
||||
background-color: var(--status-widget-background-color-warning);
|
||||
|
||||
378
frontend/test/frontend_tests/data/persistence_test.cljs
Normal file
378
frontend/test/frontend_tests/data/persistence_test.cljs
Normal file
@ -0,0 +1,378 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.data.persistence-test
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.persistence :as dps]
|
||||
[app.main.data.render-wasm :as drw]
|
||||
[app.main.errors :as errors]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.util.i18n :as i18n]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn- local-commit
|
||||
[file-id]
|
||||
(ptk/data-event ::dch/commit
|
||||
{:id (uuid/next)
|
||||
:file-id file-id
|
||||
:file-revn 0
|
||||
:file-vern 0
|
||||
:source :local
|
||||
:features #{}
|
||||
:redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}]
|
||||
:undo-changes []}))
|
||||
|
||||
(t/deftest queued-edits-save-during-temporary-read-only-mode
|
||||
(doseq [read-only-event [(drw/context-lost)
|
||||
#(assoc-in % [:workspace-global :read-only?] true)
|
||||
#(assoc % :workspace-global {:read-only? true
|
||||
:preview-id (uuid/next)})]]
|
||||
(let [file-id (uuid/next)
|
||||
response (rx/subject)
|
||||
errors (atom [])
|
||||
store (ptk/store {:state {:permissions {:can-edit true}
|
||||
:files {file-id {:id file-id :revn 0}}}
|
||||
:on-error #(swap! errors conj %)})]
|
||||
(with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))]
|
||||
(try
|
||||
(ptk/emit! store (dps/initialize-persistence)
|
||||
(local-commit file-id)
|
||||
read-only-event
|
||||
::dps/force-persist)
|
||||
(rx/push! response {:revn 1})
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(t/is (empty? (get-in @store [:persistence :queue])))
|
||||
|
||||
(ptk/emit! store (drw/context-restored)
|
||||
#(assoc-in % [:workspace-global :read-only?] false)
|
||||
(local-commit file-id)
|
||||
::dps/force-persist)
|
||||
(rx/push! response {:revn 2})
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(t/is (empty? (get-in @store [:persistence :queue])))
|
||||
(t/is (empty? @errors))
|
||||
(finally
|
||||
(rx/dispose! store)
|
||||
(rx/end! response)))))))
|
||||
|
||||
(t/deftest historical-preview-cannot-create-local-commits
|
||||
(let [file-id (uuid/next)
|
||||
output (atom [])
|
||||
state {:current-file-id file-id
|
||||
:permissions {:can-edit true}
|
||||
:files {file-id {:id file-id :revn 0 :vern 0}}
|
||||
:workspace-global {:read-only? true :preview-id (uuid/next)}}
|
||||
event (dch/commit-changes {:redo-changes [] :undo-changes []})]
|
||||
(when-let [result (ptk/watch event state (rx/empty))]
|
||||
(->> result (rx/subs! #(swap! output conj %))))
|
||||
(t/is (empty? @output))))
|
||||
|
||||
(defn- with-watchdog
|
||||
[f]
|
||||
(let [clock (atom 0)
|
||||
ticks (rx/subject)
|
||||
response (rx/subject)
|
||||
reports (atom [])
|
||||
causes (atom [])
|
||||
render errors/generate-report
|
||||
file-id (uuid/next)
|
||||
store (ptk/store {:state {:current-file-id file-id
|
||||
:permissions {:can-edit true}
|
||||
:files {file-id {:id file-id :revn 0}}}
|
||||
:on-error #(t/is false (str %))})]
|
||||
(with-redefs [ct/now (mock/stub #(ct/inst @clock))
|
||||
rx/interval (mock/stub (fn [_] ticks))
|
||||
rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))
|
||||
st/state store
|
||||
errors/generate-report (fn [cause]
|
||||
(swap! causes conj cause)
|
||||
(render cause))
|
||||
errors/submit-report (fn [& params]
|
||||
(swap! reports conj (apply hash-map params)))]
|
||||
(try
|
||||
(ptk/emit! store (dps/initialize-persistence))
|
||||
(f {:clock clock :ticks ticks :response response :causes causes
|
||||
:reports reports :store store :file-id file-id})
|
||||
(finally
|
||||
(rx/dispose! store)
|
||||
(rx/end! ticks)
|
||||
(rx/end! response))))))
|
||||
|
||||
(t/deftest stalled-request-is-reported-once-without-discarding-edits
|
||||
(with-watchdog
|
||||
(fn [{:keys [clock ticks reports causes store file-id]}]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
||||
(reset! clock 300000)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (empty? @reports) "Five minutes must elapse before reporting")
|
||||
|
||||
;; More local edits must not reset the stalled request's clock.
|
||||
(reset! clock 300001)
|
||||
(ptk/emit! store (drw/context-lost)
|
||||
(local-commit file-id) ::dps/force-persist)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (= 1 (count @reports)))
|
||||
(t/is (= "handled-exception" (:event-name (first @reports))))
|
||||
(let [data (ex-data (first @causes))]
|
||||
(t/is (= :saving-stalled (:code data)))
|
||||
(t/is (= file-id (:file-id data)))
|
||||
(t/is (true? (:render-context-lost? data))))
|
||||
|
||||
(reset! clock 900000)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (= 1 (count @reports)) "Do not repeat a report for the same stall")
|
||||
(t/is (= :saving (get-in @store [:persistence :status])))
|
||||
(t/is (= 2 (count (get-in @store [:persistence :queue])))))))
|
||||
|
||||
(t/deftest successful-saves-reset-the-stall-clock-and-allow-a-new-report
|
||||
(with-watchdog
|
||||
(fn [{:keys [clock ticks response reports store file-id]}]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist
|
||||
(local-commit file-id) ::dps/force-persist)
|
||||
(reset! clock 290000)
|
||||
(rx/push! response {:revn 1})
|
||||
(reset! clock 300001)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (empty? @reports) "The queue is making progress")
|
||||
|
||||
(reset! clock 590001)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (= 1 (count @reports)) "The second request has now stalled")
|
||||
|
||||
(rx/push! response {:revn 2})
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(reset! clock 1000000)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (= 1 (count @reports)) "A saved file must not be reported")
|
||||
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
||||
(reset! clock 1300001)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (= 2 (count @reports)) "A later stall gets its own report"))))
|
||||
|
||||
(t/deftest pending-edits-are-monitored-without-extending-the-deadline
|
||||
(with-watchdog
|
||||
(fn [{:keys [clock ticks reports store]}]
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (empty? @reports) "An idle file must not be reported")
|
||||
(ptk/emit! store (#'dps/update-status :pending))
|
||||
(reset! clock 300001)
|
||||
(ptk/emit! store (#'dps/update-status :pending))
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (= 1 (count @reports)))
|
||||
(ptk/emit! store (#'dps/update-status :error))
|
||||
(reset! clock 900000)
|
||||
(rx/push! ticks :tick)
|
||||
(t/is (= 1 (count @reports)) "Do not report an already failed save"))))
|
||||
|
||||
(t/deftest reinitializing-persistence-replaces-the-watchdog
|
||||
(let [active-timers (atom 0)
|
||||
ticks (rx/subject)
|
||||
store (ptk/store {:state {} :on-error #(t/is false (str %))})]
|
||||
(with-redefs [rx/interval (mock/stub
|
||||
(fn [_]
|
||||
(rx/create
|
||||
(fn [subscriber]
|
||||
(swap! active-timers inc)
|
||||
(let [subscription (.subscribe ticks subscriber)]
|
||||
(fn []
|
||||
(rx/dispose! subscription)
|
||||
(swap! active-timers dec)))))))]
|
||||
(try
|
||||
(ptk/emit! store (dps/initialize-persistence))
|
||||
(t/is (= 1 @active-timers))
|
||||
(ptk/emit! store (dps/initialize-persistence))
|
||||
(t/is (= 1 @active-timers))
|
||||
(finally
|
||||
(rx/dispose! store)
|
||||
(rx/end! ticks))))
|
||||
(t/is (zero? @active-timers))))
|
||||
|
||||
(defn- with-persistence
|
||||
[f]
|
||||
(let [file-id (uuid/next)
|
||||
response (rx/subject)
|
||||
failures (atom [])
|
||||
requests (atom [])
|
||||
store (ptk/store {:state {:current-file-id file-id
|
||||
:permissions {:can-edit true}
|
||||
:files {file-id {:id file-id :revn 0}}}
|
||||
:on-error #(t/is false (str %))})]
|
||||
(with-redefs [rp/cmd! (mock/stub (fn [cmd params]
|
||||
(swap! requests conj [cmd params])
|
||||
(rx/take 1 response)))
|
||||
errors/flash (fn [& {:keys [cause]}]
|
||||
(swap! failures conj cause))]
|
||||
(try
|
||||
(ptk/emit! store (dps/initialize-persistence))
|
||||
(f {:file-id file-id :response response :failures failures
|
||||
:requests requests :store store})
|
||||
(finally
|
||||
(rx/dispose! store)
|
||||
(rx/end! response))))))
|
||||
|
||||
(t/deftest permission-loss-fails-without-discarding-queued-edits
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id requests failures store]}]
|
||||
(ptk/emit! store (local-commit file-id)
|
||||
#(assoc-in % [:permissions :can-edit] false)
|
||||
::dps/force-persist)
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
|
||||
(t/is (empty? @requests))
|
||||
(t/is (= 1 (count @failures)))
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist
|
||||
(#'dps/update-status :pending))
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(t/is (= 2 (count (get-in @store [:persistence :queue])))))))
|
||||
|
||||
(t/deftest failed-request-retains-the-queue-and-is-not-retried-on-initialization
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id response requests store]}]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist
|
||||
(local-commit file-id) ::dps/force-persist)
|
||||
(.error response (ex-info "Connection lost" {:type :network}))
|
||||
(ptk/emit! store (dps/initialize-persistence))
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(t/is (= 2 (count (get-in @store [:persistence :queue]))))
|
||||
(t/is (= 1 (count @requests))))))
|
||||
|
||||
(t/deftest save-failures-use-a-translated-warning-except-for-authentication
|
||||
(doseq [cause-type [:network :offline :authentication]]
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id response store]}]
|
||||
(let [notifications (atom [])]
|
||||
(with-redefs [errors/flash (fn [& params]
|
||||
(swap! notifications conj (apply hash-map params)))
|
||||
i18n/tr (mock/stub #(str "translated:" %))]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
||||
(.error response (ex-info "Raw transport details" {:type cause-type}))
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(let [data (get-in @store [:persistence :error])]
|
||||
(ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data))))
|
||||
(if (= cause-type :authentication)
|
||||
(t/is (empty? @notifications))
|
||||
(t/is (= ["translated:errors.save-failed"]
|
||||
(mapv :hint @notifications))))))))))
|
||||
|
||||
(t/deftest missing-commit-is-an-error-instead-of-skipping-changes
|
||||
(with-persistence
|
||||
(fn [{:keys [requests store]}]
|
||||
(let [id (uuid/next)]
|
||||
(ptk/emit! store
|
||||
#(assoc % :persistence {:queue (conj #queue [] id)
|
||||
:index {} :run-id (uuid/next)
|
||||
:status :saving})
|
||||
(dps/initialize-persistence))
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(t/is (= [id] (vec (get-in @store [:persistence :queue]))))
|
||||
(t/is (empty? @requests))))))
|
||||
|
||||
(t/deftest initialization-recovers-an-unsent-commit-with-a-dangling-run-id
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id response requests store]}]
|
||||
(let [commit (assoc @(local-commit file-id) :changes [])
|
||||
id (:id commit)]
|
||||
(ptk/emit! store
|
||||
#(assoc % :persistence {:queue (conj #queue [] id)
|
||||
:index {id commit} :run-id (uuid/next)
|
||||
:status :saving})
|
||||
(dps/initialize-persistence))
|
||||
(t/is (= 1 (count @requests)))
|
||||
(rx/push! response {:revn 1})
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(t/is (empty? (get-in @store [:persistence :queue])))))))
|
||||
|
||||
(t/deftest an-active-request-is-never-sent-twice
|
||||
(doseq [interrupt [[(dps/initialize-persistence)]
|
||||
[(ptk/data-event ::dps/error)]]]
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id response requests store]}]
|
||||
(apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt)
|
||||
(ptk/emit! store (dps/initialize-persistence))
|
||||
(t/is (= 1 (count @requests)))
|
||||
(rx/push! response {:revn 1})
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(t/is (empty? (get-in @store [:persistence :queue])))))))
|
||||
|
||||
(t/deftest permission-restoration-resumes-only-unsent-edits
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id response requests store]}]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist
|
||||
(local-commit file-id) ::dps/force-persist
|
||||
#(assoc-in % [:permissions :can-edit] false))
|
||||
(rx/push! response {:revn 1})
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
|
||||
(ptk/emit! store
|
||||
#(assoc-in % [:permissions :can-edit] true)
|
||||
(ptk/data-event :app.main.data.common/change-team-role))
|
||||
(t/is (= 2 (count @requests)))
|
||||
(rx/push! response {:revn 2})
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(t/is (empty? (get-in @store [:persistence :queue]))))))
|
||||
|
||||
(t/deftest recovery-keeps-an-acknowledgment-received-without-a-runner
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id response requests store]}]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist
|
||||
(ptk/data-event ::dps/error))
|
||||
(rx/push! response {:revn 1})
|
||||
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
|
||||
(ptk/emit! store (dps/initialize-persistence))
|
||||
(t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again")
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(t/is (empty? (get-in @store [:persistence :queue]))))))
|
||||
|
||||
(t/deftest recovery-reports-an-unknown-request-outcome-without-replaying-it
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id requests store]}]
|
||||
(let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next))
|
||||
id (:id commit)]
|
||||
(ptk/emit! store
|
||||
#(assoc % :persistence {:queue (conj #queue [] id)
|
||||
:index {id commit} :status :saving})
|
||||
(dps/initialize-persistence))
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(t/is (= :save-outcome-unknown (get-in @store [:persistence :error :code])))
|
||||
(t/is (= [id] (vec (get-in @store [:persistence :queue]))))
|
||||
(t/is (empty? @requests))))))
|
||||
|
||||
(t/deftest initialization-flushes-buffered-edits-without-duplicating-them
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id response requests store]}]
|
||||
(ptk/emit! store (local-commit file-id)
|
||||
(dps/initialize-persistence)
|
||||
::dps/force-persist)
|
||||
(t/is (= 1 (count @requests)))
|
||||
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
|
||||
(rx/push! response {:revn 1})
|
||||
(t/is (= :saved (get-in @store [:persistence :status]))))))
|
||||
|
||||
(t/deftest synchronous-save-results-do-not-leave-a-dangling-runner
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id store]}]
|
||||
(with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/of {:revn 1})))]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
||||
(t/is (= :saved (get-in @store [:persistence :status])))
|
||||
(t/is (empty? (get-in @store [:persistence :queue])))))))
|
||||
|
||||
(t/deftest empty-or-invalid-save-responses-preserve-the-queue-as-failed
|
||||
(doseq [result [(rx/empty) (rx/of nil) (rx/of {:revn -1})]]
|
||||
(with-persistence
|
||||
(fn [{:keys [file-id store]}]
|
||||
(with-redefs [rp/cmd! (mock/stub (fn [_ _] result))]
|
||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
||||
(t/is (= :error (get-in @store [:persistence :status])))
|
||||
(t/is (= :invalid-save-response (get-in @store [:persistence :error :code])))
|
||||
(t/is (= 1 (count (get-in @store [:persistence :queue])))))))))
|
||||
189
frontend/test/frontend_tests/data/workspace_versions_test.cljs
Normal file
189
frontend/test/frontend_tests/data/workspace_versions_test.cljs
Normal file
@ -0,0 +1,189 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns frontend-tests.data.workspace-versions-test
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.persistence :as dps]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.pages :as dwpg]
|
||||
[app.main.data.workspace.versions :as versions]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.repo :as rp]
|
||||
[app.util.timers :as tm]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(t/deftest version-creation-rejects-save-failure-and-timeout
|
||||
(doseq [failure [:error :timeout]
|
||||
operation [:create :plugin-create]]
|
||||
(t/testing (str operation " after " failure)
|
||||
(let [file-id (uuid/next)
|
||||
resolved (atom [])
|
||||
rejected (atom [])
|
||||
requests (atom [])
|
||||
state {:current-file-id file-id}
|
||||
event (case operation
|
||||
:create (versions/create-version)
|
||||
:plugin-create (versions/create-version-from-plugins
|
||||
file-id "Version"
|
||||
#(swap! resolved conj %) #(swap! rejected conj %)))]
|
||||
(with-redefs [refs/persistence (atom {:status (if (= failure :error) :error :saving)
|
||||
:error {:type :persistence :code :save-permission-denied}})
|
||||
rp/cmd! (mock/stub (fn [cmd _]
|
||||
(swap! requests conj cmd)
|
||||
(rx/of nil)))
|
||||
rx/timeout (mock/stub (fn [_ fallback source]
|
||||
(if (= failure :timeout) fallback source)))]
|
||||
(->> (ptk/watch event state (rx/empty))
|
||||
(rx/subs! (fn [_]) #(swap! rejected conj %)))
|
||||
(t/is (empty? @requests) "Do not create a version without saving")
|
||||
(t/is (empty? @resolved) "Do not resolve the plugin promise on failure")
|
||||
(t/is (= 1 (count @rejected)))
|
||||
(t/is (= (if (= failure :error) :save-permission-denied :save-timeout)
|
||||
(:code (ex-data (first @rejected))))))))))
|
||||
|
||||
(t/deftest leaving-the-workspace-clears-the-preview-flags
|
||||
(let [team-id (uuid/next)
|
||||
file-id (uuid/next)
|
||||
other-id (uuid/next)
|
||||
output (atom [])
|
||||
state (-> {:current-file-id file-id
|
||||
:current-team-id team-id
|
||||
:permissions {:can-edit true}
|
||||
:files {file-id {:id file-id :revn 0 :vern 0}}
|
||||
:workspace-global {:read-only? true :preview-id (uuid/next)}}
|
||||
(as-> $ (ptk/update (dw/finalize-workspace team-id file-id) $))
|
||||
(assoc :current-file-id other-id)
|
||||
(assoc-in [:files other-id] {:id other-id :revn 0 :vern 0}))]
|
||||
(t/is (nil? (get-in state [:workspace-global :preview-id])))
|
||||
(when-let [result (ptk/watch (dch/commit-changes {:redo-changes [] :undo-changes []})
|
||||
state (rx/empty))]
|
||||
(->> result (rx/subs! #(swap! output conj %))))
|
||||
(t/is (= 1 (count @output))
|
||||
"Editing must work again on the next file opened in the tab")))
|
||||
|
||||
(t/deftest strict-persistence-wait-rejects-a-real-timeout
|
||||
(t/async done
|
||||
(mock/with-mocks
|
||||
{refs/persistence (atom {:status :saving})}
|
||||
(fn [finish]
|
||||
(->> (dps/wait-persisted-or-error 0)
|
||||
(rx/subs! (fn [_]
|
||||
(t/is false "The stalled save must not succeed")
|
||||
(finish))
|
||||
(fn [cause]
|
||||
(t/is (= :save-timeout (:code (ex-data cause))))
|
||||
(finish)))))
|
||||
done)))
|
||||
|
||||
(t/deftest strict-persistence-wait-observes-a-later-failure
|
||||
(let [pstate (atom {:status :saving})
|
||||
rejected (atom [])
|
||||
resolved (atom [])]
|
||||
(with-redefs [refs/persistence pstate]
|
||||
(let [subscription (->> (dps/wait-persisted-or-error)
|
||||
(rx/subs! #(swap! resolved conj %) #(swap! rejected conj %)))]
|
||||
(try
|
||||
(reset! pstate {:status :error :error {:code :missing-commit}})
|
||||
(t/is (empty? @resolved))
|
||||
(t/is (= :missing-commit (:code (ex-data (first @rejected)))))
|
||||
(finally
|
||||
(rx/dispose! subscription)))))))
|
||||
|
||||
(t/deftest failed-restore-returns-to-the-live-file-before-reporting
|
||||
;; The plugin promise is rejected on a later tick, so every assertion about
|
||||
;; the rejection runs once the event loop has drained.
|
||||
(t/async done
|
||||
(let [deferred (atom [])]
|
||||
(doseq [operation [:ui :plugin]
|
||||
failure [:saving :timeout :restore]
|
||||
preview? [true false]]
|
||||
(let [label (str operation " after " failure ", preview=" preview?)
|
||||
file-id (uuid/next)
|
||||
version-id (uuid/next)
|
||||
backup {:id file-id :revn 10}
|
||||
snapshot {:id file-id :revn 5}
|
||||
rejected (atom [])
|
||||
resolved (atom [])
|
||||
requests (atom [])
|
||||
initialized (atom [])
|
||||
at-reject (atom nil)
|
||||
store (ptk/store {:state (cond-> {:current-file-id file-id
|
||||
:files {file-id backup}}
|
||||
preview?
|
||||
(assoc :files {file-id snapshot}
|
||||
:workspace-global {:read-only? true :preview-id version-id}
|
||||
:workspace-versions {:backup backup}))
|
||||
:on-error #(swap! rejected conj %)})
|
||||
reject (fn [cause]
|
||||
(reset! at-reject
|
||||
{:file (get-in @store [:files file-id])
|
||||
:preview-id (get-in @store [:workspace-global :preview-id])})
|
||||
(swap! rejected conj cause))
|
||||
event (if (= operation :ui)
|
||||
(#'versions/restore-version version-id)
|
||||
(versions/restore-version-from-plugin
|
||||
file-id version-id #(swap! resolved conj %) reject))]
|
||||
(t/testing label
|
||||
(with-redefs [refs/persistence (atom {:status (case failure
|
||||
:saving :error :timeout :saving :saved)})
|
||||
rp/cmd! (mock/stub (fn [cmd _]
|
||||
(swap! requests conj cmd)
|
||||
(rx/throw (ex-info "Restore failed" {:type :internal}))))
|
||||
rx/timeout (mock/stub (fn [_ fallback source]
|
||||
(if (= failure :timeout) fallback source)))
|
||||
dwpg/initialize-page (mock/stub
|
||||
(fn [_ _]
|
||||
(fn [state]
|
||||
(swap! initialized conj (get-in state [:files file-id]))
|
||||
state)))]
|
||||
(ptk/emit! store event)
|
||||
(t/is (empty? @resolved))
|
||||
(t/is (= (if (= failure :restore) [:restore-file-snapshot] []) @requests)
|
||||
"Do not restore a version without saving")
|
||||
(t/is (= backup (get-in @store [:files file-id])))
|
||||
(t/is (nil? (get-in @store [:workspace-global :read-only?])))
|
||||
(t/is (nil? (get-in @store [:workspace-global :preview-id])))
|
||||
(t/is (nil? (get-in @store [:workspace-versions :backup])))
|
||||
(t/is (= (if preview? [backup] []) @initialized))))
|
||||
(swap! deferred conj
|
||||
(fn []
|
||||
(t/testing label
|
||||
(t/is (= 1 (count @rejected)))
|
||||
(when (= operation :plugin)
|
||||
(t/is (= {:file backup :preview-id nil} @at-reject)
|
||||
"The live file is back before the plugin promise rejects")))
|
||||
(rx/dispose! store)))))
|
||||
(tm/schedule 50 (fn []
|
||||
(doseq [check @deferred] (check))
|
||||
(done))))))
|
||||
|
||||
(t/deftest successful-restore-clears-preview-only-after-loading
|
||||
(let [file-id (uuid/next)
|
||||
state {:current-file-id file-id
|
||||
:current-team-id (uuid/next)
|
||||
:current-page-id (uuid/next)
|
||||
:workspace-global {:read-only? true :preview-id (uuid/next)}
|
||||
:workspace-versions {:backup {:id file-id}}}
|
||||
stream (rx/subject)
|
||||
events (atom [])
|
||||
cleanup #(filter (ptk/type? ::versions/clear-preview-state) @events)
|
||||
sub (->> (ptk/watch (#'versions/initialize-version) state stream)
|
||||
(rx/subs! #(swap! events conj %)))]
|
||||
(try
|
||||
(t/is (empty? (cleanup)))
|
||||
(rx/push! stream (ptk/data-event ::dw/bundle-fetched nil))
|
||||
(t/is (= 1 (count (cleanup))))
|
||||
(let [result (ptk/update (first (cleanup)) state)]
|
||||
(t/is (nil? (get-in result [:workspace-global :read-only?])))
|
||||
(t/is (nil? (get-in result [:workspace-global :preview-id])))
|
||||
(t/is (nil? (get-in result [:workspace-versions :backup]))))
|
||||
(finally
|
||||
(rx/dispose! sub)))))
|
||||
@ -15,7 +15,10 @@
|
||||
- organization SSO recovery – expired SSO sessions go back to the provider
|
||||
- invalid-sso-config handler – requires :organization-id to promote to :sso-error"
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.persistence :as dps]
|
||||
[app.main.errors :as errors]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
@ -396,3 +399,48 @@
|
||||
(t/is (= :validation (:type assigned)))
|
||||
(t/is (nil? (:organization-id assigned)))
|
||||
(t/is (= :invalid-sso-config (:code assigned))))))
|
||||
|
||||
(t/deftest persistence-notifications-do-not-expire-but-other-flashes-do
|
||||
(doseq [[notify timeout] [[#(errors/flash :hint "Ordinary error") 5000]
|
||||
[#(errors/flash :hint "Custom error" :timeout 1000) 1000]
|
||||
[#(errors/flash-persistence nil) nil]]]
|
||||
(let [scheduled (atom [])
|
||||
events (atom [])]
|
||||
(with-redefs [tm/schedule (mock/stub #(swap! scheduled conj %))
|
||||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))]
|
||||
(notify)
|
||||
(t/is (empty? @events) "Keep notification delivery asynchronous")
|
||||
(doseq [callback @scheduled] (callback))
|
||||
(t/is (= 1 (count @events)))
|
||||
(let [state (ptk/update (first @events) {})]
|
||||
(t/is (= timeout (get-in state [:notification :timeout])))
|
||||
(t/is (= :visible (get-in state [:notification :status]))))))))
|
||||
|
||||
(t/deftest persistence-waiters-do-not-report-an-already-handled-failure
|
||||
(let [reports (atom [])
|
||||
rejected (atom [])
|
||||
pstate (atom {:status :saving})
|
||||
store (ptk/store {:state {} :on-error errors/on-error})
|
||||
cause (ex-info "Save failed" {:type :network})]
|
||||
(with-redefs [refs/persistence pstate
|
||||
errors/submit-report (fn [& params]
|
||||
(swap! reports conj (apply hash-map params)))
|
||||
tm/schedule (mock/stub (fn [_]))]
|
||||
(try
|
||||
(ptk/emit! store (#'dps/persistence-failed (uuid/next) cause))
|
||||
(reset! pstate (:persistence @store))
|
||||
(dotimes [_ 2]
|
||||
(->> (dps/wait-persisted-or-error)
|
||||
(rx/subs! (fn [_] (t/is false "A failed save must still reject"))
|
||||
(fn [error]
|
||||
(swap! rejected conj error)
|
||||
(errors/on-error error)))))
|
||||
(t/is (= 2 (count @rejected)))
|
||||
(t/is (= 1 (count @reports)))
|
||||
;; A standalone timeout and a later save failure are new incidents.
|
||||
(errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout}))
|
||||
(t/is (= 2 (count @reports)))
|
||||
(ptk/emit! store (#'dps/persistence-failed (uuid/next) cause))
|
||||
(t/is (= 3 (count @reports)))
|
||||
(finally
|
||||
(rx/dispose! store))))))
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[frontend-tests.data.dashboard-test]
|
||||
[frontend-tests.data.exports-assets-test]
|
||||
[frontend-tests.data.nitrate-test]
|
||||
[frontend-tests.data.persistence-test]
|
||||
[frontend-tests.data.profile-test]
|
||||
[frontend-tests.data.repo-test]
|
||||
[frontend-tests.data.store-test]
|
||||
@ -28,6 +29,7 @@
|
||||
[frontend-tests.data.workspace-shortcuts-test]
|
||||
[frontend-tests.data.workspace-texts-test]
|
||||
[frontend-tests.data.workspace-thumbnails-test]
|
||||
[frontend-tests.data.workspace-versions-test]
|
||||
[frontend-tests.errors-test]
|
||||
[frontend-tests.fonts-test]
|
||||
[frontend-tests.helpers-shapes-test]
|
||||
@ -122,6 +124,7 @@
|
||||
'frontend-tests.data.comments-filters-test
|
||||
'frontend-tests.data.dashboard-test
|
||||
'frontend-tests.data.nitrate-test
|
||||
'frontend-tests.data.persistence-test
|
||||
'frontend-tests.data.profile-test
|
||||
'frontend-tests.data.repo-test
|
||||
'frontend-tests.data.store-test
|
||||
@ -139,6 +142,7 @@
|
||||
'frontend-tests.data.workspace-shortcuts-test
|
||||
'frontend-tests.data.workspace-texts-test
|
||||
'frontend-tests.data.workspace-thumbnails-test
|
||||
'frontend-tests.data.workspace-versions-test
|
||||
'frontend-tests.errors-test
|
||||
'frontend-tests.fonts-test
|
||||
'frontend-tests.helpers-shapes-test
|
||||
|
||||
@ -1825,6 +1825,13 @@ msgstr "Your profile has emails muted (spam reports or high bounces)."
|
||||
msgid "errors.registration-disabled"
|
||||
msgstr "The registration is currently disabled."
|
||||
|
||||
#: src/app/main/errors.cljs:216
|
||||
msgid "errors.save-failed"
|
||||
msgstr ""
|
||||
"Your latest changes are not confirmed as saved. Saving may not resume "
|
||||
"automatically. Do not close or reload this tab: you may lose changes. "
|
||||
"Contact support for help."
|
||||
|
||||
#: src/app/main/errors.cljs:337
|
||||
msgid "errors.svg-parser.invalid-svg"
|
||||
msgstr "SVG is invalid or malformed"
|
||||
@ -10524,4 +10531,4 @@ msgid "labels.sso-error.retry"
|
||||
msgstr "Try again"
|
||||
|
||||
msgid "dashboard.invite-profile-disabled"
|
||||
msgstr "You don't have permission to invite people to this team"
|
||||
msgstr "You don't have permission to invite people to this team"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user