mirror of
https://github.com/penpot/penpot.git
synced 2026-11-08 19:16:56 +00:00
Reloading the open file (a peer's restore, the fallback of a rollback) runs initialize-workspace again without leaving the workspace. The streams the first run started kept running next to the new ones, so a commit added one undo entry per reload and each notification was handled twice. The old subscription also sent its unsubscribe after the new subscribe, which ended the new one on the server. A new initialize-workspace, text reflow, shape layout or notification subscription stops the one before it, and a subscription replaced by one to the same file sends no unsubscribe.
240 lines
11 KiB
Clojure
240 lines
11 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-notifications-test
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.main.data.changes :as dch]
|
|
[app.main.data.modal :as modal]
|
|
[app.main.data.notifications :as ntf]
|
|
[app.main.data.websocket :as dws]
|
|
[app.main.data.workspace :as-alias dw]
|
|
[app.main.data.workspace.notifications :as dwntf]
|
|
[app.util.i18n :as i18n]
|
|
[beicon.v2.core :as rx]
|
|
[cljs.test :as t :include-macros true]
|
|
[frontend-tests.helpers.async :as async]
|
|
[frontend-tests.helpers.mock :as mock]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
(defn- file-change-msg
|
|
[extra]
|
|
(merge {:type :file-change
|
|
:profile-id (uuid/next)
|
|
:file-id (uuid/next)
|
|
:session-id (uuid/next)
|
|
:revn 1
|
|
:vern 0
|
|
:changes []}
|
|
extra))
|
|
|
|
(defn- ^:async watch-file-change
|
|
[msg]
|
|
(let [events (atom [])]
|
|
(await (async/observe (ptk/watch (dwntf/handle-file-change msg) {} (rx/empty))
|
|
:on-next #(swap! events conj %)))
|
|
@events))
|
|
|
|
(t/deftest ^:async repaired-file-change-applies-and-notifies
|
|
(let [events (await (watch-file-change (file-change-msg {:repaired true})))
|
|
types (mapv ptk/type events)
|
|
show (d/seek #(= ::ntf/show (ptk/type %)) events)
|
|
state (ptk/update show {})]
|
|
(t/is (= [::dch/commit ::ntf/show] types))
|
|
(t/is (= :info (get-in state [:notification :level])))
|
|
(t/is (nil? (get-in state [:notification :timeout]))
|
|
"the toast stays until the user closes it")
|
|
(t/is (= (i18n/tr "workspace.notifications.file-repaired")
|
|
(get-in state [:notification :content])))))
|
|
|
|
(t/deftest ^:async ordinary-file-change-does-not-notify
|
|
(let [events (await (watch-file-change (file-change-msg {})))]
|
|
(t/is (= [::dch/commit] (mapv ptk/type events)))))
|
|
|
|
(defn- restored-events
|
|
"Events the watch of `handle-file-restored` emits for `msg` on `state`."
|
|
[state msg]
|
|
(let [events (atom [])]
|
|
(-> (async/observe (ptk/watch (dwntf/handle-file-restored msg) state nil)
|
|
:on-next #(swap! events conj %))
|
|
(.then (fn [_] @events)))))
|
|
|
|
(defn- shown-modal
|
|
[events]
|
|
(some #(when (= ::modal/show-modal (ptk/type %))
|
|
(::modal/modal (ptk/update % {})))
|
|
events))
|
|
|
|
;; Scenario: a peer restores an earlier version of the open file. The file
|
|
;; reloads and a modal names the peer. Proves: the reload is not silent.
|
|
(t/deftest ^:async a-peer-restore-reloads-and-names-the-peer
|
|
(let [file-id (uuid/next)
|
|
session-id (uuid/next)
|
|
profile-id (uuid/next)
|
|
state {:current-file-id file-id
|
|
:files {file-id {:id file-id :vern 1}}
|
|
:workspace-presence {session-id {:profile-id profile-id}}
|
|
:profiles {profile-id {:id profile-id :fullname "Ada"}}}
|
|
events (await (restored-events state {:type :file-restored
|
|
:file-id file-id
|
|
:session-id session-id
|
|
:vern 2}))
|
|
modal (shown-modal events)]
|
|
(t/is (some #(= ::dw/reload-current-file (ptk/type %)) events))
|
|
(t/is (= :file-restored (:type modal)))
|
|
(t/is (= "Ada" (get-in modal [:props :fullname])))
|
|
(t/is (false? (get-in modal [:props :unsaved])))))
|
|
|
|
;; Scenario: a peer restores a version they named, while this tab has a
|
|
;; rename not saved yet and the restorer has no presence. Proves: the modal
|
|
;; names the restorer by profile and the version by its label, and says the
|
|
;; unsaved rename is lost.
|
|
(t/deftest ^:async a-peer-restore-names-the-version-and-the-lost-edits
|
|
(let [file-id (uuid/next)
|
|
profile-id (uuid/next)
|
|
created-at (ct/now)
|
|
commit-id (uuid/next)
|
|
state {:current-file-id file-id
|
|
:files {file-id {:id file-id :vern 1}}
|
|
:profiles {profile-id {:id profile-id :fullname "Ada"}}
|
|
:persistence {:queue [commit-id]
|
|
:index {commit-id {:id commit-id
|
|
:file-id file-id
|
|
:file-vern 1
|
|
:commits [{:redo-changes [{:type :mod-page}]}]}}}}
|
|
events (await (restored-events state {:type :file-restored
|
|
:file-id file-id
|
|
:session-id (uuid/next)
|
|
:profile-id profile-id
|
|
:label "Hand-off v2"
|
|
:created-at created-at
|
|
:vern 2}))
|
|
props (:props (shown-modal events))]
|
|
(t/is (= "Ada" (:fullname props)))
|
|
(t/is (= "Hand-off v2" (:label props)))
|
|
(t/is (= created-at (:created-at props)))
|
|
(t/is (true? (:unsaved props)))))
|
|
|
|
;; Scenario: the restoring session left before its message arrived, so it
|
|
;; has no presence. Proves: the modal still shows, without a name.
|
|
(t/deftest ^:async a-restore-from-an-unknown-session-shows-no-name
|
|
(let [file-id (uuid/next)
|
|
state {:current-file-id file-id
|
|
:files {file-id {:id file-id :vern 1}}}
|
|
events (await (restored-events state {:type :file-restored
|
|
:file-id file-id
|
|
:session-id (uuid/next)
|
|
:vern 2}))
|
|
modal (shown-modal events)]
|
|
(t/is (= :file-restored (:type modal)))
|
|
(t/is (nil? (get-in modal [:props :fullname])))))
|
|
|
|
;; Scenario: the file already shows the restored version. Proves: no reload
|
|
;; and no modal for a version the user already sees.
|
|
(t/deftest ^:async a-restore-of-the-loaded-version-does-nothing
|
|
(let [file-id (uuid/next)
|
|
state {:current-file-id file-id
|
|
:files {file-id {:id file-id :vern 2}}}
|
|
events (await (restored-events state {:type :file-restored
|
|
:file-id file-id
|
|
:session-id (uuid/next)
|
|
:vern 2}))]
|
|
(t/is (empty? events))))
|
|
|
|
(defn- ^:async subscription-messages
|
|
"Runs `scenario` with a store over `team-id`, `dws/send` and the pointer
|
|
handler stubbed. `scenario` gets the store and a function that delivers
|
|
a peer's pointer message for a file. Resolves to the messages sent over
|
|
the websocket and the number of handled pointer messages."
|
|
[team-id scenario]
|
|
(let [sent (atom [])
|
|
handled (atom 0)
|
|
result (atom nil)
|
|
store (ptk/store {:state {:profile-id (uuid/next)
|
|
:session-id (uuid/next)
|
|
:teams {team-id {:id team-id}}}
|
|
:on-error #(t/is false (str %))})
|
|
pointer (fn [file-id]
|
|
(ptk/emit! store (ptk/data-event ::dws/message
|
|
{:type :pointer-update
|
|
:topic file-id
|
|
:file-id file-id
|
|
:session-id (uuid/next)})))]
|
|
(await
|
|
(mock/with-mocks*
|
|
{dws/send (mock/stub (fn [msg]
|
|
(swap! sent conj msg)
|
|
(ptk/data-event ::sent msg)))
|
|
dwntf/handle-pointer-update (mock/stub (fn [_]
|
|
(swap! handled inc)
|
|
(ptk/data-event ::handled {})))}
|
|
(try
|
|
(await (scenario store pointer))
|
|
;; Taken before the store ends, which ends the subscriptions too
|
|
(reset! result {:sent (mapv #(select-keys % [:type :file-id]) @sent)
|
|
:handled @handled})
|
|
(finally
|
|
(rx/dispose! store)))))
|
|
@result))
|
|
|
|
;; Scenario: the open file reloads three times (a peer's restore, the
|
|
;; restorer's own version reload, the rollback fallback), each starting a
|
|
;; new subscription, and then a peer's message arrives. Proves: one live
|
|
;; subscription per open file, so each message is handled once, and no
|
|
;; unsubscribe ends the server's subscription of the reloaded file.
|
|
(t/deftest ^:async a-reload-replaces-the-file-subscription
|
|
(let [team-id (uuid/next)
|
|
file-id (uuid/next)
|
|
result (await
|
|
(subscription-messages
|
|
team-id
|
|
(^:async fn [store pointer]
|
|
(ptk/emit! store
|
|
(dwntf/initialize team-id file-id)
|
|
(dwntf/initialize team-id file-id)
|
|
(dwntf/initialize team-id file-id))
|
|
(await (async/settle))
|
|
(pointer file-id)
|
|
(await (async/settle)))))]
|
|
(t/is (= 1 (:handled result)) "the message is handled once")
|
|
(t/is (= 3 (count (filter #(= :subscribe-file (:type %)) (:sent result)))))
|
|
(t/is (not-any? #(= :unsubscribe-file (:type %)) (:sent result)))))
|
|
|
|
;; Scenario: the user leaves a file, and in another case opens another file
|
|
;; without leaving the first one. Proves: the subscription ends with an
|
|
;; unsubscribe of the file it was for, and the old file's messages are no
|
|
;; longer handled.
|
|
(t/deftest ^:async leaving-or-switching-files-ends-the-file-subscription
|
|
(let [team-id (uuid/next)
|
|
file-a (uuid/next)
|
|
file-b (uuid/next)
|
|
left (await
|
|
(subscription-messages
|
|
team-id
|
|
(^:async fn [store _]
|
|
(ptk/emit! store
|
|
(dwntf/initialize team-id file-a)
|
|
(dwntf/finalize file-a))
|
|
(await (async/settle)))))
|
|
switch (await
|
|
(subscription-messages
|
|
team-id
|
|
(^:async fn [store pointer]
|
|
(ptk/emit! store
|
|
(dwntf/initialize team-id file-a)
|
|
(dwntf/initialize team-id file-b))
|
|
(await (async/settle))
|
|
(pointer file-a)
|
|
(pointer file-b)
|
|
(await (async/settle)))))]
|
|
(t/is (= [{:type :unsubscribe-file :file-id file-a}]
|
|
(filterv #(= :unsubscribe-file (:type %)) (:sent left))))
|
|
(t/is (= [{:type :unsubscribe-file :file-id file-a}]
|
|
(filterv #(= :unsubscribe-file (:type %)) (:sent switch))))
|
|
(t/is (= 1 (:handled switch)) "only the new file's message is handled")))
|