penpot/frontend/test/frontend_tests/data/workspace_branches_test.cljs
2026-10-23 10:32:21 +02:00

503 lines
24 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-branches-test
(:require
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.modal :as modal]
[app.main.data.persistence :as dwp]
[app.main.data.workspace.branches :as dwb]
[app.main.data.workspace.pull-requests :as dwpr]
[app.main.repo :as rp]
[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]))
(def ^:private conflict-revn
"Main's revn in the conflict answer of the faked merge/update command."
7)
(defn- cmd-stub
"Fake `rp/cmd!` that records every call into `calls`. The branch diff
fetch never answers, so the diff stays loading. A merge or update without
resolutions answers with one conflict computed at `conflict-revn`; one
with resolutions answers nothing."
[calls conflict-id]
(mock/stub (fn [cmd params]
(swap! calls conj [cmd params])
(cond
(= cmd :get-branch-diff) (rx/subject)
(seq (:resolutions params)) (rx/empty)
:else (rx/of {:status :conflicts
:conflicts [{:id conflict-id}]
:main-revn conflict-revn})))))
(defn- make-store
[]
(ptk/store {:state {}
:on-error (fn [cause] (t/is false (str cause)))}))
(defn- branch-cmd
[mode]
(if (= mode :update) :update-branch-from-main :merge-file-branch))
(defn- open-conflicts!
"Ask for a `mode` (`:merge` or `:update`) of `branch` and let the conflict
answer open the modal, which then fetches its own diff in its direction,
as `branch-conflicts-dialog*` does on mount."
[store mode branch]
(ptk/emit! store (if (= mode :update)
(dwb/update-branch-from-main branch)
(dwb/merge-branch branch)))
(ptk/emit! store (dwb/fetch-branch-diff (:id branch)
(if (= mode :update) :main->branch :branch->main))))
(defn- apply-resolutions!
"Resolve every conflict to main and press Apply, as the modal does."
[store mode branch]
(ptk/emit! store (dwb/set-all-resolutions :main))
(let [resolutions (get-in @store [:workspace-branch-diff :resolutions])]
(ptk/emit! store (if (= mode :update)
(dwb/update-branch-from-main branch {:resolutions resolutions})
(dwb/merge-branch branch {:resolutions resolutions})))))
(defn- last-params
"Params of the last merge or update call recorded in `calls`."
[calls mode]
(->> @calls
(filter #(= (branch-cmd mode) (first %)))
(last)
(second)))
(t/deftest resolutions-applied-before-the-diff-loads-send-the-conflict-revn
(t/async done
(let [calls (atom [])
conflict-id (uuid/next)]
(mock/with-mocks
{rp/cmd! (cmd-stub calls conflict-id)}
(fn [done']
(doseq [mode [:merge :update]]
(t/testing (name mode)
(let [branch {:id (uuid/next)}
store (make-store)]
(reset! calls [])
(open-conflicts! store mode branch)
(t/is (= :loading (get-in @store [:workspace-branch-diff :status]))
"the modal's diff fetch has not answered")
(apply-resolutions! store mode branch)
(let [params (last-params calls mode)]
(t/is (= {conflict-id :main} (:resolutions params)))
(t/is (= conflict-revn (:expected-main-revn params))
"the resolutions carry the revn of the conflict answer"))
(rx/dispose! store))))
(done'))
done))))
(t/deftest the-loaded-diff-revn-wins-over-the-conflict-revn
(t/async done
(let [calls (atom [])
conflict-id (uuid/next)]
(mock/with-mocks
{rp/cmd! (cmd-stub calls conflict-id)}
(fn [done']
(doseq [mode [:merge :update]]
(t/testing (name mode)
(let [branch {:id (uuid/next)}
store (make-store)]
(reset! calls [])
(open-conflicts! store mode branch)
;; the modal's diff fetch answers, computed at a newer main
(ptk/emit! store (dwb/update-branch-diff {:status :loaded
:diff {:meta {:main-revn 9}}}))
(apply-resolutions! store mode branch)
(t/is (= 9 (:expected-main-revn (last-params calls mode))))
(rx/dispose! store))))
(done'))
done))))
(t/deftest no-resolutions-without-a-loaded-diff-send-no-revn
(t/async done
(let [calls (atom [])
conflict-id (uuid/next)]
(mock/with-mocks
{rp/cmd! (cmd-stub calls conflict-id)}
(fn [done']
(doseq [mode [:merge :update]]
(t/testing (name mode)
(let [branch {:id (uuid/next)}
store (make-store)]
(reset! calls [])
(open-conflicts! store mode branch)
(t/is (not (contains? (last-params calls mode) :expected-main-revn))
"the first request has neither resolutions nor a diff")
;; ask again while the diff is loading, with the conflict
;; answer's revn already stored
(ptk/emit! store (if (= mode :update)
(dwb/update-branch-from-main branch)
(dwb/merge-branch branch)))
(let [params (last-params calls mode)]
(t/is (= 2 (count (filter #(= (branch-cmd mode) (first %)) @calls))))
(t/is (not (contains? params :resolutions)))
(t/is (not (contains? params :expected-main-revn))
"the stored revn only travels with resolutions"))
(rx/dispose! store))))
(done'))
done))))
;;; --- Selective merge: the per-change selection ---
(t/deftest the-per-change-selection-toggles-and-clears
(let [store (make-store)
k1 "color//aaaaaaaa/1"
k2 "color//aaaaaaaa/2"]
(t/testing "a row flips on its own"
(ptk/emit! store (dwb/toggle-diff-excluded #{k1}))
(t/is (= #{k1} (get-in @store [:workspace-branch-diff :excluded])))
(ptk/emit! store (dwb/toggle-diff-excluded #{k1}))
(t/is (= #{} (get-in @store [:workspace-branch-diff :excluded]))))
(t/testing "a group sets every key of it to one side"
(ptk/emit! store (dwb/toggle-diff-excluded #{k1 k2} false))
(t/is (= #{k1 k2} (get-in @store [:workspace-branch-diff :excluded])))
(ptk/emit! store (dwb/toggle-diff-excluded #{k1} false))
(t/is (= #{k1 k2} (get-in @store [:workspace-branch-diff :excluded])))
(ptk/emit! store (dwb/toggle-diff-excluded #{k1 k2} true))
(t/is (= #{} (get-in @store [:workspace-branch-diff :excluded]))))
(t/testing "closing the compare dialog drops the selection"
(ptk/emit! store (dwb/toggle-diff-excluded #{k1}))
(ptk/emit! store (dwb/clear-diff-excluded))
(t/is (= #{} (get-in @store [:workspace-branch-diff :excluded]))))))
(t/deftest a-refetch-of-the-same-comparison-keeps-the-selection
(t/async done
(let [calls (atom [])
branch {:id (uuid/next)}]
(mock/with-mocks
{rp/cmd! (cmd-stub calls (uuid/next))}
(fn [done']
(let [store (make-store)]
(ptk/emit! store (dwb/fetch-branch-diff (:id branch)))
(ptk/emit! store (dwb/toggle-diff-excluded #{"color//aaaaaaaa/1"}))
(t/testing "the same branch and direction refetches over the selection"
(ptk/emit! store (dwb/fetch-branch-diff (:id branch)))
(t/is (= #{"color//aaaaaaaa/1"}
(get-in @store [:workspace-branch-diff :excluded]))))
(t/testing "the other direction starts the selection over"
(ptk/emit! store (dwb/fetch-branch-diff (:id branch) :main->branch))
(t/is (= #{} (get-in @store [:workspace-branch-diff :excluded]))))
(rx/dispose! store))
(done'))
done))))
(t/deftest the-selection-travels-with-the-merge-and-update-calls
(t/async done
(let [calls (atom [])
conflict-id (uuid/next)]
(mock/with-mocks
{rp/cmd! (cmd-stub calls conflict-id)}
(fn [done']
(doseq [mode [:merge :update]]
(t/testing (name mode)
(let [branch {:id (uuid/next)}
store (make-store)
excluded #{"color//aaaaaaaa/1"}]
(reset! calls [])
(ptk/emit! store (if (= mode :update)
(dwb/update-branch-from-main branch {:excluded excluded})
(dwb/merge-branch branch {:excluded excluded})))
(t/is (= excluded (:excluded (last-params calls mode))))
(t/testing "an empty selection sends no `:excluded` at all"
(ptk/emit! store (if (= mode :update)
(dwb/update-branch-from-main branch)
(dwb/merge-branch branch)))
(t/is (not (contains? (last-params calls mode) :excluded))))
(rx/dispose! store))))
(done'))
done))))
(t/deftest the-comment-travels-with-the-merge-and-its-conflicts
(t/async done
(let [calls (atom [])
conflict-id (uuid/next)]
(mock/with-mocks
{rp/cmd! (cmd-stub calls conflict-id)}
(fn [done']
(let [branch {:id (uuid/next)}
store (make-store)]
(ptk/emit! store (dwb/merge-branch branch {:comment " Ships the header "}))
(t/is (= "Ships the header" (:comment (last-params calls :merge)))
"the comment is trimmed")
(t/testing "the conflict answer hands the comment to the modal"
(t/is (= :branch-conflicts (get-in @store [::modal/modal :type])))
(t/is (= " Ships the header "
(get-in @store [::modal/modal :props :comment]))))
(t/testing "a blank comment sends no `:comment` at all"
(ptk/emit! store (dwb/merge-branch branch {:comment " "}))
(t/is (not (contains? (last-params calls :merge) :comment))))
(rx/dispose! store))
(done'))
done))))
;;; --- Branch switcher: the entry point of the menu and the shortcut ---
(defn- make-store-with
[state]
(ptk/store {:state state
:on-error (fn [cause] (t/is false (str cause)))}))
(def ^:private file-id (uuid/next))
(def ^:private main-without-branches
{:current-file-id file-id
:files {file-id {:id file-id :name "Checkout"}}
:workspace-branch-count {:file-id file-id :count 0}
:workspace-branch-context {:file-id file-id :loaded? true :info nil}})
(def ^:private main-with-branches
(assoc main-without-branches :workspace-branch-count {:file-id file-id :count 1}))
(def ^:private on-a-branch
(assoc main-without-branches :workspace-branch-context
{:file-id file-id :loaded? true :info {:id (uuid/next) :name "feature-login"}}))
(t/deftest the-switcher-only-exists-once-the-file-has-branches
(t/is (false? (dwb/has-branches? main-without-branches)))
(t/is (true? (dwb/has-branches? main-with-branches)))
(t/is (true? (dwb/has-branches? on-a-branch))
"a branch counts even before the count loads"))
(t/deftest the-listing-never-decides-whether-the-switcher-exists
(t/is (true? (dwb/has-branches? (assoc main-with-branches :workspace-branches {:status :error})))
"a failed listing keeps the switcher")
(t/is (true? (dwb/has-branches? (assoc main-with-branches :workspace-branches {:status :loading})))
"a loading listing keeps the switcher")
(t/is (false? (dwb/has-branches? (assoc main-without-branches :workspace-branches
{:status :loaded :data [{:id (uuid/next)}]})))
"the count decides, not a listing left over from another file"))
(t/deftest ^:async a-failed-listing-is-an-error-not-an-empty-list
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/throw (ex-info "network" {}))))}
(let [store (make-store-with main-with-branches)]
(ptk/emit! store (dwb/init-branches-state))
(await (async/wait-for #(not= :loading (get-in @store [:workspace-branches :status]))
"the listing settles"))
(t/is (= :error (get-in @store [:workspace-branches :status])))
(t/is (nil? (get-in @store [:workspace-branches :data])))
(t/is (true? (dwb/has-branches? @store)) "the switcher stays")
(rx/dispose! store)))))
(t/deftest ^:async the-count-loads-without-the-listing
(let [calls (atom [])]
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(swap! calls conj [cmd params])
(rx/of {:count 3})))}
(let [store (make-store-with (dissoc main-without-branches :workspace-branch-count))]
(ptk/emit! store (dwb/fetch-branch-count))
(await (async/wait-for #(some? (:workspace-branch-count @store)) "the count lands"))
(t/is (= {:file-id file-id :count 3} (:workspace-branch-count @store)))
(t/is (= [[:get-file-branch-count {:file-id file-id}]] @calls)
"only the count is asked, never the listing")
(t/is (true? (dwb/has-branches? @store)))
(rx/dispose! store))))))
(t/deftest ^:async the-count-of-another-file-is-dropped-at-once
(let [other (uuid/next)]
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/subject)))}
(let [store (make-store-with (assoc main-with-branches :workspace-branch-count
{:file-id other :count 4}))]
(ptk/emit! store (dwb/fetch-branch-count))
(t/is (nil? (:workspace-branch-count @store)))
(t/is (false? (dwb/has-branches? @store)))
(rx/dispose! store))))))
(t/deftest ^:async a-failed-count-shows-the-switcher-with-an-error
(await (mock/with-mocks*
{cf/flags (conj cf/flags :branching)
rp/cmd! (mock/stub (fn [_ _] (rx/throw (ex-info "network" {}))))}
(let [store (make-store-with (dissoc main-without-branches :workspace-branch-count))]
(ptk/emit! store (dwb/fetch-branch-count))
(await (async/wait-for #(some? (:workspace-branch-count @store)) "the count settles"))
(t/is (= {:file-id file-id :status :error} (:workspace-branch-count @store)))
(t/is (true? (dwb/branch-count-failed? @store)))
(t/is (true? (dwb/has-branches? @store))
"a count that failed never hides the switcher")
(ptk/emit! store (dwb/show-branches))
(await (async/wait-for #(some? (::modal/modal @store))
"the shortcut opens the branches dialog, which retries"))
(t/is (= :branches-list (get-in @store [::modal/modal :type]))
"no create dialog on an unknown count")
(rx/dispose! store)))))
(t/deftest ^:async a-listing-after-a-failed-count-settles-it
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd _]
(case cmd
:get-file-branch-info (rx/of nil)
:get-file-branches (rx/of [{:id (uuid/next) :status "open"}])
(rx/empty))))}
(let [store (make-store-with (assoc main-without-branches :workspace-branch-count
{:file-id file-id :status :error}))]
(ptk/emit! store (dwb/init-branches-state))
(await (async/wait-for #(= :loaded (get-in @store [:workspace-branches :status]))
"the listing lands"))
(t/is (= {:file-id file-id :count 1} (:workspace-branch-count @store)))
(t/is (false? (dwb/branch-count-failed? @store)))
(rx/dispose! store)))))
(t/deftest ^:async a-listing-refreshes-the-count
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd _]
(case cmd
:get-file-branch-info (rx/of nil)
:get-file-branches (rx/of [])
(rx/empty))))}
(let [store (make-store-with main-with-branches)]
(ptk/emit! store (dwb/fetch-branches))
(await (async/wait-for #(= :loaded (get-in @store [:workspace-branches :status]))
"the listing lands"))
(t/is (= 0 (get-in @store [:workspace-branch-count :count]))
"deleting the last branch from the listing hides the switcher")
(t/is (false? (dwb/has-branches? @store)))
(rx/dispose! store)))))
(t/deftest ^:async a-pull-request-from-the-open-branch-saves-first-without-the-listing
(let [waits (atom 0)
branch-id (uuid/next)]
(await (mock/with-mocks*
{dwp/wait-persisted (mock/stub (fn [& _] (swap! waits inc) (rx/of :saved)))
rp/cmd! (mock/stub (fn [_ _] (rx/empty)))}
;; the bottom banner opens the dialog with the branch context;
;; the listing only loads with the switcher, so it is absent
(let [store (make-store-with
(assoc main-without-branches :workspace-branch-context
{:file-id file-id
:loaded? true
:info {:id branch-id :branch-file-id file-id}}))]
(ptk/emit! store (dwpr/create-pull-request branch-id {:title "Review me"}))
(await (async/wait-for #(pos? @waits) "the pending edits are saved first"))
(rx/dispose! store))))))
(t/deftest the-switcher-toggles-and-can-be-forced
(let [store (make-store-with main-with-branches)]
(ptk/emit! store (dwb/toggle-branch-switcher))
(t/is (true? (:workspace-branch-switcher @store)))
(ptk/emit! store (dwb/toggle-branch-switcher))
(t/is (false? (:workspace-branch-switcher @store)))
(ptk/emit! store (dwb/toggle-branch-switcher true))
(ptk/emit! store (dwb/toggle-branch-switcher true))
(t/is (true? (:workspace-branch-switcher @store)) "forcing it open twice keeps it open")
(rx/dispose! store)))
(t/deftest ^:async show-branches-opens-the-branches-dialog-when-there-are-branches
(await (mock/with-mocks*
{cf/flags (conj cf/flags :branching)}
(let [store (make-store-with (assoc main-with-branches :workspace-branch-switcher true))]
(ptk/emit! store (dwb/show-branches))
(await (async/wait-for #(some? (::modal/modal @store))
"the branches dialog opens"))
(t/is (= :branches-list (get-in @store [::modal/modal :type])))
(t/is (false? (:workspace-branch-switcher @store)) "the switcher closes")
(rx/dispose! store)))))
(t/deftest ^:async show-branches-asks-for-the-first-branch-when-there-are-none
(await (mock/with-mocks*
{cf/flags (conj cf/flags :branching)}
(let [store (make-store-with main-without-branches)]
(ptk/emit! store (dwb/show-branches))
(await (async/wait-for #(some? (::modal/modal @store))
"the create dialog opens"))
(t/is (= :create-branch (get-in @store [::modal/modal :type])))
(t/is (not (:workspace-branch-switcher @store)))
(rx/dispose! store)))))
(t/deftest ^:async show-branches-does-nothing-without-the-flag
(await (mock/with-mocks*
{cf/flags (disj cf/flags :branching)}
(let [store (make-store-with main-without-branches)]
(ptk/emit! store (dwb/show-branches))
(await (async/settle))
(t/is (nil? (::modal/modal @store)))
(t/is (nil? (:workspace-branch-switcher @store)))
(rx/dispose! store)))))
(t/deftest ^:async opening-a-branch-closes-the-switcher-and-navigates
(let [navigations (atom [])
branch-id (uuid/next)]
(await (mock/with-mocks*
{dcm/go-to-workspace
(fn [& {:as params}]
(swap! navigations conj params)
(ptk/data-event ::navigated params))}
(let [store (make-store-with (assoc main-with-branches :workspace-branch-switcher true))]
(ptk/emit! store (dwb/open-branch branch-id))
(await (async/wait-for #(seq @navigations) "it navigates"))
(t/is (false? (:workspace-branch-switcher @store)))
(t/is (= {:file-id branch-id} (first @navigations)))
(rx/dispose! store))))))
;;; --- Creating a branch: one path, from the open file ---
(t/deftest ^:async creating-a-branch-branches-the-open-file
(let [calls (atom [])
branch-id (uuid/next)
navigations (atom [])]
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(swap! calls conj [cmd params])
(rx/of {:branch-file-id branch-id})))
dcm/go-to-workspace
(fn [& {:as params}]
(swap! navigations conj params)
(ptk/data-event ::navigated params))}
(let [store (make-store-with main-without-branches)]
(ptk/emit! store (dwb/create-branch "feature-login" "from the workspace"))
(await (async/wait-for #(seq @navigations) "it lands in the new branch"))
(t/is (= [[:create-file-branch
{:file-id file-id
:name "feature-login"
:description "from the workspace"}]]
@calls)
"it creates the branch from the open file, once")
(t/is (= [{:file-id branch-id}] @navigations)
"it opens the branch that was created")
(rx/dispose! store))))))
;;; --- Reviews dialog: the merges into main ---
(t/deftest ^:async the-merges-load-for-the-open-file
(let [calls (atom [])
merges [{:id (uuid/next) :branch-name "header" :comment "Ships it"}]]
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(swap! calls conj [cmd params])
(rx/of merges)))}
(let [store (make-store-with main-without-branches)]
(ptk/emit! store (dwb/fetch-branch-merges))
(await (async/wait-for #(= :loaded (get-in @store [:workspace-branch-merges :status]))
"the merges land"))
(t/is (= merges (get-in @store [:workspace-branch-merges :data])))
(t/is (= [[:get-file-branch-merges {:file-id file-id}]] @calls)
"the open file is asked as is: the command resolves a branch to main")
(rx/dispose! store))))))
(t/deftest ^:async a-failed-merges-load-is-an-error-not-an-empty-list
(await (mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/throw (ex-info "network" {}))))}
(let [store (make-store-with main-without-branches)]
(ptk/emit! store (dwb/fetch-branch-merges))
(await (async/wait-for #(= :error (get-in @store [:workspace-branch-merges :status]))
"the failure lands"))
(t/is (nil? (get-in @store [:workspace-branch-merges :data])))
(rx/dispose! store)))))