penpot/frontend/test/frontend_tests/data/persistence_commits_test.cljs
alonso.torres 793760e489 ✨ Tell plugins and the MCP agent when changes are rolled back
The backend rejects a save after the call that made the changes has
returned, and the workspace then undoes them. A plugin, or the MCP
agent, went on working with shapes that were no longer there and never
learned why.

The rollback event now names the files that lost commits and the pages
and page shapes their changes touched, and plugins get it as the new
`contentrollback` event (`ContentRollback` type) through a subscription
to the store stream, so no rollback is dropped by debouncing.

The MCP plugin keeps the rollbacks it is told about and sends them with
its next task response, failed or not. The server collects them per tool
call and puts a warning first in that tool's response, naming what was
affected; the instructions tell the agent to read the pages again and
redo what is missing.
2026-11-05 10:56:41 +01:00

173 lines
8.0 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.persistence-commits-test
"Tests for the pure commit bookkeeping of persistence
(`app.main.data.persistence.commits`)."
(:require
[app.common.uuid :as uuid]
[app.main.data.persistence.commits :as pcm]
[cljs.test :as t :include-macros true]))
(defn- commit
[file-id undo-group]
{:id (uuid/next)
:file-id file-id
:file-revn 0
:file-vern 0
:features #{}
:source :local
:undo-group undo-group
:redo-changes [{:type :mod-page :id (uuid/next) :name "redo"}]
:undo-changes [{:type :mod-page :id (uuid/next) :name "undo"}]
:remote-seq 0})
(defn- queued
"Persistence state with `entries` queued, in order."
[& entries]
{:queue (into #queue [] (map :id) entries)
:index (into {} (map (juxt :id identity)) entries)})
(t/deftest a-queue-entry-keeps-only-what-a-save-needs
(let [file-id (uuid/next)
c1 (commit file-id (uuid/next))
c2 (commit file-id (uuid/next))
entry (pcm/queue-entry [c1 c2])]
(t/is (= (:id c1) (:id entry)))
(t/is (= file-id (:file-id entry)))
(t/is (not (contains? entry :redo-changes)))
(t/is (= [(:id c1) (:id c2)] (mapv :id (:commits entry))))
(t/is (not (contains? (first (:commits entry)) :source)))))
(t/deftest pending-commits-are-the-queued-then-the-buffered-of-one-file
(let [file-id (uuid/next)
other-id (uuid/next)
c1 (commit file-id nil)
c2 (commit other-id nil)
c3 (commit file-id nil)
pstate (assoc (queued (pcm/queue-entry [c1]) (pcm/queue-entry [c2]))
:buffer [c3])]
(t/is (= [(:id c1) (:id c3)] (mapv :id (pcm/pending-commits pstate file-id))))))
(t/deftest a-rollback-plan-reverts-whole-undo-groups-across-files
(let [file-id (uuid/next)
lib-id (uuid/next)
group (uuid/next)
kept (commit file-id (uuid/next))
bad (commit file-id (uuid/next))
lib-1 (commit lib-id (uuid/next))
lib-2 (commit lib-id (uuid/next))
linked (commit lib-id group)
later (commit file-id group)
saved (assoc (commit lib-id group) :file-id lib-id)
head (pcm/queue-entry [kept bad])
pstate (-> (queued head (pcm/queue-entry [lib-1 linked lib-2]))
(assoc :buffer [later]
:saved [saved]))
plan (pcm/rollback-plan pstate head {:commit-ids [(:id bad)]})]
(t/testing "this file reverts from the rejected commit on"
(t/is (= [(:id bad) (:id later)]
(mapv :id (get-in plan [:files file-id :reverted])))))
(t/testing "the library reverts from the commit that shares a group on"
(t/is (= [(:id linked) (:id lib-2)]
(mapv :id (get-in plan [:files lib-id :reverted]))))
(t/is (= [(:id lib-1)] (mapv :id (get-in plan [:files lib-id :redo])))))
(t/testing "saved commits of a reverted group are compensated"
(t/is (= [(:id saved)] (mapv :id (:compensate plan)))))
(t/is (= {:reverted 5 :files 2} (pcm/rollback-summary plan)))
(t/testing "the rolled back commits leave the queue and the buffer"
(let [pstate' (pcm/without-rolled-back pstate head plan)]
(t/is (= [(:id kept)] (mapv :id (get-in pstate' [:index (:id head) :commits]))))
(t/is (= [(:id lib-1)]
(mapv :id (get-in pstate' [:index (:id lib-1) :commits]))))
(t/is (empty? (:buffer pstate')))
(t/is (empty? (:saved pstate')))))))
(t/deftest an-entry-left-without-commits-leaves-the-queue
(let [file-id (uuid/next)
lib-id (uuid/next)
group (uuid/next)
bad (commit file-id group)
linked (commit lib-id group)
head (pcm/queue-entry [bad])
lib (pcm/queue-entry [linked])
pstate (queued head lib)
plan (pcm/rollback-plan pstate head {:commit-ids [(:id bad)]})
pstate' (pcm/without-rolled-back pstate head plan)]
(t/is (= [(:id head)] (vec (:queue pstate'))) "the in-flight entry stays until it is discarded")
(t/is (not (contains? (:index pstate') (:id lib))))))
(t/deftest compensation-undoes-newest-first
(let [c1 {:redo-changes [:r1] :undo-changes [:u1]}
c2 {:redo-changes [:r2] :undo-changes [:u2]}]
(t/is (= {:redo-changes [:u2 :u1] :undo-changes [:r1 :r2]}
(pcm/compensation-changes [c1 c2])))))
(t/deftest a-rollback-plan-names-the-index-before-the-oldest-reverted-undo
(let [file-id (uuid/next)
bad (commit file-id (uuid/next))
undo-1 (assoc (commit file-id (uuid/next)) :undo-index 4)
undo-2 (assoc (commit file-id (uuid/next)) :undo-index 3)
head (pcm/queue-entry [bad undo-1])
pstate (assoc (queued head) :buffer [undo-2])]
(t/is (= 4 (:undo-index (pcm/rollback-plan pstate head {:commit-ids [(:id bad) (:id undo-1)]}))))
(t/is (nil? (:undo-index (pcm/rollback-plan (queued (pcm/queue-entry [bad]))
(pcm/queue-entry [bad])
{:commit-ids [(:id bad)]}))))))
(t/deftest saved-commits-are-kept-while-a-pending-commit-shares-their-group
(let [file-id (uuid/next)
group (uuid/next)
shared (commit file-id group)
alone (commit file-id (uuid/next))
pending (commit file-id group)
old (commit file-id (uuid/next))
entry (pcm/queue-entry [shared alone])
pstate (pcm/keep-saved-commits {:queue #queue [] :index {} :buffer [pending] :saved [old]}
entry)]
(t/is (= [(:id shared)] (mapv :id (:saved pstate))))
(t/is (= file-id (:file-id (first (:saved pstate))))
"a saved commit carries the file it was saved to")))
(t/deftest remote-changes-older-than-the-oldest-pending-commit-are-forgotten
(let [file-id (uuid/next)
pending (assoc (commit file-id nil) :remote-seq 2)
pstate {:queue #queue [] :index {} :buffer [pending]
:remote-changes {file-id [{:seq 1 :changes [:old]} {:seq 3 :changes [:new]}]}}]
(t/is (= [{:seq 3 :changes [:new]}]
(get-in (pcm/prune-remote-changes pstate file-id) [:remote-changes file-id])))
(t/is (not (contains? (:remote-changes (pcm/prune-remote-changes (dissoc pstate :buffer) file-id))
file-id))
"a file with nothing pending keeps no remote changes")))
(t/deftest a-release-plan-reverts-every-pending-commit-of-the-file-and-its-groups
(let [file-id (uuid/next)
lib-id (uuid/next)
group (uuid/next)
edit (commit file-id (uuid/next))
linked (commit file-id group)
lib (commit lib-id group)
pstate (assoc (queued (pcm/queue-entry [edit linked]))
:buffer [lib])
plan (pcm/release-plan pstate file-id)]
(t/is (= [(:id edit) (:id linked)] (mapv :id (get-in plan [:files file-id :reverted]))))
(t/is (empty? (get-in plan [:files file-id :redo])))
(t/is (= [(:id lib)] (mapv :id (get-in plan [:files lib-id :reverted])))
"the rest of the undo group goes too")))
(t/deftest rollback-targets-name-the-files-pages-and-page-shapes-of-the-reverted-changes
(let [[f1 f2 p1 p2 p3 s1 s2 s3 s4 s5] (repeatedly 10 uuid/next)
plan {:files {f1 {:reverted [{:redo-changes [{:type :add-obj :page-id p1 :id s1}
{:type :mov-objects :page-id p1 :shapes [s2 s3]}
{:type :mod-obj :component-id (uuid/next) :id s4}
{:type :mod-page :id p2}]}]}}
:compensate [{:file-id f2 :redo-changes [{:type :del-obj :page-id p3 :id s5}]}]}]
(t/is (= {:file-ids #{f1 f2}
:page-ids #{p1 p2 p3}
:shape-ids #{s1 s2 s3 s5}}
(pcm/rollback-targets plan)))))