Files
Synapse/clj-e2e/test/logseq/e2e/outliner_basic_test.clj
Dasemu 40631362b3 Initial import of LogDarioSeq baseline
Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:32:15 +02:00

665 lines
24 KiB
Clojure

(ns logseq.e2e.outliner-basic-test
(:require
[clojure.string :as string]
[clojure.test :refer [deftest testing is use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.graph :as graph]
[logseq.e2e.keyboard :as k]
[logseq.e2e.page :as p]
[logseq.e2e.util :as util]
[wally.main :as w])
(:import
(com.microsoft.playwright Locator$DragToOptions)))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(defn- block-text-position
[text]
(let [locator (w/find-one-by-text "span" text)]
(assert/assert-is-visible locator)
(first (util/bounding-xy locator))))
(defn create-test-page-and-insert-blocks []
;; a page block and a child block
(is (= 2 (util/blocks-count)))
(b/new-blocks ["first block" "second block"])
(util/exit-edit)
(is (= 3 (util/blocks-count))))
(defn indent-and-outdent []
(b/new-blocks ["b1" "b2"])
(testing "simple indent and outdent"
(b/indent)
(b/outdent))
(testing "indent a block with its children"
(b/new-block "b3")
(b/indent)
(k/arrow-up)
(b/indent)
(util/exit-edit)
(let [[x1 x2 x3] (map block-text-position ["b1" "b2" "b3"])]
(is (< x1 x2 x3))))
(testing "unindent a block with its children"
(b/open-last-block)
(b/new-blocks ["b4" "b5"])
(b/indent)
(k/arrow-up)
(b/outdent)
(util/exit-edit)
(let [[x2 x3 x4 x5] (map block-text-position ["b2" "b3" "b4" "b5"])]
(is (and (= x2 x4) (= x3 x5) (< x2 x3))))))
(defn indent-outdent-embed-page []
(p/new-page "Page embed")
(b/new-blocks ["b1" "b2"])
(p/new-page "Page testing")
(b/new-blocks ["b3" ""])
(util/input-command "Node embed")
(util/press-seq "Page embed" {:delay 60})
(w/wait-for "#ac-0.menu-link:has-text('Page embed')")
(k/press "Enter" {:delay 60})
(util/exit-edit)
(b/new-blocks ["b4"])
(b/outdent)
(b/indent)
(util/exit-edit)
(let [[x2 x3 x4] (map block-text-position ["b2" "b3" "b4"])]
(is (= x2 x4))
(is (< x3 x2))))
(defn move-up-down []
(b/new-blocks ["b1" "b2" "b3" "b4"])
(util/repeat-keyboard 2 "Shift+ArrowUp")
(let [contents (util/get-page-blocks-contents)]
(is (= contents ["b1" "b2" "b3" "b4"])))
(util/repeat-keyboard 2 (str (if util/mac? "Meta" "Alt") "+Shift+ArrowUp"))
(let [contents (util/get-page-blocks-contents)]
(is (= contents ["b3" "b4" "b1" "b2"])))
(util/repeat-keyboard 2 (str (if util/mac? "Meta" "Alt") "+Shift+ArrowDown"))
(let [contents (util/get-page-blocks-contents)]
(is (= contents ["b1" "b2" "b3" "b4"]))))
(defn- zoom-in-shortcut []
(k/press (if util/mac? "Meta+Shift+." "Alt+ArrowRight")))
(defn- current-location-hash []
(w/eval-js "window.location.hash"))
(defn- current-editing-block-id []
(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
return editor?.closest('[blockid]')?.getAttribute('blockid') ?? null;
})();"))
(deftest focused-root-block-cannot-indent-or-move-test
(testing "Focused root block ignores indent/outdent/move-up/move-down commands"
(b/new-blocks ["focused-root" "focused-child"])
(k/arrow-up)
(let [root-id (current-editing-block-id)]
(is (string? root-id))
(zoom-in-shortcut)
(util/wait-timeout 400)
;; Retry once in case the first key event gets swallowed by the editor.
(when-not (string/includes? (or (current-location-hash) "") root-id)
(zoom-in-shortcut)
(util/wait-timeout 400))
(is (string/includes? (or (current-location-hash) "") root-id))
(util/wait-editor-visible)
(is (= "focused-root" (util/get-edit-content)))
(let [before-hash (current-location-hash)
before-block-contents (util/get-page-blocks-contents)]
(k/tab)
(util/wait-timeout 100)
(k/shift+tab)
(util/wait-timeout 100)
(k/meta+shift+arrow-up)
(util/wait-timeout 100)
(k/meta+shift+arrow-down)
(util/wait-timeout 100)
(is (= "focused-root" (util/get-edit-content)))
(is (= before-hash (current-location-hash)))
(is (= before-block-contents (util/get-page-blocks-contents)))))))
(defn delete []
(testing "Delete blocks case 1"
(b/new-blocks ["b1" "b2" "b3" "b4"])
(b/delete-blocks) ; delete b4
(util/repeat-keyboard 2 "Shift+ArrowUp") ; select b3 and b2
(b/delete-blocks)
(is (= "b1" (util/get-edit-content)))
(is (= 1 (util/page-blocks-count)))))
(defn delete-end []
(testing "Delete at end"
(b/new-blocks ["b1" "b2" "b3"])
(k/arrow-up)
(k/delete)
(is (= "b2b3" (util/get-edit-content)))
(is (= 2 (util/page-blocks-count)))))
(defn delete-test-with-children []
(testing "Delete block with its children"
(b/new-blocks ["b1" "b2" "b3" "b4"])
(b/indent)
(k/arrow-up)
(b/indent)
(k/arrow-up)
(b/delete-blocks)
(util/wait-editor-visible)
(is (= "b1" (util/get-edit-content)))
(is (= 1 (util/page-blocks-count)))))
(deftest create-test-page-and-insert-blocks-test
(create-test-page-and-insert-blocks))
(deftest indent-and-outdent-test
(indent-and-outdent))
(deftest indent-outdent-embed-page-test
(indent-outdent-embed-page))
(deftest move-up-down-test
(move-up-down))
(deftest delete-test
(delete))
(deftest delete-end-test
(delete-end))
(deftest delete-test-with-children-test
(delete-test-with-children))
(deftest delete-concat-test-2-blocks
(testing "Delete concat with empty block"
(b/new-blocks ["" "b2"])
(b/indent)
(k/arrow-up)
(k/delete)
(util/wait-editor-visible)
(is (= "b2" (util/get-edit-content)))
(util/exit-edit)
(is (= ["b2"] (util/get-page-blocks-contents)))))
(deftest delete-concat-test-3-blocks
(testing "Delete concat with empty block"
(b/new-blocks ["" "b2" "b3"])
(b/indent)
(k/arrow-up)
(k/arrow-up)
(k/delete)
(util/wait-editor-visible)
(is (= "b2" (util/get-edit-content)))
(util/exit-edit)
(is (= ["b2" "b3"] (util/get-page-blocks-contents)))))
(deftest delete-concat-test-with-children
(testing "Delete concat with children blocks"
(b/new-blocks ["" "b2" "b3"])
(b/indent)
(k/arrow-up)
(b/indent)
(k/arrow-up)
(k/delete)
(util/wait-editor-visible)
(is (= "" (util/get-edit-content)))
(is (= 3 (util/page-blocks-count)))))
(deftest delete-concat-test-with-tag
(testing "Delete concat with tag"
(b/new-blocks ["" "b2"])
(b/indent)
(util/set-tag "tag1")
(k/arrow-up)
(k/delete)
(util/wait-editor-visible)
(is (= "b2" (util/get-edit-content)))
(util/exit-edit)
(assert/assert-is-visible
".ls-block a.tag:has-text('tag1')")
(is (= ["b2"] (util/get-page-blocks-contents)))))
(deftest backspace-empty-first-child-keeps-empty-parent-subtree-test
(testing "Backspace in the first empty child of an empty parent deletes only the child"
(p/new-page "backspace empty first child")
(let [page-uuid (get (ls-api-call! :editor.getBlock "backspace empty first child") "uuid")
[parent first-child child2 child3]
(ls-api-call! :editor.insertBatchBlock
page-uuid
[{:content ""
:children [{:content ""}
{:content "child2"}
{:content "child3"}]}])
block-visible? #(pos? (util/count-elements (str "#ls-block-" %)))]
(w/click (str "#ls-block-" (get first-child "uuid") " .block-content"))
(util/wait-editor-visible)
(is (= "" (util/get-edit-content)))
(k/backspace)
(util/wait-timeout 100)
(is (not (block-visible? (get first-child "uuid"))))
(is (block-visible? (get parent "uuid")))
(is (block-visible? (get child2 "uuid")))
(is (block-visible? (get child3 "uuid"))))))
(deftest backspace-at-parent-start-keeps-children-test
(doseq [[page-name parent-content]
[["backspace non-empty parent start" "a"]
["backspace empty parent start" ""]]]
(testing (str "Backspace at the start of parent " (pr-str parent-content)
" preserves its child")
(p/new-page page-name)
(let [page-uuid (get (ls-api-call! :editor.getBlock page-name) "uuid")
[parent child]
(ls-api-call! :editor.insertBatchBlock
page-uuid
[{:content parent-content
:children [{:content "b"}]}])
parent-uuid (get parent "uuid")
child-uuid (get child "uuid")
block-visible? #(pos? (util/count-elements (str "#ls-block-" %)))]
(w/click (str "#block-content-" parent-uuid))
(util/wait-editor-visible)
(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
editor.focus();
editor.setSelectionRange(0, 0);
})();")
(k/backspace)
(util/wait-timeout 200)
(is (= parent-content (util/get-edit-content)))
(is (block-visible? parent-uuid))
(is (block-visible? child-uuid))))))
(deftest consecutive-backspace-does-not-restore-deleted-blocks-test
(testing "Deleting b and then a never renders stale block content"
(p/new-page "consecutive backspace deletion")
(let [page-uuid (get (ls-api-call! :editor.getBlock "consecutive backspace deletion") "uuid")
[_a b] (ls-api-call! :editor.insertBatchBlock
page-uuid
[{:content "a"}
{:content "b"}])
editor-state #(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
return [
editor?.value ?? null,
Array.from(document.querySelectorAll(
'.ls-page-blocks .block-title-wrap'))
.map((node) => node.textContent.trim())
.filter(Boolean)
];
})();")]
(w/click (str "#block-content-" (get b "uuid")))
(util/wait-editor-visible)
(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
editor.focus();
editor.setSelectionRange(editor.value.length, editor.value.length);
})();")
(dotimes [_ 4]
(k/backspace))
(util/wait-timeout 50)
(let [immediate (editor-state)]
(is (= [] (second immediate)))
(util/wait-timeout 500)
(is (= immediate (editor-state)))))))
(deftest held-backspace-does-not-duplicate-merged-content-test
(testing "Repeated Backspace cannot merge the same block twice"
(p/new-page "held backspace merge")
(b/new-blocks ["Foo" "" "Foo" "" "Foo" ""])
(util/repeat-keyboard 75 "Backspace")
(util/wait-timeout 500)
(let [editor-content (util/get-edit-content)
block-contents (util/get-page-blocks-contents)]
(is (not= "FooFoo" editor-content))
(is (not-any? #{"FooFoo"} block-contents)))))
(deftest rapid-retype-before-enter-keeps-the-edit-test
(testing "Backspace, retype, and Enter keep the retyped content"
(b/open-last-block)
(k/press "a")
(k/press "Backspace")
(k/press "a")
(k/press "Enter")
(util/wait-timeout 700)
(util/exit-edit)
(is (= ["a"] (remove string/blank?
(util/get-page-blocks-contents))))))
(defn- block-tree
[page-name]
(ls-api-call! :editor.getPageBlocksTree page-name))
(defn- content-tree
[nodes]
(mapv (fn [node]
[(get node "content")
(content-tree (get node "children"))])
nodes))
(defn- wait-for-block-content!
[block-id expected]
(loop [attempts-left 40]
(let [actual (get (ls-api-call! :editor.getBlock block-id) "content")]
(if (or (= expected actual) (zero? attempts-left))
(is (= expected actual))
(do
(util/wait-timeout 100)
(recur (dec attempts-left)))))))
(defn- visible-outline-content-tree
[]
(let [relations
(w/eval-js
"(() => {
const nodes = [...document.querySelectorAll('.ls-page-blocks .ls-block')]
.filter(node => !node.closest('.is-comments-area, .ls-comments-area, .ls-comment-row'));
return nodes
.map((node, index) => {
const own = selector => [...node.querySelectorAll(selector)]
.find(child => child.closest('.ls-block') === node);
const editor = own('.editor-wrapper textarea');
const content = own('.block-content');
const title = (editor && editor.offsetParent !== null
? editor.value
: content?.innerText || node.dataset.blockTitle || '').trim();
const parent = node.parentElement.closest('.ls-block');
return [index, title, parent ? nodes.indexOf(parent) : null];
})
.filter(([_index, title]) => title);
})()")
children-by-parent (group-by #(nth % 2) relations)
build-tree (fn build-tree [parent]
(mapv (fn [[index title]]
[title (build-tree index)])
(get children-by-parent parent)))]
(build-tree nil)))
(defn- wait-for-content-tree!
[_page-name expected]
(util/wait-timeout 300)
(loop [attempts 40]
(let [actual (visible-outline-content-tree)]
(if (= expected actual)
(do
(util/wait-timeout 250)
(is (= expected (visible-outline-content-tree))))
(if (zero? attempts)
(is (= expected actual))
(do
(util/wait-timeout 100)
(recur (dec attempts))))))))
(defn- undo-and-wait-for-content-tree!
[page-name expected]
(b/undo)
(util/wait-timeout 1000)
(wait-for-content-tree! page-name expected))
(defn- redo-and-wait-for-content-tree!
[page-name expected]
(b/redo)
(util/wait-timeout 1000)
(wait-for-content-tree! page-name expected))
(defn- click-block-by-uuid!
[uuid]
(w/click (.first (w/-query (str "#block-content-" uuid ":visible")))))
(defn- click-block-by-title!
[title]
(let [target-selector
(w/eval-js
(format
"(() => {
const root = document.querySelector(\".ls-block[data-block-title='%s']\");
const own = selector => [...root.querySelectorAll(selector)]
.find(node => node.closest('.ls-block') === root);
const editor = own('textarea');
const target = editor && editor.offsetParent !== null
? editor
: own('.block-content');
return `#${CSS.escape(target.id)}`;
})()"
title))]
(w/click target-selector)
(w/eval-js
(format
"(() => new Promise((resolve, reject) => {
let attempts = 40;
const waitForTarget = () => {
const root = document.querySelector(\".ls-block[data-block-title='%s']\");
const target = [...root.querySelectorAll('textarea')]
.find(node => node.closest('.ls-block') === root && node.offsetParent !== null);
if (target) {
target.focus();
resolve(true);
} else if (attempts-- === 0) {
reject(new Error('Target block editor did not become active'));
} else {
setTimeout(waitForTarget, 50);
}
};
waitForTarget();
}))()"
title))))
(defn- move-editor-cursor-to-start!
[]
(w/eval-js
"(() => {
const editor = document.activeElement?.matches('.editor-wrapper textarea')
? document.activeElement
: [...document.querySelectorAll('.editor-wrapper textarea')]
.find(node => node.offsetParent !== null);
editor.focus();
editor.setSelectionRange(0, 0);
})();"))
(defn- move-editor-cursor-to!
[position]
(w/eval-js
(format
"(() => {
const editor = document.activeElement?.matches('.editor-wrapper textarea')
? document.activeElement
: [...document.querySelectorAll('.editor-wrapper textarea')]
.find(node => node.offsetParent !== null);
editor.focus();
editor.setSelectionRange(%d, %d);
})();"
position
position)))
(defn- move-editor-cursor-to-end!
[]
(w/eval-js
"(() => {
const editor = document.activeElement?.matches('.editor-wrapper textarea')
? document.activeElement
: [...document.querySelectorAll('.editor-wrapper textarea')]
.find(node => node.offsetParent !== null);
editor.focus();
editor.setSelectionRange(editor.value.length, editor.value.length);
})();"))
(deftest boundary-delete-and-backspace-merge-contract-test
(testing "boundary edits preserve structure through per-step and full-history undo/redo"
(let [page-name "boundary edit history"
page-uuid (do
(p/new-page page-name)
(get (ls-api-call! :editor.getBlock page-name) "uuid"))
[_left right _child _source _source-child leaf]
(ls-api-call! :editor.insertBatchBlock
page-uuid
[{:content "b"}
{:content "c"
:children [{:content "d"}]}
{:content "e"
:children [{:content "f"}]}
{:content "g"}])
initial [["b" []]
["c" [["d" []]]]
["e" [["f" []]]]
["g" []]]
after-backspace [["bc" [["d" []]]]
["e" [["f" []]]]
["g" []]]
after-enter [["b" [["c" []]
["d" []]]]
["e" [["f" []]]]
["g" []]]
after-delete [["b" [["c" []]
["d" []]]]
["e" [["fg" []]]]]
assert-tree! (partial wait-for-content-tree! page-name)]
(click-block-by-uuid! (get right "uuid"))
(util/wait-editor-visible)
(move-editor-cursor-to-start!)
(k/backspace)
(assert-tree! after-backspace)
(is (= "bc" (util/get-edit-content)))
(is (= 1
(w/eval-js
"document.querySelector('.editor-wrapper textarea').selectionStart")))
(undo-and-wait-for-content-tree! page-name initial)
(redo-and-wait-for-content-tree! page-name after-backspace)
(click-block-by-title! "bc")
(util/wait-editor-visible)
(move-editor-cursor-to! 1)
(is (= ["bc" 1]
(w/eval-js
"(() => {
const editor = document.activeElement;
return [editor.value, editor.selectionStart];
})()")))
(k/enter)
(util/wait-timeout 1000)
(assert-tree! after-enter)
(undo-and-wait-for-content-tree! page-name after-backspace)
(redo-and-wait-for-content-tree! page-name after-enter)
(click-block-by-title! "e")
(util/wait-editor-visible)
(move-editor-cursor-to-start!)
(k/backspace)
(assert-tree! after-enter)
(click-block-by-title! "d")
(util/wait-editor-visible)
(move-editor-cursor-to-end!)
(k/delete)
(assert-tree! after-enter)
(click-block-by-title! "f")
(util/wait-editor-visible)
(move-editor-cursor-to-end!)
(k/delete)
(assert-tree! after-delete)
(is (nil? (ls-api-call! :editor.getBlock (get leaf "uuid"))))
(undo-and-wait-for-content-tree! page-name after-enter)
(redo-and-wait-for-content-tree! page-name after-delete)
(undo-and-wait-for-content-tree! page-name after-enter)
(undo-and-wait-for-content-tree! page-name after-backspace)
(undo-and-wait-for-content-tree! page-name initial))))
(defn- drag-block!
[source-title target-title placement]
(let [source-block (w/-query
(format ".ls-page-blocks .ls-block[data-block-title='%s']"
source-title))
target-block (w/-query
(format ".ls-page-blocks .ls-block[data-block-title='%s']"
target-title))
target-height (.-height (.boundingBox target-block))
target-x (if (= placement "inside") 80 30)
target-y (case placement
"before" 2
"after" (- target-height 2)
"inside" (- target-height 2))
options (doto (Locator$DragToOptions.)
(.setTargetPosition target-x target-y)
(.setSteps 12))]
(.dragTo (.first (.locator source-block ".bullet-container"))
target-block
options)
(util/wait-timeout 250)))
(deftest drag-reorders-once-and-is-undoable-test
(testing "same-level drag changes order once and undo restores the original order"
(let [page-name (p/get-page-name)
initial [["drag first" []]
["drag second" []]
["drag third" []]]
reordered [["drag third" []]
["drag first" []]
["drag second" []]]]
(b/new-blocks ["drag first" "drag second" "drag third"])
(util/exit-edit)
(wait-for-content-tree! page-name initial)
(drag-block! "drag third" "drag first" "before")
(wait-for-content-tree! page-name reordered)
(undo-and-wait-for-content-tree! page-name initial))))
(deftest drag-indents-and-outdents-test
(testing "dragging into and out of a parent updates both sides of the tree"
(let [page-name (p/get-page-name)
initial [["drag parent" []]
["drag child candidate" []]
["drag tail" []]]
indented [["drag parent" [["drag child candidate" []]]]
["drag tail" []]]
outdented [["drag parent" []]
["drag tail" []]
["drag child candidate" []]]]
(b/new-blocks ["drag parent" "drag child candidate" "drag tail"])
(util/exit-edit)
(wait-for-content-tree! page-name initial)
(drag-block! "drag child candidate" "drag parent" "inside")
(wait-for-content-tree! page-name indented)
(drag-block! "drag child candidate" "drag tail" "after")
(wait-for-content-tree! page-name outdented))))
(deftest drag-rejects-parent-into-descendant-test
(testing "dragging a parent into its descendant leaves the tree unchanged"
(let [page-name (p/get-page-name)]
(b/new-blocks ["cycle parent" "cycle child"])
(b/indent)
(util/exit-edit)
(let [before (content-tree (block-tree page-name))]
(drag-block! "cycle parent" "cycle child" "inside")
(is (= before (content-tree (block-tree page-name))))))))
(deftest undo-history-is-scoped-to-current-graph-test
(testing "Undo in a newly switched graph cannot consume the previous graph history"
(let [graph-a (str "undo-a-" (random-uuid))
graph-b (str "undo-b-" (random-uuid))]
(graph/new-graph graph-a false)
(p/new-page "undo graph a page")
(b/new-block "graph a history")
(let [graph-a-block-id (current-editing-block-id)]
(wait-for-block-content! graph-a-block-id "graph a history")
(graph/new-graph graph-b false)
(p/new-page "undo graph b page")
(b/new-block "graph b history")
(let [graph-b-block-id (current-editing-block-id)]
(wait-for-block-content! graph-b-block-id "graph b history")
(b/undo)
(wait-for-block-content! graph-b-block-id "")
(graph/switch-graph graph-a false false)
(wait-for-block-content! graph-a-block-id "graph a history"))))))