penpot/frontend/test/frontend_tests/data/workspace_versions_test.cljs
alonso.torres 707026acbb ✨ Label the copy kept before a repair in the History panel
Before it repairs a damaged file, the backend keeps a copy of it as a
system snapshot, and the repair notice points to the version history to
find it. The History panel listed that copy among the autosaves, so it
could not be told apart.

The History panel lists the copies kept before a repair as their own
entry per day, "Before automatic repair, <date>", above that day's
autosaves, and the preview of such a copy uses the same label.
2026-11-05 10:56:46 +01:00

223 lines
12 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns frontend-tests.data.workspace-versions-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.notifications :as ntf]
[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.main.ui.workspace.sidebar.versions :as versions-ui]
[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 [])
emitted (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! #(swap! emitted conj %) #(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")
(if (= operation :create)
;; The save failure is reported on its own; the user is told
;; why the version is missing instead
(let [notice (some #(when (= ::ntf/show (ptk/type %))
(:notification (ptk/update % {})))
@emitted)]
(t/is (empty? @rejected))
(t/is (= "workspace.versions.create-failed-unsaved" (:content notice)))
(t/is (= :warning (:level notice))))
(do
(t/is (= 1 (count @rejected)))
(t/is (= (if (= failure :error) :save-permission-denied :save-timeout)
(:code (ex-data (first @rejected))))))))))))
(t/deftest repair-snapshots-are-told-apart-from-autosaves
(t/is (versions/repair-snapshot? {:label "internal/repair/5" :created-by "system"}))
(t/is (not (versions/repair-snapshot? {:label "internal/snapshot/20" :created-by "system"})))
(t/is (not (versions/repair-snapshot? {:label "My version" :created-by "user"})))
(t/is (not (versions/repair-snapshot? {:created-by "user"}))))
;; Scenario: on one day the backend kept two autosaves and the copy from
;; before a repair. Proves: the history shows the repair copy as its own
;; entry, above the day's autosaves, so it can be found by its label.
(t/deftest the-history-lists-the-copy-before-a-repair-apart
(let [at (fn [hh] (ct/inst (str "2026-11-03T" hh ":00:00Z")))
data [{:id (uuid/next) :created-by "system" :label "internal/snapshot/0" :created-at (at "10")}
{:id (uuid/next) :created-by "system" :label "internal/repair/5" :created-at (at "11")}
{:id (uuid/next) :created-by "system" :label "internal/snapshot/20" :created-at (at "12")}]
entries (#'versions-ui/group-snapshots data)]
(t/is (= [:repair :autosave] (mapv :kind entries)))
(t/is (= [["internal/repair/5"] ["internal/snapshot/0" "internal/snapshot/20"]]
(mapv #(mapv :label (:snapshots %)) entries)))))
(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)))))