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>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
(ns logseq.e2e.assets-basic-test
(:require
[clojure.string :as string]
[clojure.test :refer [deftest is testing 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.util :as util]
[wally.main :as w])
(:import
(java.nio.file Paths)
(java.util.function Consumer)))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph)
(defn- asset-path
[relative-path]
(-> (Paths/get (str "../" relative-path) (into-array String []))
(.toAbsolutePath)
(.normalize)))
(deftest image-upload-lightbox-and-resize-test
(testing "uploaded images create stable asset blocks and retain resized metadata"
(let [page (w/get-page)
files [(asset-path "assets/icon.png")
(asset-path "assets/splash.png")
(asset-path "resources/img/logo.png")]
pending-files (atom files)]
(.onFileChooser
page
(reify Consumer
(accept [_ chooser]
(let [file (first @pending-files)]
(swap! pending-files rest)
(.setFiles chooser
(into-array java.nio.file.Path [file]))))))
(b/new-block "image uploads")
(doseq [expected-count (range 1 (inc (count files)))]
(util/input-command "Upload an asset")
(assert/assert-have-count
".ls-page-blocks .asset-container img"
expected-count))
(assert/assert-have-count ".ls-page-blocks .asset-container img" 3)
(doseq [image (.all (w/-query ".ls-page-blocks .asset-container img"))]
(is (not (string/blank? (.getAttribute image "src"))))
(is (pos? (.evaluate image "image => image.naturalWidth"))))
(let [first-image (.first (w/-query ".ls-page-blocks .asset-container img"))
first-block (.first (w/-query ".ls-page-blocks .ls-block:has(.asset-container img)"))
block-uuid (.getAttribute first-block "blockid")]
(w/click first-image)
(assert/assert-is-visible ".pswp.pswp--open")
(.waitForFunction page "() => window.pswp?.opener?.isOpen" nil)
(w/click (w/get-by-label "Close"))
(assert/assert-is-hidden ".pswp.pswp--open")
(.hover (.first (w/-query ".ls-page-blocks .ls-resize-image")))
(let [handle (.first (w/-query ".ls-page-blocks .image-resize.handle-right"))
target (.first (w/-query ".ls-page-blocks .block-content"))]
(assert/assert-is-visible handle)
(.dragTo handle target))
(util/wait-timeout 250)
(let [block (ls-api-call! :editor.getBlock block-uuid)
metadata (get block "properties")]
(is (some? metadata)))
(w/refresh)
(assert/assert-have-count ".ls-page-blocks .asset-container img" 3))
(let [asset-tag (ls-api-call! :editor.getTag "logseq.class/Asset")]
(ls-api-call! :app.pushState
"page"
{"name" (get asset-tag "uuid")}
nil))
(w/wait-for ".ls-view-body .ls-table-row")
(assert/assert-have-count ".ls-view-body .ls-table-row" 3)
(assert/assert-is-visible
".ls-table-header-cell:has-text('File')")
(assert/assert-is-hidden
".ls-table-header-cell:has-text('checksum')"))))

View File

@@ -0,0 +1,51 @@
(ns logseq.e2e.bidirectional-properties-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.page :as page]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(deftest bidirectional-properties-test
(testing "shows reverse property references when a class enables bidirectional properties"
(let [friend-prop "friend"
person-tag "Person"
project-tag "Project"
target "Bob"
container-page "Bidirectional Props"]
(ls-api-call! :editor.createTag person-tag
{:tagProperties [{:name friend-prop
:schema {:type "node"}}]})
(ls-api-call! :editor.createTag project-tag)
(let [person (ls-api-call! :editor.getTag person-tag)
person-uuid (get person "uuid")
friend (ls-api-call! :editor.getPage friend-prop)]
(ls-api-call! :editor.upsertBlockProperty (get friend "id")
"logseq.property/classes"
(get person "id"))
(is (string? person-uuid))
(ls-api-call! :editor.upsertBlockProperty person-uuid
"logseq.property.class/bidirectional-property-title"
"People")
(ls-api-call! :editor.upsertBlockProperty person-uuid
"logseq.property.class/enable-bidirectional?"
true))
(ls-api-call! :editor.createPage target)
(ls-api-call! :editor.createPage container-page)
(let [bob (ls-api-call! :editor.getPage target)
bob-id (get bob "id")]
(ls-api-call! :editor.insertBlock container-page (str "Alice #" person-tag)
{:properties {friend-prop bob-id}})
(ls-api-call! :editor.insertBlock container-page (str "Charlie #" project-tag)
{:properties {friend-prop bob-id}}))
(page/goto-page target)
(w/wait-for ".property-k:text('People')")
(assert/assert-is-visible ".property-value .block-title-wrap:text('Alice')")
(assert/assert-have-count ".property-k:text('Projects')" 0))))

View File

@@ -0,0 +1,507 @@
(ns logseq.e2e.block-property-basic-test
(:require
[clojure.test :refer [deftest is testing 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.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph)
(defn- current-page-name
[]
(page/get-page-name))
(defn- add-property-through-ui!
[property-name property-type]
(util/input-command "Add property")
(w/click "input[placeholder]")
(util/input property-name)
(w/click (w/get-by-text "New option:"))
(w/click (loc/and "span" (util/get-by-text property-type true))))
(defn- block-value
[uuid property-name]
(let [result (ls-api-call! :editor.getBlockProperty uuid property-name)]
(if (instance? java.util.Map result)
(get result "value")
result)))
(defn- open-flashcards!
[]
(util/double-esc)
(when-not (w/visible? "#left-sidebar.is-open")
(w/click "#left-menu")
(w/wait-for "#left-sidebar.is-open"))
(w/wait-for ".flashcards-nav")
(w/click ".flashcards-nav a")
(assert/assert-is-visible "#cards-modal"))
(defn- wait-for-property-type
[property-name expected-type]
(loop [remaining-attempts 20]
(let [actual-type (get (ls-api-call! :editor.getProperty property-name)
"type")]
(if (or (= expected-type actual-type)
(zero? remaining-attempts))
actual-type
(do
(util/wait-timeout 50)
(recur (dec remaining-attempts)))))))
(deftest references-embeds-and-mounted-instance-refresh-test
(testing "page refs, embeds, linked refs and mounted copies share canonical updates"
(let [target-page "block sample target"
source-page "block sample source"]
(page/new-page target-page)
(let [target (ls-api-call! :editor.appendBlockInPage
target-page
"target parent")
target-uuid (get target "uuid")]
(ls-api-call! :editor.insertBlock
target-uuid
"target child"
{:sibling false})
(page/new-page source-page)
(b/new-block (str "[[" target-page "]]"))
(b/new-block "")
(util/input-command "Node embed")
(util/press-seq target-page)
(assert/assert-is-visible
(loc/filter ".ui__popover-content a.menu-link.chosen"
:has-text target-page))
(k/enter)
(assert/assert-is-visible
(loc/filter ".embed-block" :has-text "target child"))
(ls-api-call! :editor.openInRightSidebar target-uuid)
(w/click
(format ".cp__right-sidebar #block-content-%s" target-uuid))
(w/fill util/editor-q "target parent updated")
(util/exit-edit)
(doseq [container ["#main-content-container" ".cp__right-sidebar"]]
(assert/assert-is-visible
(loc/filter container :has-text "target parent updated")))
(page/goto-page target-page)
(assert/assert-is-visible
(loc/filter ".references" :has-text source-page))))))
(deftest unlinked-reference-filter-and-breadcrumb-test
(testing "unlinked-reference search updates results and breadcrumbs track renames"
(let [target "reference sample target"
include-page "reference include"
exclude-page "reference exclude"]
(page/new-page target)
(let [parent (ls-api-call! :editor.appendBlockInPage
target
"target nested parent")
parent-uuid (get parent "uuid")
child (ls-api-call! :editor.insertBlock
parent-uuid
"target nested child"
{:sibling false})
child-uuid (get child "uuid")]
(page/new-page include-page)
(b/new-block (str target " plain mention"))
(page/new-page exclude-page)
(b/new-block (str target " another mention"))
(page/goto-page target)
(w/click ".unlinked-references .ls-foldable-title-control")
(assert/assert-is-visible
(loc/filter ".unlinked-references" :has-text include-page))
(w/click
".unlinked-references button:has(.ls-icon-search)")
(w/fill ".unlinked-references .view-action-search input" include-page)
(assert/assert-have-count
(loc/filter ".unlinked-references .ls-view-body" :has-text exclude-page)
0)
(w/click (format "#dot-%s" child-uuid))
(assert/assert-is-visible
(loc/filter ".breadcrumb" :has-text "target nested parent"))
(ls-api-call! :editor.updateBlock parent-uuid "target renamed parent")
(assert/assert-is-visible
(loc/filter ".breadcrumb" :has-text "target renamed parent"))))))
(deftest flashcard-rating-advances-once-test
(testing "answer reveal and one rating advance to the next card and update due count"
(let [page-name (current-page-name)]
(ls-api-call! :editor.appendBlockInPage page-name "sample card one #Card")
(ls-api-call! :editor.appendBlockInPage page-name "sample card two #Card")
(open-flashcards!)
(w/click "#card-answers")
(assert/assert-is-visible
(loc/filter "#cards-modal" :has-text "Again"))
(let [before (util/get-text "#cards-modal")]
(w/click "#card-good")
(let [after (util/get-text "#cards-modal")]
(is (not= before after))))
(assert/assert-have-count "#cards-modal .card-rating-loading" 0))))
(deftest multi-target-comment-draft-edit-delete-test
(testing "multi-target comments preserve drafts and support edit/delete lifecycle"
(b/new-blocks ["comment sample a" "comment sample b"])
(b/select-blocks 2)
(util/search-and-click "Add comment")
(assert/assert-is-visible
(loc/filter ".ls-comments-area" :has-text "those blocks"))
(w/fill ".ls-comment-add textarea" "draft sample comment")
(k/esc)
(assert/assert-have-count
(loc/filter ".ls-comment-row" :has-text "draft sample comment")
0)
(let [reply-placeholder
(loc/filter ".ls-comment-reply-placeholder"
:has-text "draft sample comment")]
(assert/assert-is-visible reply-placeholder)
(w/click reply-placeholder))
(is (= "draft sample comment"
(.inputValue (w/-query ".ls-comment-add textarea"))))
(w/fill ".ls-comment-add textarea" "saved sample comment")
(w/click ".ls-comment-submit")
(assert/assert-is-visible
(loc/filter ".ls-comment-row" :has-text "saved sample comment"))
(w/click ".ls-comment-row button[aria-label='Click to edit']")
(w/fill ".ls-comment-row textarea" "edited sample comment")
(k/enter)
(assert/assert-is-visible
(loc/filter ".ls-comment-row" :has-text "edited sample comment"))
(w/click ".ls-comment-row button[title='Delete']")
(assert/assert-have-count ".ls-comment-row" 0)))
(deftest block-and-comment-reaction-toggle-test
(testing "block and comment reactions use their own targets and toggle without duplicates"
(b/new-blocks ["reaction sample a" "reaction sample b"])
(b/select-blocks 2)
(util/search-and-click "Add reaction")
(w/fill ".ls-icon-picker input" "thumbs up")
(w/click ".ls-icon-picker button:has(em-emoji[id='+1'])")
(assert/assert-have-count
".ls-page-blocks .ls-block-reactions button:has(em-emoji[id='+1'])"
2)
(w/click
".ls-page-blocks .ls-block:has-text('reaction sample a') .ls-block-reactions button:has(em-emoji[id='+1'])")
(assert/assert-have-count
".ls-page-blocks .ls-block:has-text('reaction sample a') .ls-block-reactions button:has(em-emoji[id='+1'])"
0)
(w/click (loc/filter ".block-title-wrap" :has-text "reaction sample b"))
(util/search-and-click "Add comment")
(w/fill ".ls-comment-add textarea" "reaction comment")
(w/click ".ls-comment-submit")
(w/click ".ls-comment-row button[title='Add reaction']")
(w/fill ".ls-icon-picker input" "red heart")
(w/click ".ls-icon-picker button:has(em-emoji[id='heart'])")
(assert/assert-have-count
".ls-comment-row .ls-block-reactions button:has(em-emoji[id='heart'])"
1)
(assert/assert-have-count
".ls-page-blocks .ls-block-reactions button:has(em-emoji[id='heart'])"
1)))
(deftest icon-and-structural-tag-visibility-test
(testing "icons update mounted references and internal tags stay hidden from user tags"
(let [page-name "icon sample page"]
(page/new-page page-name)
(util/exit-edit)
(w/click "button:text('Add icon')")
(w/fill ".cp__emoji-icon-picker input" "books")
(w/click ".cp__emoji-icon-picker button:has(em-emoji[id='books'])")
(assert/assert-is-visible ".ls-page-icon")
(b/new-block "icon sample block")
(util/set-tag "task")
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".block-tag" :has-text "task"))
(assert/assert-have-count
(loc/filter ".block-tag" :has-text "Page")
0)
(ls-api-call! :editor.openInRightSidebar
(get (ls-api-call! :editor.getPage page-name) "uuid"))
(assert/assert-is-visible
".cp__right-sidebar .page-title em-emoji[id='books']")
(w/click ".ls-page-icon button")
(w/click ".cp__emoji-icon-picker button[data-action='del']")
(assert/assert-have-count "em-emoji[id='books']" 0))))
(deftest property-create-and-name-validation-test
(testing "property creation is unique and invalid names cannot create ghost nodes"
(b/new-block "property validation owner")
(add-property-through-ui! "valid-property" "Text")
(assert/assert-have-count
(loc/filter ".property-k" :has-text "valid-property")
1)
(b/open-last-block)
(util/input-command "Add property")
(w/click "input[placeholder]")
(assert/assert-have-count (util/get-by-text "New option:" false) 0)
(k/esc)
(doseq [invalid-name ["[[bad" "#bad"]]
(b/open-last-block)
(util/input-command "Add property")
(w/click "input[placeholder]")
(util/input invalid-name)
(w/click (w/get-by-text "New option:"))
(w/click (loc/and "span" (util/get-by-text "Text" true)))
(assert/assert-is-visible
(loc/filter ".ui__toast.error" :has-text "invalid property name"))
(doseq [message ["Property failed to create"
"invalid property name"]]
(let [toast (loc/filter ".ui__toast.error" :has-text message)]
(when (w/visible? toast)
(w/click (.locator toast ".ui__toast-close")))))
(k/esc))
(is (nil? (ls-api-call! :editor.getProperty "[[bad")))
(is (nil? (ls-api-call! :editor.getProperty "#bad")))))
(deftest scalar-property-value-validation-test
(testing "number/date reject incompatible text and URL values preserve valid edits"
(let [page-name (current-page-name)
block (ls-api-call! :editor.appendBlockInPage page-name "scalar property owner")
uuid (get block "uuid")
journal (ls-api-call! :editor.createJournalPage
"2026-07-28T12:00:00")
journal-id (get journal "id")]
(doseq [[property-name property-type valid-value invalid-value]
[["sample-number" "number" -12.5 "not-a-number"]
["sample-date" "date" journal-id "not-a-date"]]]
(ls-api-call! :editor.upsertProperty property-name {:type property-type})
(ls-api-call! :editor.upsertBlockProperty uuid property-name valid-value)
(is (some? (block-value uuid property-name)))
(let [property-row
(loc/filter (format "#ls-block-%s .bottom-property-pill" uuid)
:has-text property-name)]
(w/click
(.locator property-row ".bottom-property-content .jtrigger")))
(k/press "ControlOrMeta+a")
(util/press-seq invalid-value)
(k/enter)
(is (not= invalid-value (block-value uuid property-name))))
(let [property-name "sample-url"
initial-value "https://logseq.com"
updated-value "https://docs.logseq.com"]
(ls-api-call! :editor.upsertProperty property-name {:type "url"})
(ls-api-call! :editor.upsertBlockProperty uuid property-name initial-value)
(is (= initial-value (block-value uuid property-name)))
(let [property-row
(loc/filter (format "#ls-block-%s .property-pair" uuid)
:has-text property-name)]
(w/click
(.locator property-row ".property-block-container.jtrigger")))
(util/input updated-value)
(k/enter)
(is (= updated-value (block-value uuid property-name))))
(util/refresh-until-graph-loaded)
(assert/assert-is-visible (format "#ls-block-%s" uuid)))))
(deftest property-type-cardinality-and-checkbox-choice-test
(testing "schema controls expose cardinality and allow unused properties to change type"
(let [property-name "sample-schema-property"
checkbox-property-name "sample-checkbox-choice"
page-name (current-page-name)
block (ls-api-call! :editor.appendBlockInPage page-name "schema property owner")
uuid (get block "uuid")]
(ls-api-call! :editor.upsertProperty property-name
{:type "default" :cardinality "many"})
(ls-api-call! :editor.upsertBlockProperty uuid property-name ["one" "two"])
(is (= 2 (count (block-value uuid property-name))))
(page/goto-page property-name)
(assert/assert-is-visible
(loc/filter ".ls-table-header-cell" :has-text property-name))
(w/click
(.first
(loc/filter ".ls-table-header-cell" :has-text property-name)))
(assert/assert-is-visible
(loc/filter ".ls-property-dropdown" :has-text "Multiple values"))
(assert/assert-is-visible
(loc/filter ".ls-property-dropdown" :has-text "Property type"))
(k/esc)
(ls-api-call! :editor.upsertProperty
checkbox-property-name
{:type "default"})
(page/goto-page checkbox-property-name)
(assert/assert-is-visible
(loc/filter ".ls-table-header-cell" :has-text checkbox-property-name))
(w/click
(.first
(loc/filter ".ls-table-header-cell" :has-text checkbox-property-name)))
(w/click
(loc/filter "[role='menuitem']" :has-text "Property type"))
(w/click
(util/get-by-text "Checkbox" true))
(is (= "checkbox"
(wait-for-property-type checkbox-property-name "checkbox"))))))
(deftest property-default-description-position-and-hidden-state-test
(testing "property presentation/default settings apply to new objects and hidden values"
(let [property-name "sample-configured-property"
tag-name "sample configured class"
property (ls-api-call! :editor.upsertProperty
property-name
{:type "default"})
property-uuid (get property "uuid")]
(ls-api-call! :editor.upsertBlockProperty
property-uuid
"logseq.property/default-value"
"sample default")
(ls-api-call! :editor.upsertBlockProperty
property-uuid
"logseq.property/description"
"sample description")
(ls-api-call! :editor.createTag
tag-name
{:tagProperties [{:name property-name}]})
(page/new-page "configured object host")
(b/new-block "configured object")
(util/set-tag tag-name)
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".property-pair" :has-text "sample default"))
(is (= "sample description"
(block-value property-uuid
"logseq.property/description")))
(ls-api-call! :editor.upsertBlockProperty
property-uuid
"logseq.property/hide-empty-value"
true)
(assert/assert-have-count
(loc/filter ".property-pair" :has-text property-name)
0)
(k/press "p")
(k/press "a")
(assert/assert-is-visible
(loc/filter ".property-pair" :has-text "sample default")))))
(deftest property-delete-and-bidirectional-refresh-test
(testing "bidirectional values refresh both sides and deleting the definition removes usages"
(let [property-name "sample bidirectional"
owner-tag "SampleOwner"
container-page "bidirectional refresh host"
owner-class (ls-api-call! :editor.createTag
owner-tag
{:tagProperties [{:name property-name
:schema {:type "node"}}]})
property (ls-api-call! :editor.getProperty property-name)]
(ls-api-call! :editor.upsertBlockProperty
(get property "id")
"logseq.property/classes"
(get owner-class "id"))
(ls-api-call! :editor.upsertBlockProperty
(get owner-class "uuid")
"logseq.property.class/enable-bidirectional?"
true)
(ls-api-call! :editor.createPage container-page)
(let [owner (ls-api-call! :editor.insertBlock
container-page
(str "sample owner object #" owner-tag))
target (ls-api-call! :editor.createPage "sample target object")]
(ls-api-call! :editor.upsertBlockProperty
(get owner "uuid") property-name (get target "id"))
(page/goto-page "sample target object")
(assert/assert-is-visible
(loc/filter ".ls-bidirectional-properties" :has-text "sample owner object"))
(ls-api-call! :editor.removeBlockProperty (get owner "uuid") property-name)
(assert/assert-have-count
(loc/filter ".ls-bidirectional-properties" :has-text "sample owner object")
0)
(ls-api-call! :editor.removeProperty property-name)
(is (nil? (ls-api-call! :editor.getProperty property-name)))))))
(deftest tag-inheritance-schema-and-object-view-test
(testing "class inheritance and schema mutations update object views without losing values"
(let [parent-tag-name "SampleParent"
child-tag-name "SampleChild"
inherited-property "sample-inherited-property"
child-property "sample-child-property"
parent-tag (ls-api-call! :editor.createTag
parent-tag-name
{:tagProperties [{:name inherited-property}]})
child-tag (ls-api-call! :editor.createTag
child-tag-name
{:tagProperties [{:name child-property}]})]
(ls-api-call! :editor.addTagExtends
(get child-tag "id")
(get parent-tag "id"))
(page/new-page "tag object host")
(let [object (ls-api-call! :editor.appendBlockInPage
(current-page-name)
(str "sample child object #" child-tag-name)
{:properties {inherited-property "kept"}})]
(page/goto-page parent-tag-name)
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text "sample child object"))
(assert/assert-is-visible
(loc/filter ".ls-table-header-cell" :has-text inherited-property))
(ls-api-call! :editor.upsertBlockProperty
(get object "uuid") child-property "child value")
(page/goto-page child-tag-name)
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text "child value"))
(is (= "kept"
(block-value (get object "uuid")
inherited-property)))))))
(deftest tag-template-dynamic-values-test
(testing "a tag template is applied once and resolves dynamic date references"
(let [tag-name "sample template class"
template-page "sample tag template"
template-root "sample template root"]
(page/new-page template-page)
(let [template (ls-api-call! :editor.appendBlockInPage
template-page
template-root)
template-uuid (get template "uuid")
template-tag (ls-api-call! :editor.getTag "Template")
tag (ls-api-call! :editor.createTag tag-name)]
(ls-api-call! :editor.upsertBlockProperty
template-uuid
"block/tags"
(get template-tag "id"))
(ls-api-call! :editor.insertBatchBlock
template-uuid
[{:content "template static child"}
{:content "template date <% today %>"}]
{:sibling false})
(ls-api-call! :editor.upsertBlockProperty
template-uuid
"logseq.property/template-applied-to"
(get tag "id"))
(page/new-page "sample templated object")
(let [object (ls-api-call! :editor.appendBlockInPage
(current-page-name)
"templated object")]
(ls-api-call! :editor.upsertBlockProperty
(get object "uuid")
"block/tags"
(get tag "id")))
(assert/assert-have-count
(loc/filter ".ls-page-blocks" :has-text "template static child")
1)
(assert/assert-is-visible ".page-reference")
(util/refresh-until-graph-loaded)
(assert/assert-have-count
(loc/filter ".ls-page-blocks" :has-text "template static child")
1)))))
(deftest checkbox-property-toggle-persists-test
(testing "checkbox property toggles immediately and survives refresh"
(let [property-name "e2e checkbox"
page-name (page/get-page-name)]
(ls-api-call! :editor.upsertProperty property-name {:type "checkbox"})
(let [block (ls-api-call! :editor.appendBlockInPage
page-name
"checkbox owner"
{:properties {property-name false}})
uuid (get block "uuid")
checkbox (w/-query
(format "#ls-block-%s .bottom-property-pill" uuid))]
(w/click (.locator checkbox "button[role='checkbox']"))
(assert/assert-is-visible
(.locator checkbox "button[role='checkbox'][data-checked]"))
(is (true? (block-value uuid property-name)))
(util/refresh-until-graph-loaded)
(assert/assert-is-visible
(format "#ls-block-%s [role='checkbox'][data-checked]" uuid))))))

View File

@@ -0,0 +1,163 @@
(ns logseq.e2e.cmdk-scroll-basic-test
"E2E tests for Cmd+K scroll & highlight behavior."
(:require
[clojure.test :refer [deftest testing is use-fixtures]]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.page :as p]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph)
(def ^:private scroll-container ".cp__cmdk .overflow-y-auto")
(def ^:private kbd-highlight (str scroll-container " [data-kb-highlighted]"))
(def ^:private mouse-item (str scroll-container " .transition-colors.cursor-pointer"))
(defn- setup-search
"Creates `n` pages with `prefix` and `suffix`, opens cmdk and types the `prefix`."
([prefix n] (setup-search prefix n ""))
([prefix n suffix]
[prefix n suffix]
(dotimes [i n] (p/new-page (str prefix i suffix)))
(util/search prefix)
(util/wait-timeout 500)))
(defn- simulate-mouse-move-into-results!
"Dispatches a synthetic mousemove with non-zero movementX/Y on a result item.
Playwright's CDP mouse.move does not populate movementX/Y, so we must
construct the event in JS and patch those read-only fields."
[]
(w/eval-js
(str "(() => {
const c = document.querySelector('" scroll-container "');
if (!c) return;
const items = c.querySelectorAll('.transition-colors');
const target = items[2] || items[0] || c;
const rect = target.getBoundingClientRect();
const evt = new MouseEvent('mousemove', {
bubbles: true, cancelable: true,
clientX: rect.x + rect.width / 2,
clientY: rect.y + rect.height / 2
});
Object.defineProperty(evt, 'movementX', {value: 5});
Object.defineProperty(evt, 'movementY', {value: 3});
target.dispatchEvent(evt);
})()")))
(defn- kbd-highlight-in-viewport?
"Returns true when the keyboard-highlighted element is fully within
the visible scroll area. Allows ±5 px tolerance for borders/padding."
[]
(w/eval-js
(str "(() => {
const el = document.querySelector('" kbd-highlight "');
if (!el) return false;
const c = document.querySelector('" scroll-container "');
const cr = c.getBoundingClientRect();
const er = el.getBoundingClientRect();
return er.top >= cr.top - 5 && er.bottom <= cr.bottom + 5;
})()")))
(deftest cmdk-keeps-results-visible-while-searching
(testing "typing and deleting do not clear the visible results before the debounced search finishes"
(let [prefix (str "cmdkflicker" (random-uuid))
result-selector (format "[data-testid^='%s']" prefix)]
(setup-search prefix 2 "a")
(let [result-count (util/count-elements result-selector)]
(is (pos? result-count) "initial search results are visible")
(util/press-seq "a")
(is (= result-count (util/count-elements result-selector))
"typing keeps the previous results visible")
(util/wait-timeout 400)
(is (= result-count (util/count-elements result-selector))
"matching results remain visible after search settles")
(k/press "Backspace")
(is (= result-count (util/count-elements result-selector))
"deleting keeps the previous results visible")
(util/wait-timeout 400)
(is (= result-count (util/count-elements result-selector))
"matching results remain visible after deletion search settles")))))
;; ---------------------------------------------------------------------------
;; Highlight mode switching
;;
;; Cycles keyboard → mouse → keyboard → mouse and verifies:
;; • keyboard mode: [data-kb-highlighted] = 1, mouse-item count = 0
;; • mouse mode: [data-kb-highlighted] = 0, mouse-item count > 0
;; ---------------------------------------------------------------------------
(deftest cmdk-highlight-mode-switching
(testing "keyboard & mouse highlight mode toggles correctly"
(setup-search "cmdkhighlight" 8)
(dotimes [_ 3] (k/arrow-down))
(util/wait-timeout 100)
(is (= 1 (util/count-elements kbd-highlight))
"keyboard mode → keyboard-highlight = 1")
(is (= 0 (util/count-elements mouse-item))
"keyboard mode → mouse-mode items = 0")
(simulate-mouse-move-into-results!)
(util/wait-timeout 200)
(is (= 0 (util/count-elements kbd-highlight))
"mouse mode → keyboard-highlight = 0")
(is (pos? (util/count-elements mouse-item))
"mouse mode → mouse-mode items > 0")
(k/arrow-down)
(util/wait-timeout 100)
(is (= 1 (util/count-elements kbd-highlight))
"keyboard mode again → keyboard-highlight = 1")
(is (= 0 (util/count-elements mouse-item))
"keyboard mode again → mouse-mode items = 0")
(simulate-mouse-move-into-results!)
(util/wait-timeout 200)
(is (= 0 (util/count-elements kbd-highlight))
"mouse mode again → keyboard-highlight = 0")
(is (pos? (util/count-elements mouse-item))
"mouse mode again → mouse-mode items > 0")))
;; ---------------------------------------------------------------------------
;; Lazy-visible keyboard scroll verification
;;
;; Goal:
;; Verify that keyboard navigation scrolls correctly through :nodes items
;; that are wrapped in lazy-visible (always active for :nodes group).
;; This exercises the data-item-index wrapper-div DOM query path in
;; move-highlight, which finds the wrapper even when the inner list-item
;; has not yet been mounted by IntersectionObserver.
;;
;; Layout:
;; viewport_height = 65dvh × 720 px (Playwright default) = 468 px
;; item_height ≈ 32 px, group_header = 32 px
;; Create (32) + nodes header (32) + 30 nodes items (30 × 32 = 960)
;; = 1024 px total — significantly exceeds viewport
;; Navigating 20 items → top ≈ 32 + 32 + 20×32 = 704 px
;; → far below viewport bottom (468 px), deep into lazy territory
;; ---------------------------------------------------------------------------
(deftest cmdk-lazy-visible-keyboard-scroll
(testing "keyboard navigation scrolls through lazy-visible :nodes items"
(setup-search "cmdklazy" 30)
;; Expand :nodes group — sets :show :more.
(k/arrow-down)
(util/wait-timeout 100)
(k/press "ControlOrMeta+ArrowDown")
(util/wait-timeout 300)
;; Navigate down 20 steps — well past the viewport into lazy items.
(k/press (vec (repeat 20 "ArrowDown")) {:delay 30})
(util/wait-timeout 300)
;; Verify keyboard highlight exists.
(is (= 1 (util/count-elements kbd-highlight))
"keyboard highlight present after navigating through lazy items")
;; Verify the highlighted element is within the visible scroll area.
(is (kbd-highlight-in-viewport?) "highlight is in viewport after navigating through lazy-visible items")))

View File

@@ -0,0 +1,416 @@
(ns logseq.e2e.commands-basic-test
(:require
[clojure.string :as string]
[clojure.test :refer [deftest testing is use-fixtures]]
[jsonista.core :as json]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(def ^:private date-picker-day-selector
".ui__calendar [role='gridcell'] button, .ui__calendar button[role='gridcell']")
(defn- focused-date-picker-day
[]
(json/read-value
(w/eval-js
(format
"(() => {
const active = document.activeElement;
const dayButton = active?.closest?.(%s);
return JSON.stringify({
focused: !!dayButton,
text: dayButton?.textContent ?? null,
label: dayButton?.getAttribute('aria-label') ?? dayButton?.textContent ?? null
});
})()"
(json/write-value-as-string date-picker-day-selector)))
json/keyword-keys-object-mapper))
(defn- assert-date-picker-keyboard-navigation
[command]
(b/new-block (str command " keyboard test"))
(util/input-command command)
(w/wait-for date-picker-day-selector)
(let [initial (focused-date-picker-day)]
(is (:focused initial)
(str command " should focus a calendar day when opened."))
(k/arrow-right)
(let [right (focused-date-picker-day)]
(is (:focused right)
(str command " should keep calendar focus after ArrowRight."))
(is (not= (:label initial) (:label right))
(str command " should move focused date with ArrowRight."))
(k/arrow-left)
(is (= (:label initial) (:label (focused-date-picker-day)))
(str command " should move focused date back with ArrowLeft.")))
(k/arrow-down)
(let [down (focused-date-picker-day)]
(is (:focused down)
(str command " should keep calendar focus after ArrowDown."))
(is (not= (:label initial) (:label down))
(str command " should move focused date with ArrowDown."))
(k/arrow-up)
(is (= (:label initial) (:label (focused-date-picker-day)))
(str command " should move focused date back with ArrowUp.")))
(k/enter)
(if (= command "date picker")
(w/wait-for-not-visible ".ui__calendar")
(assert/assert-is-visible ".ui__calendar"))))
(deftest command-trigger-test
(testing "/command trigger popup"
(b/new-block "b2")
(util/press-seq " /")
(w/wait-for "a.menu-link.chosen:has-text('Node reference')")
(k/backspace)
(w/wait-for-not-visible ".ui__popover-content")))
(deftest page-reference-test
(testing "Page reference"
(b/new-blocks ["b1" ""])
(util/input-command "Node reference")
(util/press-seq "Another page")
(k/enter)
(is (= "[[Another page]]" (util/get-edit-content)))
(util/exit-edit)
(is (= "Another page" (util/get-text "a.page-ref")))))
(deftest block-reference-test
(testing "Block reference"
(b/new-blocks ["block test" ""])
(util/input-command "Node reference")
(util/press-seq "block test")
(util/wait-timeout 300)
(k/enter)
(is (string/includes? (util/get-edit-content) "[["))
(util/exit-edit)
(is (= "block test" (util/get-text "a.page-ref")))))
(deftest link-test
(testing "/link"
(let [add-logseq-link (fn []
(util/press-seq "https://logseq.com")
(k/tab)
(util/press-seq "Logseq")
(k/tab)
(k/enter))]
(b/new-block "")
(util/input-command "link")
(add-logseq-link)
(is (= "[Logseq](https://logseq.com)" (util/get-edit-content)))
(util/press-seq " some content ")
(util/input-command "link")
(add-logseq-link)
(is (= (str "[Logseq](https://logseq.com)"
" some content "
"[Logseq](https://logseq.com)") (util/get-edit-content))))))
(deftest link-image-test
(testing "/image link"
(b/new-block "")
(util/input-command "image link")
(util/press-seq "https://logseq.com/test.png")
(k/tab)
(util/press-seq "Logseq")
(k/tab)
(k/enter)
(is (= "![Logseq](https://logseq.com/test.png)" (util/get-edit-content)))))
(deftest underline-test
(testing "/underline"
(b/new-block "")
(util/input-command "underline")
(is (= "<ins></ins>" (util/get-edit-content)))
(util/press-seq "test")
(is (= "<ins>test</ins>" (util/get-edit-content)))
(util/move-cursor-to-end)))
(deftest code-block-test
(testing "/code block"
(b/new-block "")
(util/input-command "code block")
(w/wait-for ".CodeMirror")
(util/wait-timeout 100)
;; create another block
(k/shift+enter)
(assert/assert-is-hidden ".ls-page-blocks .block-tags")))
(deftest math-block-test
(testing "/math block"
(b/new-block "")
(util/input-command "math block")
(util/press-seq "1 + 2 = 3")
(util/exit-edit)
(w/wait-for ".katex")
(assert/assert-is-hidden ".ls-page-blocks .block-tags")))
(deftest quote-test
(testing "/quote"
(b/new-block "")
(util/input-command "quote")
(w/wait-for "div[data-node-type='quote']")
(assert/assert-is-hidden ".ls-page-blocks .block-tags")))
(deftest quote-heading-test
(testing "quote headings render consistently"
(b/new-block "Property quote heading")
(util/input-command "quote")
(util/input-command "h1")
(util/exit-edit)
(b/new-block "# Markdown quote heading")
(util/input-command "quote")
(util/exit-edit)
(assert/assert-is-visible
"div[data-node-type='quote']:has(h1.block-title-wrap.as-heading:has-text('Property quote heading'))")
(assert/assert-is-visible
"div[data-node-type='quote']:has(h1.block-title-wrap.as-heading:has-text('Markdown quote heading'))")
(b/jump-to-block "Markdown quote heading")
(assert/assert-editor-mode)
(is (= "Markdown quote heading" (util/get-edit-content)))
(util/exit-edit)
(b/new-block "Plain quote")
(util/input-command "quote")
(util/exit-edit)
(assert/assert-is-visible
"div[data-node-type='quote']:has(span.block-title-wrap:has-text('Plain quote'))")))
(deftest headings-test
(testing "/heading"
(dotimes [i 6]
(let [heading (str "h" (inc i))
text (str heading " test ")]
(b/new-block text)
(util/input-command heading)
(is (= text (util/get-edit-content)))
(util/exit-edit)
(w/wait-for heading)))))
(deftest status-test
(testing "task status commands"
(let [status->icon {"Doing" "InProgress50"
"In review" "InReview"
"Canceled" "Cancelled"}]
(doseq [status ["Backlog" "Todo" "Doing" "In review" "Done" "Canceled"]]
(let [text (str status " test ")]
(b/new-block text)
(util/input-command status)
(is (= text (util/get-edit-content)))
(util/exit-edit)
(k/esc)
(w/wait-for (str ".ls-icon-" (get status->icon status status))))))))
(deftest priority-test
(testing "task priority commands"
(let [priority->icon {"No priority" "line-dashed"}]
(doseq [priority ["No priority" "Low" "Medium" "High" "Urgent"]]
(let [text (str priority " test ")]
(b/new-block text)
(util/input-command priority)
(is (= text (util/get-edit-content)))
(util/exit-edit)
(w/wait-for (str ".ls-icon-" (get priority->icon priority
(str "priorityLvl" priority)))))))))
(deftest scheduled-deadline-test
(testing "task scheduled and deadline commands"
(doseq [command ["Scheduled" "Deadline"]]
(fixtures/create-page)
(let [text (str command " test ")]
(b/new-block text)
(util/input-command command)
(k/enter)
(assert/assert-editor-mode)
(util/exit-edit)
(is (= command (util/get-text ".property-k")))
(is (= "Today" (util/get-text ".ls-datetime a.page-ref")))))))
(deftest date-command-keyboard-navigation-test
(testing "date commands focus the calendar and support keyboard navigation"
(doseq [command ["date picker" "Scheduled" "Deadline"]]
(fixtures/create-page)
(assert-date-picker-keyboard-navigation command))))
;; TODO: java "MMMM d, yyyy" vs js "MMM do, yyyy"
(deftest date-time-test
(testing "date time commands"
(util/input-command "today")
(let [text (util/get-edit-content)]
(and (string/starts-with? text "[[")
(string/ends-with? text "]]")))
(b/new-block "")
(util/input-command "yesterday")
(let [text (util/get-edit-content)]
(and (string/starts-with? text "[[")
(string/ends-with? text "]]")))
(b/new-block "")
(util/input-command "tomorrow")
(let [text (util/get-edit-content)]
(and (string/starts-with? text "[[")
(string/ends-with? text "]]")))
;; FIXME:
;; (b/new-block "")
;; (util/input-command "time")
;; (let [text (util/get-edit-content)
;; t (tl/local-now)]
;; (is (= text (str (t/hour t) ":" (t/minute t)))))
(b/new-block "")
(util/input-command "date picker")
(let [text (util/get-edit-content)]
(and (string/starts-with? text "[[")
(string/ends-with? text "]]")))))
(deftest number-list-test
(testing "number list commands"
(util/input-command "number list")
(b/new-blocks ["a" "b" "c"])
(assert/assert-have-count "span.typed-list" 3)
(is (= ["1." "2." "3."] (w/all-text-contents "span.typed-list")))
;; double `enter` convert the next block to bullet block
(k/enter)
(assert/assert-have-count "span.typed-list" 4)
(util/wait-timeout 60)
(k/enter)
(assert/assert-have-count "span.typed-list" 3)
(is (= ["1." "2." "3."] (w/all-text-contents "span.typed-list")))))
(deftest number-children-test
(testing "number children commands"
(b/new-blocks ["a" "a1" "a2" "a3" "b"])
(k/arrow-up)
(w/wait-for "textarea:text('a3')")
(util/repeat-keyboard 3 "Shift+ArrowUp")
(k/tab)
(b/jump-to-block "a")
(util/input-command "number children")
(assert/assert-have-count "span.typed-list" 3)
(is (= ["1." "2." "3."] (w/all-text-contents "span.typed-list")))))
(deftest query-test
(testing "query"
(b/new-blocks ["[[foo]] block" "[[foo]] another" ""])
(util/input-command "query")
(let [btn (util/-query-last "button:text('filter')")]
(w/click btn)
(util/input "page reference")
(w/click "a.menu-link:has-text('page reference')")
(w/click (first (w/query "a.menu-link:has-text('foo')")))
(assert/assert-is-visible "div:text('Live query (2)')"))))
(deftest query-view-membership-updates-live
(testing "a mounted query view inserts and removes matching rows"
(let [tag "live-query-membership"
candidate-title "query membership candidate"]
(b/new-blocks [(format "[[%s]] query seed" tag)
candidate-title
""])
(let [candidate-uuid (.getAttribute
(.first
(w/-query
(format ".ls-block[data-block-title='%s']"
candidate-title)))
"blockid")
candidate-row (format ".custom-query-results :text('%s')"
candidate-title)]
(is (string? candidate-uuid))
(util/input-command "query")
(w/click (util/-query-last "button:text('filter')"))
(util/input "page reference")
(w/click "a.menu-link:has-text('page reference')")
(w/click (first (w/query (format "a.menu-link:has-text('%s')" tag))))
(w/wait-for "div:text('Live query (1)')")
(ls-api-call! :editor.updateBlock
candidate-uuid
(format "[[%s]] %s" tag candidate-title))
(w/wait-for "div:text('Live query (2)')")
(w/wait-for candidate-row)
(ls-api-call! :editor.updateBlock candidate-uuid candidate-title)
(w/wait-for "div:text('Live query (1)')")
(w/wait-for-not-visible candidate-row)))))
(deftest advanced-query-test
(testing "query"
(b/new-blocks ["[[bar]] block" "[[bar]] another" ""])
(util/input-command "advanced query")
(w/click ".ls-query-setting")
(w/click (.first (w/-query "pre.CodeMirror-line")))
(util/input "{:query [:find (pull ?b [*])
:where [?b :block/refs ?r]
[?r :block/title \"bar\"]]}")
(k/esc)
(is (some? (w/find-one-by-text "div" "Live query (2)")))))
(deftest calculator-test
(testing "calculator"
(b/new-block "")
(util/input-command "calculator")
(util/input "1 + 2")
(w/wait-for "div.extensions__code-calc-output-line")
(is (= "3" (util/get-text "div.extensions__code-calc-output-line")))
(assert/assert-is-hidden ".ls-page-blocks .block-tags")))
(deftest template-test
(testing "template"
(b/new-block "template 1")
(util/set-tag "Template" :hidden? true)
(b/new-blocks ["block 1" "block 2" "block 3" "test"])
(k/arrow-up)
(w/wait-for "textarea:text('block 3')")
(util/repeat-keyboard 3 "Shift+ArrowUp")
(k/tab)
(b/jump-to-block "test")
(util/input-command "template")
(util/input "template 1")
(w/wait-for "a.menu-link.chosen:has-text('template 1')")
(k/enter)
(doseq [text ["block 1" "block 2" "block 3"]]
(assert/assert-have-count
(loc/or (format ".ls-block .block-title-wrap:text('%s')" text)
(format ".ls-block textarea:text('%s')" text))
2))))
(deftest embed-html-test
(testing "embed html"
(b/new-block "")
(util/input-command "embed html")
(util/press-seq "<div id=\"embed-test\">test</div>")
(util/exit-edit)
(is (= "test" (util/get-text "#embed-test")))))
(deftest embed-video-test
(testing "embed video"
(b/new-block "")
(util/input-command "embed video")
(util/press-seq "https://www.youtube.com/watch?v=7xTGNNLPyMI")
(util/exit-edit)
(w/wait-for "iframe")))
(deftest embed-tweet-test
(testing "embed tweet"
(b/new-block "")
(util/input-command "embed tweet")
(util/press-seq "https://x.com/logseq/status/1784914564083314839")
(util/exit-edit)
(w/wait-for "iframe")))
(deftest cloze-test
(testing "cloze"
(b/new-block "")
(util/input-command "cloze")
(util/press-seq "hidden answer")
(util/exit-edit)
(w/click "span.cloze")
(w/wait-for "span.cloze-revealed")))

View File

@@ -0,0 +1,70 @@
(ns logseq.e2e.custom-report
(:require [clojure.pprint :as pp]
[clojure.stacktrace :as stack]
[clojure.string :as string]
[clojure.test :as t]
[logseq.e2e.playwright-page :as pw-page])
(:import (com.microsoft.playwright Page$ScreenshotOptions)))
(def ^:dynamic *pw-contexts*
"Set of pw-contexts.
record all playwright contexts in this dynamic var"
nil)
(def ^:dynamic *pw-page->console-logs* nil)
(def ^:dynamic *preserve-graph* nil)
(defn screenshot
[page test-name]
(println :screenshot test-name)
(.screenshot
page
(-> (Page$ScreenshotOptions.)
(.setPath (java.nio.file.Paths/get "e2e-dump/"
(into-array [(format "./screenshot-%s-%s.png" test-name (System/currentTimeMillis))]))))))
(defn- collect-info-when-error-or-failed
[]
;; screenshot for all pw pages when :error
(when-let [all-contexts (seq *pw-contexts*)]
(doseq [page (mapcat pw-page/get-pages all-contexts)]
(screenshot page (string/join "-" (map (comp str :name meta) t/*testing-vars*)))))
;; dump console logs
(when-let [pw-page->console-logs (some-> *pw-page->console-logs* deref)]
(doseq [[_pw-page logs] pw-page->console-logs]
(spit (format "e2e-dump/console-logs-%s.txt" (System/currentTimeMillis))
(with-out-str (pp/pprint logs)))))
(when (some? *preserve-graph*)
(set! *preserve-graph* true)))
(defmethod t/report :error
[m]
;; copy from default impl
(t/with-test-out
(t/inc-report-counter :error)
(println "\nERROR in" (t/testing-vars-str m))
(when (seq t/*testing-contexts*) (println (t/testing-contexts-str)))
(when-let [message (:message m)] (println message))
(println "expected:" (pr-str (:expected m)))
(print " actual: ")
(let [actual (:actual m)]
(if (instance? Throwable actual)
(stack/print-cause-trace actual t/*stack-trace-depth*)
(prn actual))))
(collect-info-when-error-or-failed))
(defmethod t/report :fail
[m]
(t/with-test-out
(t/inc-report-counter :fail)
(println "\nFAIL in" (t/testing-vars-str m))
(when (seq t/*testing-contexts*) (println (t/testing-contexts-str)))
(when-let [message (:message m)] (println message))
(println "expected:" (pr-str (:expected m)))
(println " actual:" (pr-str (:actual m))))
(collect-info-when-error-or-failed))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
(ns logseq.e2e.export-basic-test
(:require
[clojure.string :as string]
[clojure.test :refer [deftest is testing use-fixtures]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.locator :as loc]
[logseq.e2e.util :as util]
[wally.main :as w])
(:import
(com.microsoft.playwright Page$WaitForDownloadOptions)
(java.nio.file Files)
(java.util.zip ZipFile)))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph)
(defn- open-export!
[]
(util/double-esc)
(w/click ".toolbar-dots-btn")
(w/click (loc/filter "[role='menuitem']" :has-text "Export graph"))
(w/wait-for ".export"))
(defn- download!
([label]
(.waitForDownload
(w/get-page)
(reify Runnable
(run [_]
(w/click (loc/filter ".export a" :has-text label))))))
([label timeout]
(.waitForDownload
(w/get-page)
(-> (Page$WaitForDownloadOptions.)
(.setTimeout timeout))
(reify Runnable
(run [_]
(w/click (loc/filter ".export a" :has-text label)))))))
(defn- nonempty-download?
[download]
(and (not (string/blank? (.suggestedFilename download)))
(pos? (Files/size (.path download)))))
(deftest graph-export-downloads-browser-artifacts-test
(testing "browser graph export produces nonempty DB, zip, EDN, Markdown and transit files"
(b/new-blocks ["export root" "export child"])
(util/set-tag "export-tag")
(b/indent)
(open-export!)
(assert/assert-is-visible
(loc/filter ".export a" :has-text "Export SQLite DB"))
(doseq [label ["Export EDN file"
"Export as standard Markdown"
"Export debug transit file"]]
(let [download (download! label)]
(is (nonempty-download? download) label)))
(let [download (download! "Export both SQLite DB and assets" 60000)
path (.path download)]
(is (nonempty-download? download))
(with-open [zip (ZipFile. (.toFile path))]
(let [entries (map #(.getName %) (enumeration-seq (.entries zip)))]
(is (some #(string/ends-with? % ".sqlite") entries))
(is (every? #(not (string/blank? %)) entries)))))
(assert/assert-is-hidden ".ui__loading, .loading-graph")))

View File

@@ -0,0 +1,169 @@
(ns logseq.e2e.fixtures
(:require [com.climate.claypoole :as cp]
[logseq.e2e.assert :as assert]
[logseq.e2e.config :as config]
[logseq.e2e.const :refer [*page1 *page2 *graph-name*]]
[logseq.e2e.custom-report :as custom-report]
[logseq.e2e.graph :as graph]
[logseq.e2e.page :as page]
[logseq.e2e.playwright-page :as pw-page]
[logseq.e2e.rtc :as rtc]
[logseq.e2e.settings :as settings]
[logseq.e2e.util :as util]
[wally.main :as w]))
;; TODO: save trace
;; TODO: parallel support
(defn open-page
[f & {:keys [headless port]}]
(w/with-page-open
(w/make-page {:headless (or headless @config/*headless)
:persistent false
:slow-mo @config/*slow-mo})
(w/grant-permissions :clipboard-write :clipboard-read)
(binding [custom-report/*pw-contexts* #{(.context (w/get-page))}
custom-report/*pw-page->console-logs* (atom {})]
(settings/install-init-script! (.context (w/get-page)))
(w/grant-permissions :clipboard-write :clipboard-read)
(w/navigate (pw-page/get-test-url port))
(settings/developer-mode)
(settings/refresh-test-env!)
(let [p (w/get-page)]
(.onConsoleMessage p (fn [msg]
(when custom-report/*pw-page->console-logs*
(swap! custom-report/*pw-page->console-logs* update p conj (.text msg))))))
(f))))
(defn open-2-pages
"Use `*page1` and `*page2` in `f`"
[f & {:keys [headless port]}]
(let [headless (or headless @config/*headless)
page-opts {:headless headless
:persistent false
:slow-mo @config/*slow-mo}
p1 (w/make-page page-opts)
p2 (w/make-page page-opts)]
(run! #(settings/install-init-script! (.context @%)) [p1 p2])
(reset! *page1 p1)
(reset! *page2 p2)
(binding [custom-report/*pw-contexts* (set [(.context @p1) (.context @p2)])
custom-report/*pw-page->console-logs* (atom {})
w/*page* (delay (throw (ex-info "Don't use *page*, use *page1* and *page2* instead" {})))]
(run!
#(w/with-page %
(w/grant-permissions :clipboard-write :clipboard-read)
(w/navigate (pw-page/get-test-url (or port @config/*port)))
(settings/developer-mode)
(settings/refresh-test-env!)
(let [p (w/get-page)]
(.onConsoleMessage
p
(fn [msg]
(when custom-report/*pw-page->console-logs*
(swap! custom-report/*pw-page->console-logs* update p conj (.text msg)))))))
[p1 p2])
(f))
;; use with-page-open to release resources
(w/with-page-open p1)
(w/with-page-open p2)
(reset! *page1 nil)
(reset! *page2 nil)))
(def ^:dynamic *pw-ctx* nil)
(defn open-new-context
"create a new playwright-context in `*pw-ctx*`"
[f]
(let [page-opts {:headless @config/*headless
:persistent false
:slow-mo @config/*slow-mo}
p @(w/make-page page-opts)
ctx (.newContext (.browser (.context p)))]
;; context for p is no longer needed
(.close (.context p))
(w/with-page-open p) ; use with-page-open to close playwright instance
(binding [custom-report/*pw-contexts* #{ctx}
*pw-ctx* ctx]
(settings/install-init-script! ctx)
(f)
(.close (.browser *pw-ctx*)))))
(defonce *page-number (atom 0))
(defn create-page
[& [page-name]]
(let [page-name (or page-name (str "page " (swap! *page-number inc)))]
(page/new-page page-name)
page-name))
(defn new-logseq-page
[f]
(when (w/visible? ".cp__right-sidebar.open")
(w/click ".toggle-right-sidebar")
(w/wait-for-not-visible ".cp__right-sidebar.open"))
(w/eval-js
"() => {
const url = new URL(location.href);
url.searchParams.delete('virtualized');
history.replaceState(null, '', url.pathname + url.search + url.hash);
}")
(create-page)
(f))
(defn new-logseq-page-in-rtc*
"create a logseq page and switch to this page on both `*page1` and `*page2`"
[& [page-name]]
(let [*page-name (atom nil)
{:keys [_local-tx remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(reset! *page-name (create-page page-name))))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)
(page/goto-page @*page-name))))
(defn new-logseq-page-in-rtc
[f]
(new-logseq-page-in-rtc*)
(f))
(defn validate-graph
[f]
(f)
(if (and @*page1 @*page2)
(doseq [p [@*page1 @*page2]]
(w/with-page p
(graph/validate-graph)))
(graph/validate-graph)))
(def ^:private formatter (java.time.format.DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH-mm-ss"))
(defn- inst-string
[inst]
(.format formatter (.atZone inst (java.time.ZoneId/of "UTC"))))
(defn prepare-rtc-graph-fixture
"open 2 app instances, add a rtc graph, check this graph available on other instance"
[graph-name-prefix f]
(let [graph-name (str graph-name-prefix "-" (inst-string (java.time.Instant/now)))]
(cp/prun!
2
#(w/with-page %
(settings/developer-mode)
(settings/refresh-test-env!)
(util/login-test-account))
[@*page1 @*page2])
(w/with-page @*page1
(graph/new-graph graph-name true false))
(w/with-page @*page2
(graph/wait-for-remote-graph graph-name)
(graph/switch-graph graph-name true true))
(binding [custom-report/*preserve-graph* false
*graph-name* graph-name]
(f)
;; cleanup
(if custom-report/*preserve-graph*
(println "Don't remove graph: " graph-name)
(w/with-page @*page2
(graph/remove-remote-graph graph-name))))))

View File

@@ -0,0 +1,84 @@
(ns logseq.e2e.flashcards-basic-test
(:require [clojure.test :refer [deftest testing use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.locator :as loc]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/validate-graph)
(defn- open-flashcards
[]
(util/double-esc)
(when-not (w/visible? ".flashcards-nav")
(w/click "#left-menu")
(w/wait-for ".flashcards-nav"))
(w/eval-js "selector => document.querySelector(selector)?.click()" ".flashcards-nav a")
(assert/assert-is-visible "#cards-modal"))
(defn- select-cards-option
[label]
(w/click "#cards-modal [role='combobox']")
(w/click (loc/filter "[role='option']" :has-text label)))
(defn- click-flashcards-plus
[]
(w/click "#ls-cards-add"))
(defn- setup-flashcards-data!
[{:keys [page-name tag-a tag-b card-a card-b query-a query-b]}]
(ls-api-call! :editor.appendBlockInPage page-name (str card-a " #Card #" tag-a))
(ls-api-call! :editor.appendBlockInPage page-name (str card-b " #Card #" tag-b))
(let [cards (ls-api-call! :editor.getTag "logseq.class/Cards")
cards-id (get cards "id")
cards-a (ls-api-call! :editor.appendBlockInPage page-name "Cards A"
{:properties {:block/tags #{cards-id}}})
cards-b (ls-api-call! :editor.appendBlockInPage page-name "Cards B"
{:properties {:block/tags #{cards-id}}})
query-a-id (get cards-a ":logseq.property/query")
query-b-id (get cards-b ":logseq.property/query")]
(ls-api-call! :editor.updateBlock query-a-id query-a)
(ls-api-call! :editor.updateBlock query-b-id query-b)))
(deftest flashcards-plus-and-switching-test
(testing "create #Cards blocks from flashcards dialog and switch card sets"
(let [tag-a "fc-tag-a"
tag-b "fc-tag-b"
card-a "Card A"
card-b "Card B"
query-a (str "[[" tag-a "]]")
query-b (str "[[" tag-b "]]")]
(util/goto-journals)
(let [page (ls-api-call! :editor.getCurrentPage)
page-name (get page "name")]
(setup-flashcards-data!
{:page-name page-name
:tag-a tag-a
:tag-b tag-b
:card-a card-a
:card-b card-b
:query-a query-a
:query-b query-b}))
(open-flashcards)
(click-flashcards-plus)
(w/wait-for ".ls-block .tag:has-text('Cards')")
(open-flashcards)
(select-cards-option "Cards A")
(assert/assert-is-visible (format "#cards-modal .ls-card :text('%s')" card-a))
(assert/assert-have-count (format "#cards-modal .ls-card :text('%s')" card-b) 0)
(assert/assert-is-visible (loc/filter "#cards-modal .text-sm.opacity-50" :has-text "1/1"))
(select-cards-option "Cards B")
(assert/assert-is-visible (format "#cards-modal .ls-card :text('%s')" card-b))
(assert/assert-have-count (format "#cards-modal .ls-card :text('%s')" card-a) 0)
(assert/assert-is-visible (loc/filter "#cards-modal .text-sm.opacity-50" :has-text "1/1"))
(select-cards-option "All cards")
(assert/assert-is-visible (loc/filter "#cards-modal .text-sm.opacity-50" :has-text "1/2")))))

View File

@@ -0,0 +1,259 @@
(ns logseq.e2e.graph-navigation-basic-test
(:require
[clojure.string :as string]
[clojure.test :refer [deftest is testing 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.locator :as loc]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph)
(deftest graph-empty-state-and-name-validation-test
(testing "graph creation exposes an actionable empty state and rejects invalid/duplicate names"
(let [graph-name (str "sample-valid-graph-" (random-uuid))]
(graph/goto-all-graphs)
(assert/assert-is-visible
(loc/filter "#main-content-container" :has-text "Create a new graph"))
(util/search-and-click "Add a DB graph")
(let [input (w/-query "input[placeholder='your graph name']")]
(w/fill input "")
(w/click "button:text('Submit')")
(assert/assert-is-visible ".new-graph")
(doseq [invalid-name ["bad/name" "bad\\name"]]
(w/fill input invalid-name)
(w/click "button:text('Submit')")
(assert/assert-is-visible
".ui__toast.warning:has-text(\"Graph name can't contain\")")
(w/click (.last (w/-query ".ui__toast.warning button"))))
(w/fill input graph-name)
(w/click "button:text('Submit')")
(assert/assert-graph-loaded?)
(graph/goto-all-graphs)
(util/search-and-click "Add a DB graph")
(w/fill "input[placeholder='your graph name']" graph-name)
(w/click "button:text('Submit')")
(assert/assert-is-visible
".ui__toast.error:has-text('already exists')")
(k/esc)
(graph/switch-graph graph-name false false)))))
(deftest graph-refresh-and-browser-history-test
(testing "refresh and back/forward restore current graph/page without duplicate history"
(let [page-a "graph history a"
page-b "graph history b"]
(page/new-page page-a)
(b/new-block "history a content")
(util/exit-edit)
(page/new-page page-b)
(b/new-block "history b content")
(util/exit-edit)
(.goBack (w/get-page))
(assert/assert-is-visible
(loc/filter "#main-content-container" :has-text "history a content"))
(.goForward (w/get-page))
(assert/assert-is-visible
(loc/filter "#main-content-container" :has-text "history b content"))
(util/refresh-until-graph-loaded)
(is (= page-b (page/get-page-name)))
(assert/assert-is-visible
(loc/filter "#main-content-container" :has-text "history b content")))))
(deftest direct-page-and-block-route-test
(testing "direct page/block URLs load exact worker entities and missing routes recover"
(let [suffix (str (random-uuid))
page-name (str "direct-route-page-" suffix)
block-content (str "direct route block " suffix)]
(page/new-page page-name)
(b/new-block block-content)
(let [block-uuid (.getAttribute
(util/get-edit-block-container)
"blockid")]
(util/exit-edit)
(util/refresh-until-graph-loaded)
(let [page-uuid (get (ls-api-call! :editor.getPage page-name) "uuid")
block (ls-api-call! :editor.getBlock block-uuid)
current-url (.url (w/get-page))
base (-> current-url
(string/replace #"[?#].*$" "")
(string/replace #"/$" ""))
graph-id (some-> (re-find #"[?&]graph-id=([^&]+)" current-url)
second)
direct-url (fn [kind uuid]
(str base
"/#/"
kind
"/"
uuid
"?graph-id="
graph-id))
navigate-direct! (fn [url]
(w/navigate "about:blank")
(w/navigate url))]
(is (= block-content (get block "content")))
(is (some? block-uuid))
(is (some? graph-id))
(navigate-direct! (direct-url "page" page-uuid))
(assert/assert-is-visible
(loc/filter "[data-testid='page title']" :has-text page-name))
(is (= page-name (page/get-page-name)))
(assert/assert-is-visible
(loc/filter "#main-content-container" :has-text block-content))
(is (= block-content
(get (ls-api-call! :editor.getBlock block-uuid) "content")))
(navigate-direct! (direct-url "block" block-uuid))
(assert/assert-is-visible
(loc/filter "#main-content-container" :has-text block-content))
(navigate-direct! (direct-url "page" (random-uuid)))
(assert/assert-is-visible
(loc/filter "#main-content-container" :has-text "Page not found"))
(util/goto-journals)
(assert/assert-is-visible "#journals"))))))
(deftest local-graph-delete-and-list-metadata-test
(testing "graph list metadata is current and local deletion cannot leak its route/data"
(let [graph-name (str "graph-delete-" (random-uuid))]
(graph/new-graph graph-name false)
(page/new-page "local graph deletion page")
(b/new-block "local graph deletion content")
(graph/goto-all-graphs)
(let [card (w/-query (format "div[data-testid='logseq_db_%s']" graph-name))]
(assert/assert-is-visible card)
(assert/assert-is-visible
(loc/filter card :has-text "Last opened at:"))
(w/click (.locator card ".graph-action-btn"))
(w/click ".delete-local-graph-menu-item")
(w/click "div[role='alertdialog'] button:text('Confirm')")
(assert/assert-have-count card 0))
(assert/assert-have-count
(loc/filter "#main-content-container" :has-text "local graph deletion content")
0)
(graph/switch-graph "Demo" false false)
(page/new-page "post deletion validation")
(util/exit-edit)
(util/refresh-until-graph-loaded)
(assert/assert-is-visible "#search-button"))))
(deftest default-home-route-test
(testing "configured default home opens on startup and invalid home falls back explicitly"
(let [set-default-home!
(fn [page-name]
(ls-api-call! :app.setCurrentGraphConfigs
{"default-home" (cond-> {}
page-name
(assoc "page" page-name))})
(loop [remaining 40]
(let [stored-page
(ls-api-call! :app.getCurrentGraphConfigs
"default-home"
"page")]
(when-not (= page-name stored-page)
(when (zero? remaining)
(throw (ex-info "Default home config did not persist"
{:expected page-name
:actual stored-page})))
(util/wait-timeout 250)
(recur (dec remaining))))))]
(page/new-page "sample default home")
(b/new-block "default home content")
(util/exit-edit)
(let [current-url (.url (w/get-page))
base (-> current-url
(string/replace #"[?#].*$" "")
(string/replace #"/$" ""))
graph-id (some-> (re-find #"[?&]graph-id=([^&]+)" current-url)
second)
home-url (str base "/#/?graph-id=" graph-id)
navigate-home! (fn []
(w/navigate "about:blank")
(w/navigate home-url))]
(is (some? graph-id))
(set-default-home! "sample default home")
(navigate-home!)
(assert/assert-is-visible
(loc/filter "[data-testid='page title']"
:has-text "sample default home"))
(is (= "sample default home" (page/get-page-name)))
(set-default-home! "missing sample home")
(navigate-home!)
(assert/assert-is-visible "#journals")
(assert/assert-is-hidden ".loading-graph")
(set-default-home! nil)
(page/new-page "post default home validation")
(util/exit-edit)
(util/refresh-until-graph-loaded)
(assert/assert-is-visible "#search-button")))))
(deftest graph-view-mode-settings-test
(testing "Graph view modes and settings rebuild the canvas"
(doseq [title ["graph view alpha" "graph view beta" "graph view gamma"]]
(page/new-page title)
(b/new-block "[[graph view alpha]]")
(util/set-tag title)
(util/exit-edit))
(util/search-and-click "Go to graph view")
(assert/assert-is-visible "#global-graph.graph-root")
(assert/assert-is-visible
"[role='application'][aria-label='Graph canvas']")
(w/click ".graph-settings-toggle")
(w/click (loc/filter ".graph-mode-tab" :has-text "Tags"))
(assert/assert-is-visible
(loc/filter ".graph-mode-tab[aria-selected='true']" :has-text "Tags"))
(w/click (loc/filter ".graph-mode-tab" :has-text "All pages"))
(assert/assert-is-visible
(loc/filter ".graph-mode-tab[aria-selected='true']" :has-text "All pages"))
(w/refresh)
(assert/assert-is-visible "#global-graph.graph-root")
(assert/assert-is-visible
"[role='application'][aria-label='Graph canvas']")
(assert/assert-is-hidden ".graph-error")
(page/goto-page "graph view beta")
(assert/assert-graph-loaded?)))
(deftest graph-time-travel-playback-test
(testing "time travel limits nodes and returning to Now restores the complete graph"
(page/new-page "time travel old")
(util/wait-timeout 20)
(page/new-page "time travel new")
(util/search-and-click "Go to graph view")
(w/click "button[title*='Time'], button:has-text('Time travel')")
(let [slider (w/-query "input[type='range']")]
(assert/assert-is-visible slider)
(let [full-count (util/count-elements ".graph-node, [data-node-id]")]
(w/click slider)
(k/press "Home")
(is (<= (util/count-elements ".graph-node, [data-node-id]")
full-count))
(w/click ".graph-time-travel-reset[title='Now']")
(assert/assert-is-visible
(loc/filter ".graph-time-travel-label" :has-text "Now"))
(is (= full-count
(util/count-elements ".graph-node, [data-node-id]")))))))
(deftest restoring-graph-gates-and-recovers-interaction-test
(testing "reload/restoring never accepts half-mounted edits and restores all controls"
(b/new-block "restore interaction target")
(util/exit-edit)
(w/refresh)
(assert/assert-is-visible
(loc/or ".loading-graph, .ui__loading"
"[data-testid='page title']"))
(when (w/visible? ".loading-graph, .ui__loading")
(is (or (not (w/visible? ".block-add-button"))
(.isDisabled (w/-query ".block-add-button")))))
(assert/assert-graph-loaded?)
(assert/assert-is-visible ".toolbar-dots-btn")
(w/click ".toolbar-dots-btn")
(assert/assert-is-visible "[role='menuitem']")
(k/esc)
(b/jump-to-block "restore interaction target")
(util/move-cursor-to-end)
(util/press-seq " after restore")
(is (= "restore interaction target after restore" (util/get-edit-content)))))

View File

@@ -0,0 +1,46 @@
(ns logseq.e2e.graph-test
(:require [clojure.test :refer [deftest is]]
[logseq.e2e.graph]
[logseq.e2e.util :as util]
[wally.main :as w]))
(deftest maybe-input-e2ee-password-skips-when-cloud-ready-test
(let [wait-calls (atom 0)
input-calls (atom 0)]
(with-redefs-fn {#'w/visible? (fn [q] (= q "button.cloud.on.idle"))
#'util/wait-timeout (fn [_] (swap! wait-calls inc))
#'logseq.e2e.graph/input-e2ee-password (fn [] (swap! input-calls inc))}
(fn []
((var logseq.e2e.graph/maybe-input-e2ee-password))
(is (= 8 @wait-calls))
(is (zero? @input-calls))))))
(deftest maybe-input-e2ee-password-inputs-when-modal-appears-test
(let [ticks (atom 0)
input-calls (atom 0)]
(with-redefs-fn {#'w/visible? (fn [q]
(case q
".e2ee-password-modal-content" (>= @ticks 2)
"button.cloud.on.idle" false
false))
#'util/wait-timeout (fn [_] (swap! ticks inc))
#'logseq.e2e.graph/input-e2ee-password (fn [] (swap! input-calls inc))}
(fn []
((var logseq.e2e.graph/maybe-input-e2ee-password))
(is (= 2 @ticks))
(is (= 1 @input-calls))))))
(deftest maybe-input-e2ee-password-does-not-exit-on-stale-cloud-ready-test
(let [ticks (atom 0)
input-calls (atom 0)]
(with-redefs-fn {#'w/visible? (fn [q]
(case q
".e2ee-password-modal-content" (>= @ticks 2)
"button.cloud.on.idle" true
false))
#'util/wait-timeout (fn [_] (swap! ticks inc))
#'logseq.e2e.graph/input-e2ee-password (fn [] (swap! input-calls inc))}
(fn []
((var logseq.e2e.graph/maybe-input-e2ee-password))
(is (= 2 @ticks))
(is (= 1 @input-calls))))))

View File

@@ -0,0 +1,49 @@
(ns logseq.e2e.import-basic-test
(:require
[clojure.test :refer [deftest is testing use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.graph :as graph]
[logseq.e2e.locator :as loc]
[logseq.e2e.util :as util]
[wally.main :as w])
(:import
(java.nio.file Paths)))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph)
(defn- open-import!
[]
(util/double-esc)
(w/click ".toolbar-dots-btn")
(w/click (loc/filter "[role='menuitem']" :has-text "Import"))
(w/wait-for ".importer"))
(deftest import-options-and-invalid-edn-preserve-current-graph-test
(testing "browser import exposes supported sources and rejects invalid EDN atomically"
(let [current-graph (ls-api-call! :app.getCurrentGraph)
invalid-file (Paths/get "resources/invalid-db-export.edn"
(into-array String []))
graph-name (str "invalid-import-" (random-uuid))]
(open-import!)
(doseq [selector ["#import-sqlite-db"
"#import-sqlite-zip"
"#import-file-graph"
"#import-debug-transit"
"#import-db-edn"]]
(assert/assert-have-count selector 1))
(.setInputFiles (w/-query "#import-db-edn") invalid-file)
(w/wait-for "#modal-headline")
(w/click (loc/filter "button" :has-text "Submit"))
(assert/assert-is-visible ".ui__toast")
(assert/assert-is-visible "#modal-headline")
(w/fill ".form-input" graph-name)
(w/click (loc/filter "button" :has-text "Submit"))
(w/wait-for ".ui__toast")
(assert/assert-is-hidden ".ui__loading, .loading-graph")
(is (= current-graph (ls-api-call! :app.getCurrentGraph)))
(graph/goto-all-graphs)
(assert/assert-is-hidden
(loc/filter "#main-content-container" :has-text graph-name)))))

View File

@@ -0,0 +1,57 @@
(ns logseq.e2e.multi-tabs-basic-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.graph :as graph]
[logseq.e2e.playwright-page :as pw-page]
[logseq.e2e.util :as util]
[wally.main :as w]
[wally.repl :as repl]))
(use-fixtures :once fixtures/open-new-context)
(defn- add-blocks-and-check-on-other-tabs
[new-blocks add-blocks-tab check-blocks-tabs]
(w/with-page add-blocks-tab
(b/new-blocks new-blocks))
(run!
#(w/with-page %
(b/assert-blocks-visible new-blocks))
check-blocks-tabs))
(deftest multi-tabs-test
(testing "edit on one tab, check all tab's blocks are same"
(pw-page/open-pages fixtures/*pw-ctx* 3)
(let [[p1 p2 p3 :as pages] (pw-page/get-pages fixtures/*pw-ctx*)
blocks-to-add (map #(str "b" %) (range 10))]
(is (= 3 (count pages)))
(add-blocks-and-check-on-other-tabs blocks-to-add p1 [p2 p3])))
(testing "add new graphs and switch graphs + edit + check on tabs"
(let [[p1 p2 p3] (pw-page/get-pages fixtures/*pw-ctx*)]
(letfn [(switch-to-graph-then-edit-and-check [graph-name]
(w/with-page p2
(util/goto-journals)
(assert/assert-in-normal-mode?)
(graph/switch-graph graph-name false false))
(w/with-page p3
(util/goto-journals)
(assert/assert-in-normal-mode?)
(graph/switch-graph graph-name false false))
(w/with-page p1
(util/goto-journals)
(assert/assert-in-normal-mode?)
(graph/switch-graph graph-name false false))
(let [graph-new-blocks (map #(str graph-name "-b1-" %) (range 5))]
(add-blocks-and-check-on-other-tabs graph-new-blocks p1 [p2 p3])))]
(w/with-page p1
(graph/new-graph "graph1" false)
(graph/new-graph "graph2" false)
(graph/new-graph "graph3" false))
;; FIXME: since all-graphs isn't auto-update when other tabs add new graphs, so refresh here
(w/with-page p2 (util/refresh-until-graph-loaded))
(w/with-page p3 (util/refresh-until-graph-loaded))
(switch-to-graph-then-edit-and-check "graph1")
(switch-to-graph-then-edit-and-check "graph2")
(switch-to-graph-then-edit-and-check "graph3")))))

View File

@@ -0,0 +1,664 @@
(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"))))))

View File

@@ -0,0 +1,399 @@
(ns logseq.e2e.plugins-basic-test
(:require
[clojure.set :as set]
[clojure.test :refer [deftest testing is use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]
[wally.repl :as repl]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page)
(defn ->plugin-ident
[property-name]
(str ":plugin.property._test_plugin/" property-name))
(defonce ^:private *property-idx (atom 0))
(defn- new-property
[]
(str "test-property-" (swap! *property-idx inc)))
(defn- assert-api-ls-block!
([ret] (assert-api-ls-block! ret 1))
([ret-or-uuid count]
(let [uuid' (or (get ret-or-uuid "uuid") ret-or-uuid)]
(is (string? uuid'))
(assert/assert-have-count (str "#ls-block-" uuid') count)
uuid')))
(deftest editor-apis-test
(testing "editor related apis"
(page/new-page "test-block-apis")
(ls-api-call! :ui.showMsg "hello world" "info")
(let [ret (ls-api-call! :editor.appendBlockInPage "test-block-apis" "append-block-in-page-0")
ret1 (ls-api-call! :editor.appendBlockInPage "append-block-in-current-page-0")
uuid' (assert-api-ls-block! ret)]
(assert-api-ls-block! ret1)
(-> (ls-api-call! :editor.insertBlock uuid' "insert-0")
(assert-api-ls-block!))
(ls-api-call! :editor.updateBlock uuid' "append-but-updated-0")
(k/esc)
(w/wait-for ".block-title-wrap:text('append-but-updated-0')")
(ls-api-call! :editor.removeBlock uuid')
(assert-api-ls-block! uuid' 0))))
(deftest append-block-in-page-stays-at-page-root-test
(page/new-page "test-append-block-root")
(let [page (ls-api-call! :editor.getPage "test-append-block-root")
root (ls-api-call! :editor.appendBlockInPage "test-append-block-root" "root")
nested (ls-api-call! :editor.insertBlock (get root "uuid") "nested-1" {:sibling false})
_ (ls-api-call! :editor.insertBlock (get nested "uuid") "nested-2" {:sibling true})
appended (ls-api-call! :editor.appendBlockInPage "test-append-block-root" "appended")
appended-block (ls-api-call! :editor.getBlock (get appended "uuid"))]
(is (= (get page "id")
(get-in appended-block ["parent" "id"])))))
(deftest block-properties-test
(testing "block properties related apis"
(page/new-page "test-block-properties-apis")
(let [ret (ls-api-call! :editor.appendBlockInPage "test-block-properties-apis" "block-in-page-0" {:properties {:new-p1 1}})
uuid' (assert-api-ls-block! ret)
prop1 (ls-api-call! :editor.getBlockProperty uuid' "new-p1")
props1 (ls-api-call! :editor.getBlockProperties uuid' "new-p1")
props2 (ls-api-call! :editor.getPageProperties "test-block-properties-apis")]
(w/wait-for ".property-k:text('new-p1')")
(is (= 1 (get prop1 "value")))
(is (= (get prop1 "ident") ":plugin.property._test_plugin/new-p1"))
(is (= 1 (get props1 ":plugin.property._test_plugin/new-p1")))
(is (= ["Page"] (get props2 ":block/tags")))
(ls-api-call! :editor.upsertBlockProperty uuid' "p2" "p2")
(ls-api-call! :editor.upsertBlockProperty uuid' "p3" true)
(ls-api-call! :editor.upsertBlockProperty uuid' "p4" {:a 1, :b [2, 3]})
(let [prop2 (ls-api-call! :editor.getBlockProperty uuid' "p2")
prop3 (ls-api-call! :editor.getBlockProperty uuid' "p3")
prop4 (ls-api-call! :editor.getBlockProperty uuid' "p4")]
(w/wait-for ".property-k:text('p2')")
(is (= "p2" (get prop2 "value")))
(is (true? prop3))
(is (= prop4 {"a" 1, "b" [2 3]})))
(ls-api-call! :editor.removeBlockProperty uuid' "p4")
;; wait for react re-render
(util/wait-timeout 16)
(is (nil? (w/find-one-by-text ".property-k" "p4")))
(ls-api-call! :editor.upsertBlockProperty uuid' "p3" false)
(ls-api-call! :editor.upsertBlockProperty uuid' "p2" "p2-updated")
(w/wait-for ".block-title-wrap:text('p2-updated')")
(let [props (ls-api-call! :editor.getBlockProperties uuid')]
(is (= (get props ":plugin.property._test_plugin/p3") false))
(is (= (get props ":plugin.property._test_plugin/p2") "p2-updated"))))))
(deftest property-upsert-test
(testing "property with default settings"
(let [p (new-property)]
(ls-api-call! :editor.upsertProperty p)
(let [property (ls-api-call! :editor.getProperty p)]
(is (= "default" (get property "type")))
(is (= ":db.cardinality/one" (get property "cardinality"))))))
(testing "property with specified cardinality && type"
(let [p (new-property)]
(ls-api-call! :editor.upsertProperty p {:type "number"
:cardinality "one"})
(let [property (ls-api-call! :editor.getProperty p)]
(is (= "number" (get property "type")))
(is (= ":db.cardinality/one" (get property "cardinality")))))
(let [p (new-property)]
(ls-api-call! :editor.upsertProperty p {:type "number"
:cardinality "many"})
(let [property (ls-api-call! :editor.getProperty p)]
(is (= "number" (get property "type")))
(is (= ":db.cardinality/many" (get property "cardinality"))))
(ls-api-call! :editor.upsertProperty p {:type "default"})
(let [property (ls-api-call! :editor.getProperty p)]
(is (= "default" (get property "type"))))))
;; TODO: How to test against eval-js errors on playwright?
#_(testing ":checkbox property doesn't allow :many cardinality"
(let [p (new-property)]
(ls-api-call! :editor.upsertProperty p {:type "checkbox"
:cardinality "many"}))))
(deftest property-related-test
(testing "properties management related apis"
(doseq [property-type ["default" "number" "date" "datetime" "checkbox" "url" "node" "json" "string"]]
(let [property-name (new-property)
_ (ls-api-call! :editor.upsertProperty property-name {:type property-type})
property (ls-api-call! :editor.getProperty property-name)]
(is (= (get property "ident") (str ":plugin.property._test_plugin/" property-name)))
(is (= (get property "type") property-type))
(ls-api-call! :editor.removeProperty property-name)
(is (nil? (ls-api-call! :editor.getProperty property-name)))))))
(deftest insert-block-with-properties
(testing "insert block with properties"
(let [page "insert-block-properties-test"
_ (page/new-page page)
;; :checkbox, :number, :url, :json can be inferred and default to :default, but not for :page
b1 (ls-api-call! :editor.insertBlock page "b1" {:properties {"x1" true
"x2" "https://logseq.com"
"x3" 1
"x4" [1]
"x5" {:foo "bar"}
"x6" "Page x"
"x7" ["Page y" "Page z"]
"x8" "some content"}
:schema {"x6" {:type "page"}
"x7" {:type "page"}}})]
(is (true? (get b1 (->plugin-ident "x1"))))
(is (= "https://logseq.com" (-> (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x2")))
(get "title"))))
(is (= 1 (-> (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x3")))
(get ":logseq.property/value"))))
(is (= 1 (-> (ls-api-call! :editor.getBlock (first (get b1 (->plugin-ident "x4"))))
(get ":logseq.property/value"))))
(is (= "{\"foo\":\"bar\"}" (get b1 (->plugin-ident "x5"))))
(let [page-x (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x6")))]
(is (= "page x" (get page-x "name"))))
(is (= ["page y" "page z"] (map #(-> (ls-api-call! :editor.getBlock %)
(get "name")) (get b1 (->plugin-ident "x7")))))
(let [x8-block-value (ls-api-call! :editor.getBlock (get b1 (->plugin-ident "x8")))]
(is (= "some content" (get x8-block-value "title")))
(is (some? (get x8-block-value "page")))))))
(deftest update-block-with-properties
(testing "update block with properties"
(let [page "update-block-properties-test"
_ (page/new-page page)
block (ls-api-call! :editor.insertBlock page "b1")
_ (ls-api-call! :editor.updateBlock (get block "uuid")
"b1-new-content"
{:properties {"y1" true
"y2" "https://logseq.com"
"y3" 1
"y4" [1]
"y5" {:foo "bar"}
"y6" "Page x"
"y7" ["Page y" "Page z"]
"y8" "some content"}
:schema {"y6" {:type "page"}
"y7" {:type "page"}}})
b1 (ls-api-call! :editor.getBlock (get block "uuid"))]
(is (true? (get b1 (->plugin-ident "y1"))))
(is (= "https://logseq.com" (-> (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y2") "id"]))
(get "title"))))
(is (= 1 (-> (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y3") "id"]))
(get ":logseq.property/value"))))
(is (= 1 (-> (ls-api-call! :editor.getBlock (get (first (get b1 (->plugin-ident "y4"))) "id"))
(get ":logseq.property/value"))))
(is (= "{\"foo\":\"bar\"}" (get b1 (->plugin-ident "y5"))))
(let [page-x (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y6") "id"]))]
(is (= "page x" (get page-x "name"))))
(is (= ["page y" "page z"] (map #(-> (ls-api-call! :editor.getBlock %)
(get "name"))
(map #(get % "id") (get b1 (->plugin-ident "y7"))))))
(let [y8-block-value (ls-api-call! :editor.getBlock (get-in b1 [(->plugin-ident "y8") "id"]))]
(is (= "some content" (get y8-block-value "title")))
(is (some? (get y8-block-value "page")))))))
(deftest insert-batch-blocks-test
(testing "insert batch blocks"
(let [page "insert batch blocks"
_ (page/new-page page)
page-uuid (get (ls-api-call! :editor.getBlock page) "uuid")
result (ls-api-call! :editor.insertBatchBlock page-uuid
[{:content "b1"
:children [{:content "b1.1"
:children [{:content "b1.1.1"}
{:content "b1.1.2"}]}
{:content "b1.2"}]}
{:content "b2"}])
contents (util/get-page-blocks-contents)]
(is (= contents ["b1" "b1.1" "b1.1.1" "b1.1.2" "b1.2" "b2"]))
(is (= (map #(get % "title") result) ["b1" "b1.1" "b1.1.1" "b1.1.2" "b1.2" "b2"]))))
(testing "insert batch blocks with properties"
(let [page "insert batch blocks with properties"
_ (page/new-page page)
page-uuid (get (ls-api-call! :editor.getBlock page) "uuid")
result (ls-api-call! :editor.insertBatchBlock page-uuid
[{:content "b1"
:children [{:content "b1.1"
:children [{:content "b1.1.1"
:properties {"z3" "Page 1"
"z4" ["Page 2" "Page 3"]}}
{:content "b1.1.2"}]}
{:content "b1.2"}]
:properties {"z1" "test"
"z2" true}}
{:content "b2"}]
{:schema {"z3" "page"
"z4" "page"}})
_ (assert/assert-is-visible ".block-title-wrap:text('Page 3')")
contents (util/get-page-blocks-contents)]
(is (= contents
["b1" "test" "b1.1" "b1.1.1" "Page 1" "Page 2" "Page 3" "b1.1.2" "b1.2" "b2"]))
(is (true? (get (first result) (->plugin-ident "z2")))))))
(deftest create-page-test
(testing "create page"
(let [result (ls-api-call! :editor.createPage "Test page 1")]
(is (= "Test page 1" (get result "title")))
(is
(=
":logseq.class/Page"
(-> (ls-api-call! :editor.getBlock (first (get result "tags")))
(get "ident"))))))
(testing "create page with properties"
(let [result (ls-api-call! :editor.createPage "Test page 2"
{:px1 "test"
:px2 1
:px3 "Page 1"
:px4 ["Page 2" "Page 3"]}
{:schema {:px3 {:type "page"}
:px4 {:type "page"}}})
page (ls-api-call! :editor.getBlock "Test page 2")]
(is (= "Test page 2" (get result "title")))
(is
(=
":logseq.class/Page"
(-> (ls-api-call! :editor.getBlock (first (get result "tags")))
(get "ident"))))
;; verify properties
(is (= "test" (-> (ls-api-call! :editor.getBlock (get-in page [(->plugin-ident "px1") "id"]))
(get "title"))))
(is (= 1 (-> (ls-api-call! :editor.getBlock (get-in page [(->plugin-ident "px2") "id"]))
(get ":logseq.property/value"))))
(let [page-1 (ls-api-call! :editor.getBlock (get-in page [(->plugin-ident "px3") "id"]))]
(is (= "page 1" (get page-1 "name"))))
(is (= ["page 2" "page 3"] (map #(-> (ls-api-call! :editor.getBlock %)
(get "name"))
(map #(get % "id") (get page (->plugin-ident "px4"))))))))
(testing "create tag page"
(let [result (ls-api-call! :editor.createPage "Tag new"
{}
{:class true})]
(is
(=
":logseq.class/Tag"
(-> (ls-api-call! :editor.getBlock (first (get result "tags")))
(get "ident")))))))
(deftest get-all-tags-test
(testing "get_all_tags"
(let [result (ls-api-call! :editor.get_all_tags)
built-in-tags #{":logseq.class/Template"
":logseq.class/Query"
":logseq.class/Math-block"
":logseq.class/Task"
":logseq.class/Code-block"
":logseq.class/Card"
":logseq.class/Quote-block"
":logseq.class/Cards"}]
(is (set/subset? built-in-tags (set (map #(get % "ident") result)))))))
(deftest get-all-properties-test
(testing "get_all_properties"
(let [result (ls-api-call! :editor.get_all_properties)]
(is (>= (count result) 20)))))
(deftest get-tag-objects-test
(testing "get_tag_objects"
(let [page "tag objects test"
_ (page/new-page page)
_ (ls-api-call! :editor.insertBlock page "task 1"
{:properties {"logseq.property/status" "Doing"}})
result (ls-api-call! :editor.get_tag_objects "logseq.class/Task")]
(is (= (count result) 1))
(is (= "task 1" (get (first result) "title"))))))
(deftest create-and-get-tag-test
(testing "create and get tag with title or ident"
(let [title "book1"
title-ident (str :plugin.class._test_plugin/book1)
tag1 (ls-api-call! :editor.createTag title)
tag2 (ls-api-call! :editor.getTag title)
tag3 (ls-api-call! :editor.getTag title-ident)
tag4 (ls-api-call! :editor.getTag (get tag1 "uuid"))]
(is (= (get tag1 "ident") title-ident) "create tag with title from test as plugin")
(is (= (get tag2 "ident") title-ident) "get tag with title")
(is (= (get tag3 "title") title) "get tag with ident")
(is (= (get tag4 "title") title) "get tag with uuid")))
(testing "create tag with tagProperties"
(let [tag-props [{:name "prop1"}
{:name "prop2" :schema {:type "number"}}
{:name "prop3" :schema {:type "checkbox"}}]
tag (ls-api-call! :editor.createTag "tag-with-props" {:tagProperties tag-props})
props (get tag ":logseq.property.class/properties")]
(is (= 3 (count props)) "tag has 3 properties")))
(testing "add and remove tag extends"
(let [tag1 (ls-api-call! :editor.createTag "tag1")
tag2 (ls-api-call! :editor.createTag "tag2")
tag3 (ls-api-call! :editor.createTag "tag3")
id1 (get tag1 "id")
id2 (get tag2 "id")
id3 (get tag3 "id")
_ (ls-api-call! :editor.addTagExtends id1 id2)
tag1 (ls-api-call! :editor.getTag id1)]
(is (= (get tag1 ":logseq.property.class/extends") [id2]) "tag1 extends tag2 with db id")
(let [_ (ls-api-call! :editor.addTagExtends id1 id3)
tag1 (ls-api-call! :editor.getTag id1)]
(is (= (get tag1 ":logseq.property.class/extends") [id2 id3]) "tag1 extends tag2,tag3 with db ids")))))
(deftest get-tags-by-name-test
(testing "get tags by exact name"
(let [tag-name "product"
tag1 (ls-api-call! :editor.createTag tag-name)
result (ls-api-call! :editor.getTagsByName tag-name)]
(is (= 1 (count result)) "should return exactly one tag")
(is (= (get tag1 "uuid") (get (first result) "uuid")) "should return the created tag")
(is (= (get tag1 "title") (get (first result) "title")) "tag title should match")))
(testing "get tags by name is case-insensitive"
(let [tag-name "TestTag123"
_ (ls-api-call! :editor.createTag tag-name)
result-lower (ls-api-call! :editor.getTagsByName "testtag123")
result-upper (ls-api-call! :editor.getTagsByName "TESTTAG123")
result-mixed (ls-api-call! :editor.getTagsByName "TeStTaG123")]
(is (= 1 (count result-lower)) "should find tag with lowercase search")
(is (= 1 (count result-upper)) "should find tag with uppercase search")
(is (= 1 (count result-mixed)) "should find tag with mixed case search")
(is (= (get (first result-lower) "uuid") (get (first result-upper) "uuid")) "all searches should return same tag")
(is (= (get (first result-lower) "uuid") (get (first result-mixed) "uuid")) "all searches should return same tag")))
(testing "get tags by name returns empty array for non-existent tag"
(let [result (ls-api-call! :editor.getTagsByName "NonExistentTag12345")]
(is (empty? result) "should return empty array for non-existent tag")))
(testing "get tags by name filters out non-tag pages"
(let [page-name "regular-page"
_ (page/new-page page-name)
result (ls-api-call! :editor.getTagsByName page-name)]
(is (empty? result) "should not return regular pages, only tags")))
(testing "get tags by name with multiple tags having similar names"
(let [_tag1 (ls-api-call! :editor.createTag "category")
_tag2 (ls-api-call! :editor.createTag "Category")
result (ls-api-call! :editor.getTagsByName "category")]
;; Due to case-insensitive name normalization, both tags should be the same
(is (>= (count result) 1) "should return at least one tag")
;; Verify the result contains valid tag structure
(is (string? (get (first result) "uuid")) "returned tag should have uuid")
(is (string? (get (first result) "title")) "returned tag should have title"))))
(deftest set-property-node-tags
(testing "set property node tags"
(let [property-name (new-property)
_ (ls-api-call! :editor.upsertProperty property-name {:type "node"})
tag1 (ls-api-call! :editor.createTag "Tag A")
tag2 (ls-api-call! :editor.createTag "Tag B")
tags [(get tag1 "id") (get tag2 "id")]
_ (ls-api-call! :editor.setPropertyNodeTags property-name tags)
property (ls-api-call! :editor.getProperty property-name)
node-tags (get property ":logseq.property/classes")]
(is (= (set node-tags) (set tags)) "property node tags should match the set tags"))))

View File

@@ -0,0 +1,199 @@
(ns logseq.e2e.plugins-marketplace-test
(:require [logseq.e2e.fixtures :as fixtures]
[logseq.e2e.assert :as assert]
[logseq.e2e.keyboard :as k]
[logseq.e2e.util :as util]
[clojure.test :refer [deftest testing use-fixtures]]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page)
(defn- open-plugins-dialog
"Opens the plugins dialog via the More menu"
[]
(util/double-esc)
(w/click ".toolbar-dots-btn")
(w/click ".ui__dropdown-menu-item:has-text('Plugins')")
(w/wait-for ".cp__plugins-page"))
(defn- switch-to-marketplace
"Switches to the Marketplace tab in the plugins dialog"
[]
(w/click "button:has-text('Marketplace')")
;; Wait for plugins to load from marketplace
(w/wait-for ".cp__plugins-marketplace-cnt" {:timeout 15000}))
(defn- search-plugin
"Search for a plugin by name"
[search-term]
(w/fill ".cp__plugins-page input[placeholder*='Search']" search-term))
(defn- click-install-button
"Clicks the installation button for the first visible plugin card"
[]
(let [install-btn (.first (w/-query ".cp__plugins-item-card .ctl a.btn:has-text('Install')"))]
(w/click install-btn)))
(defn- wait-for-plugin-installed
"Waits for the plugin to show as installed"
[]
(w/wait-for ".cp__plugins-item-card .ctl a.btn:has-text('Installed')" {:timeout 30000}))
(defn- switch-to-installed
"Switches to the Installed tab in the plugins dialog"
[]
(w/click "button:has-text('Installed')")
;; Wait for installed plugins view
(w/wait-for ".cp__plugins-installed"))
(defn- close-plugins-dialog
"Closes the plugins dialog"
[]
(k/esc))
(defn- ensure-journals-calendar-installed!
[]
(open-plugins-dialog)
(switch-to-marketplace)
(search-plugin "Journals calendar")
(w/wait-for ".cp__plugins-item-card h3:has-text('Journals calendar')" {:timeout 10000})
(when (w/visible? ".cp__plugins-item-card .ctl a.btn:has-text('Install')")
(click-install-button)
(wait-for-plugin-installed))
(switch-to-installed)
(search-plugin "Journals calendar")
(w/wait-for ".cp__plugins-item-card h3:has-text('Journals calendar')"))
(defn- journals-calendar-card
[]
(.first
(w/-query
".cp__plugins-item-card:has(h3:has-text('Journals calendar'))")))
(defn- set-journals-calendar-enabled!
[enabled?]
(let [card (journals-calendar-card)
toggle (.locator card "button[role='switch']")]
(when (not= enabled? (= "true" (.getAttribute toggle "aria-checked")))
(w/click toggle))
(assert/assert-is-visible card)))
(deftest marketplace-tabs-search-and-state-test
(testing "installed and marketplace tabs keep their own filters and visible results"
(open-plugins-dialog)
(assert/assert-is-visible "button:has-text('Installed')")
(assert/assert-is-visible "button:has-text('Marketplace')")
(switch-to-marketplace)
(assert/assert-is-visible "button:has-text('Plugins')")
(assert/assert-is-visible "button:has-text('Themes')")
(search-plugin "Journals calendar")
(w/wait-for
".cp__plugins-item-card h3:has-text('Journals calendar')"
{:timeout 10000})
(w/click "button:has-text('Themes')")
(assert/assert-is-hidden
".cp__plugins-item-card h3:has-text('Journals calendar')")
(w/click "button:has-text('Plugins')")
(assert/assert-is-visible
".cp__plugins-item-card h3:has-text('Journals calendar')")
(switch-to-installed)
(assert/assert-is-visible ".cp__plugins-installed")
(switch-to-marketplace)
(assert/assert-is-visible
".cp__plugins-item-card h3:has-text('Journals calendar')")
(close-plugins-dialog)
(assert/assert-is-hidden ".cp__plugins-page")))
(deftest install-plugin-from-marketplace
(testing "Install a plugin from the marketplace"
;; Open plugins dialog
(open-plugins-dialog)
;; Switch to marketplace tab
(switch-to-marketplace)
;; Search for a specific plugin (using Journals calendar as it's a well-known plugin)
(search-plugin "Journals calendar")
;; Wait for search results
(w/wait-for ".cp__plugins-item-card h3:has-text('Journals calendar')" {:timeout 10000})
;; Click install on the plugin
(click-install-button)
;; Wait for installation to complete
(wait-for-plugin-installed)
;; Verify the plugin is now marked as installed in marketplace
(assert/assert-is-visible ".cp__plugins-item-card .ctl a.btn.disabled:has-text('Installed')")
;; Switch to installed tab and verify plugin appears there
(switch-to-installed)
(assert/assert-is-visible ".cp__plugins-item-card h3:has-text('Journals calendar')")
;; Close the dialog
(close-plugins-dialog)
;; Check that the plugin is active in the main UI (e.g., check for a UI element added by the plugin
(w/wait-for ".toolbar-plugins-manager-trigger")
(w/click ".toolbar-plugins-manager-trigger")
(assert/assert-is-visible "a.button[data-on-click=goToToday]")
(w/click ".ui__dropdown-menu-content a.button[data-on-click=goToToday]")
(assert/assert-is-visible ".is-today-page")))
(deftest plugin-command-registration-follows-lifecycle-test
(testing "a plugin command is registered once, removed on disable, and restored on enable"
(ensure-journals-calendar-installed!)
(set-journals-calendar-enabled! true)
(close-plugins-dialog)
(w/click ".toolbar-plugins-manager-trigger")
(assert/assert-have-count
".ui__dropdown-menu-content a.button[data-on-click=goToToday]"
1)
(k/esc)
(open-plugins-dialog)
(switch-to-installed)
(search-plugin "Journals calendar")
(set-journals-calendar-enabled! false)
(close-plugins-dialog)
(w/click ".toolbar-plugins-manager-trigger")
(assert/assert-have-count
".ui__dropdown-menu-content a.button[data-on-click=goToToday]"
0)
(k/esc)
(open-plugins-dialog)
(switch-to-installed)
(search-plugin "Journals calendar")
(set-journals-calendar-enabled! true)
(close-plugins-dialog)
(w/click ".toolbar-plugins-manager-trigger")
(assert/assert-have-count
".ui__dropdown-menu-content a.button[data-on-click=goToToday]"
1)))
(deftest plugin-disable-enable-and-reload-test
(testing "plugin UI contributions are completely removed and restored without duplicates"
(ensure-journals-calendar-installed!)
(set-journals-calendar-enabled! false)
(close-plugins-dialog)
(assert/assert-have-count "a.button[data-on-click=goToToday]" 0)
(open-plugins-dialog)
(switch-to-installed)
(search-plugin "Journals calendar")
(set-journals-calendar-enabled! true)
(close-plugins-dialog)
(w/click ".toolbar-plugins-manager-trigger")
(assert/assert-have-count
".ui__dropdown-menu-content a.button[data-on-click=goToToday]"
1)
(k/esc)
(util/refresh-until-graph-loaded)
(w/click ".toolbar-plugins-manager-trigger")
(assert/assert-have-count
".ui__dropdown-menu-content a.button[data-on-click=goToToday]"
1)))

View File

@@ -0,0 +1,95 @@
(ns logseq.e2e.property-basic-test
(:require [clojure.test :refer [deftest use-fixtures]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(def ^:private property-types ["Text" "Number" "Date" "DateTime" "Checkbox" "URL" "Node"])
(defn add-new-properties
[title-prefix]
(b/new-blocks (map #(str title-prefix "-" %) property-types))
(doseq [property-type property-types]
(let [property-name (str "p-" title-prefix "-" property-type)]
(w/click (util/get-by-text (str title-prefix "-" property-type) true))
(k/press "Control+e")
(util/input-command "Add property")
(w/click "input[placeholder]")
(util/input property-name)
(w/click (util/get-by-text "New option:" false))
(assert/assert-is-visible (w/get-by-text "Select a property type"))
(w/click (loc/and "span" (util/get-by-text property-type true)))
(case property-type
"Text" (do
(w/click (format ".property-pair:has-text('%s') > .ls-block" property-name))
(util/input "Text"))
"Number" (do (assert/assert-is-visible (format "input[placeholder='%s']" (str "Set " property-name)))
(util/input "111")
(w/click (w/get-by-text "New option:")))
("DateTime" "Date") (do
(assert/assert-is-visible ".ls-property-dialog")
(k/enter)
(k/esc))
"Checkbox" nil
"URL" nil
"Node" (do
(w/click (w/get-by-text "Skip choosing tag"))
(util/input (str title-prefix "-Node-value"))
(w/click (w/get-by-text "New option:")))))))
(deftest new-property-test
(let [title-prefix "new-property-test"]
(add-new-properties title-prefix)))
(deftest property-value-lifecycle-and-object-view-persistence-test
(let [property-name "property-value-lifecycle"
target-title "property value target"
owner-page (page/get-page-name)]
(b/new-block target-title)
(util/input-command "Add property")
(w/click "input[placeholder]")
(util/input property-name)
(w/click (w/get-by-text "New option:"))
(w/click (loc/and "span" (util/get-by-text "Text" true)))
(w/click (format ".property-pair:has-text('%s') > .ls-block" property-name))
(util/input "Initial value")
(k/esc)
(assert/assert-is-visible
(format ".property-pair:has-text('%s'):has-text('Initial value')" property-name))
(page/goto-page property-name)
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text target-title))
(util/refresh-until-graph-loaded)
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text target-title))
(page/goto-page owner-page)
(w/click (format ".property-pair:has-text('%s') > .ls-block" property-name))
(util/input "Updated value")
(k/esc)
(assert/assert-is-visible
(format ".property-pair:has-text('%s'):has-text('Updated value')" property-name))
(w/click (loc/filter ".property-k" :has-text property-name))
(w/click (loc/filter "[role='menuitem']" :has-text "Delete property from node"))
(w/click "div[role='alertdialog'] button:text('Confirm')")
(assert/assert-have-count
(format ".property-pair:has-text('%s')" property-name)
0)
(page/goto-page property-name)
(assert/assert-have-count
(loc/filter ".ls-view-body" :has-text target-title)
0)))

View File

@@ -0,0 +1,106 @@
(ns logseq.e2e.property-config-basic-test
(:require [clojure.test :refer [deftest use-fixtures]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(defn- add-text-property
[property-name]
(b/new-block "property target")
(util/input-command "Add property")
(w/click "input[placeholder]")
(util/input property-name)
(w/click (w/get-by-text "New option:"))
(w/click (loc/and "span" (util/get-by-text "Text" true)))
(k/esc)
(assert/assert-is-visible (format ".property-k:text('%s')" property-name)))
(defn- open-choices-pane
[property-name]
(w/click (loc/filter ".property-k" :has-text property-name))
(w/click (loc/filter "div[role='menuitem']" :has-text "Available choices")))
(defn- add-choice
[choice]
(w/click (loc/filter "div[role='menuitem']" :has-text "Add choice"))
(w/fill "input[placeholder='title']" choice)
(w/click "button:has-text('Save')")
(assert/assert-is-visible (format ".choices-list li:has-text('%s')" choice)))
(deftest property-choices-configuration-and-mod-p-stay-reactive-test
(let [property-name "reactive-priority"
choice-before "Choice before"
choice-after "Choice after"
removable-choice "Choice to delete"]
(add-text-property property-name)
(open-choices-pane property-name)
(add-choice choice-before)
(w/click (format ".choices-list li:has-text('%s') strong" choice-before))
(w/fill "input[placeholder='title']" choice-after)
(w/click "button:has-text('Save')")
(assert/assert-is-visible (format ".choices-list li:has-text('%s')" choice-after))
(assert/assert-have-count (format ".choices-list li:has-text('%s')" choice-before) 0)
(w/click
(format ".choices-list li:has-text('%s') button[title='More settings']"
choice-after))
(w/click (loc/filter "div[role='menuitem']" :has-text "Set as default choice"))
(open-choices-pane property-name)
(add-choice removable-choice)
(w/click
(format ".choices-list li:has-text('%s') button[title='More settings']"
removable-choice))
(w/click "div[role='menuitem'].del")
(assert/assert-have-count
(format ".choices-list li:has-text('%s')" removable-choice)
0)
(util/double-esc)
(b/new-block "closed choice target")
(k/press (if util/mac? "ControlOrMeta+p" "Control+Alt+p"))
(w/fill ".ls-property-dialog .cp__select-input" property-name)
(w/click (loc/filter "a.menu-link" :has-text property-name))
(assert/assert-is-visible
(format ".ls-property-dialog input[placeholder='Set %s']" property-name))
(assert/assert-have-count ".ls-property-dialog :text('Empty')" 0)
(w/click
(loc/filter ".ls-property-dialog .cp__select-results" :has-text choice-after))
(assert/assert-is-visible
(format ".ls-block :text('%s')" choice-after))))
(deftest mod-p-creates-and-sets-text-property-test
(let [property-name "mod-p-text"
property-value "created from mod p"]
(b/new-block "mod p target")
(k/press (if util/mac? "ControlOrMeta+p" "Control+Alt+p"))
(w/fill ".ls-property-dialog .cp__select-input" property-name)
(w/click (w/get-by-text "New option:"))
(w/click (loc/and "span" (util/get-by-text "Text" true)))
(assert/assert-is-visible
(format ".property-pair:has-text('%s') textarea" property-name))
(util/input property-value)
(k/esc)
(assert/assert-is-visible (format ".property-k:text('%s')" property-name))
(assert/assert-is-visible
(format ".property-pair:has-text('%s'):has-text('%s')" property-name property-value))))
(deftest property-table-hides-internal-id-column-test
(let [property-name "table-without-internal-id"]
(add-text-property property-name)
(util/double-esc)
(page/goto-page property-name)
(assert/assert-is-visible ".ls-view-body .ls-table-header-cell")
(assert/assert-have-count ".ls-view-body .ls-table-header-cell:text('#')" 0)))

View File

@@ -0,0 +1,131 @@
(ns logseq.e2e.property-scoped-choices-test
(:require [clojure.test :refer [deftest use-fixtures]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]
[wally.repl :as repl]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(defn- add-property
[property-name]
(b/new-blocks ["setup"])
(w/click (util/get-by-text "setup" true))
(k/press "Control+e")
(util/input-command "Add property")
(w/click "input[placeholder]")
(util/input property-name)
(w/click (w/get-by-text "New option:"))
(w/click (loc/and "span" (util/get-by-text "Text" true)))
(k/esc)
(assert/assert-is-visible (format ".property-k:text('%s')" property-name)))
(defn- add-tag-property
[property-name]
(w/click "button:has-text('Add tag property')")
(w/click "input[placeholder='Add or change property']")
(util/input property-name)
(w/click (loc/filter "a.menu-link" :has-text property-name))
(assert/assert-is-visible (format ".property-k:text('%s')" property-name)))
(defn- open-choices-pane
[property-name]
(w/click (loc/filter ".property-k" :has-text property-name))
(w/click (loc/filter "div[role='menuitem']" :has-text "Available choices")))
(defn- add-choice
[property-name choice]
(open-choices-pane property-name)
(w/click (loc/filter "div[role='menuitem']" :has-text "Add choice"))
(w/fill "input[placeholder='title']" choice)
(w/click "button:has-text('Save')")
(k/esc))
(defn- hide-choice-for-tag
[property-name choice tag]
(open-choices-pane property-name)
(util/wait-timeout 100)
(w/click (format ".choices-list li:has-text('%s') button[title='More settings']" choice))
(util/wait-timeout 100)
(w/click (loc/filter "div[role='menuitem']" :has-text (str "Hide for #" tag)))
(k/esc))
(defn- open-property-value-select
[property-name]
(w/click (format ".bottom-property-pill:has(.property-k:has-text('%s')) .property-value-container .jtrigger"
property-name))
(assert/assert-is-visible (format "input[placeholder='Set %s']" property-name))
(w/click (format "input[placeholder='Set %s']" property-name))
(assert/assert-is-visible ".cp__select-results"))
(deftest tag-scoped-property-choices-test
(let [setup-page (page/get-page-name)
tag "Device"
property-name "device-type"
scoped-choice "wired"
global-choice "wireless"]
(add-property property-name)
(page/new-page tag)
(page/convert-to-tag tag)
(add-tag-property property-name)
(add-choice property-name scoped-choice)
(util/wait-timeout 100)
(k/esc)
(page/goto-page setup-page)
(add-choice property-name global-choice)
(util/wait-timeout 100)
(k/esc)
(page/goto-page tag)
;; open tag properties
(w/click (.first (w/-query "a.block-control")))
(hide-choice-for-tag property-name global-choice tag)
(util/wait-timeout 100)
(k/esc)
(page/new-page "scoped-choices-test")
(b/new-block "Device item")
(util/set-tag tag)
(open-property-value-select property-name)
(assert/assert-is-visible (loc/filter ".cp__select-results" :has-text scoped-choice))
(assert/assert-have-count (loc/filter ".cp__select-results" :has-text global-choice) 0)))
(deftest tag-scoped-property-choices-isolated-test
(let [tag-a "Device2"
tag-b "Vehicle"
property-name "device2-type"
choice-a "wired2"
choice-b "gas"]
(add-property property-name)
(page/new-page tag-a)
(page/convert-to-tag tag-a)
(add-tag-property property-name)
(add-choice property-name choice-a)
(util/wait-timeout 100)
(k/esc)
(page/new-page tag-b)
(page/convert-to-tag tag-b)
(add-tag-property property-name)
(add-choice property-name choice-b)
(util/wait-timeout 100)
(k/esc)
(page/new-page "scoped-choices-device2")
(b/new-block "Device2 item")
(util/set-tag tag-a)
(open-property-value-select property-name)
(assert/assert-is-visible (loc/filter ".cp__select-results" :has-text choice-a))
(assert/assert-have-count (loc/filter ".cp__select-results" :has-text choice-b) 0)
(k/esc)
(page/new-page "scoped-choices-vehicle")
(b/new-block "Vehicle item")
(util/set-tag tag-b)
(open-property-value-select property-name)
(assert/assert-is-visible (loc/filter ".cp__select-results" :has-text choice-b))
(assert/assert-have-count (loc/filter ".cp__select-results" :has-text choice-a) 0)))

View File

@@ -0,0 +1,88 @@
(ns logseq.e2e.reference-basic-test
(:require
[clojure.test :refer [deftest testing 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.keyboard :as k]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
;; block references
(deftest self-reference
(testing "self reference"
(b/new-block "b2")
(b/copy)
(b/paste)
(util/exit-edit)
(assert/assert-selected-block-text "b2")))
(deftest self-tag-block-reference
(testing "self reference"
(b/new-block "b2")
(util/set-tag "task")
(b/copy)
(b/paste)
(util/exit-edit)
(assert/assert-selected-block-text "b2")))
(deftest mutual-reference
(testing "mutual reference"
(b/new-blocks ["b1" "b2"])
(util/set-tag "task")
(b/copy)
(k/arrow-up)
(b/wait-editor-text "b1")
(b/paste)
(b/copy)
(k/arrow-down)
(b/wait-editor-text "b2")
(b/paste)
(util/exit-edit)
(b/assert-blocks-visible ["b1[[b2]]" "b2[[b1]]"])))
(deftest parent-reference
(testing "parent reference"
(b/new-blocks ["b1" "b2"])
(util/set-tag "task")
(b/indent)
(b/copy)
(k/arrow-up)
(b/wait-editor-text "b1")
(b/paste)
(b/copy)
(k/arrow-down)
(b/wait-editor-text "b2")
(b/paste)
(util/exit-edit)
(b/assert-blocks-visible ["b1[[b2]]" "b2[[b1]]"])))
(deftest cycle-reference
(testing "cycle reference"
(b/new-blocks ["b1" "b2" "b3"])
(util/set-tag "task")
(b/jump-to-block "b1")
(assert/assert-editor-mode)
(b/copy)
(k/arrow-down)
(b/wait-editor-text "b2")
(b/paste)
(b/copy)
(k/arrow-down)
(b/wait-editor-text "b3")
(b/paste)
(b/copy)
(b/jump-to-block "b1")
(assert/assert-editor-mode)
(b/paste)
(util/exit-edit)
(b/assert-blocks-visible ["b1[[b3[[b2]]]]" "b2[[b1[[b3]]]]" "b3[[b2[[b1]]]]"])))
;; TODO: page references

View File

@@ -0,0 +1,99 @@
(ns logseq.e2e.right-sidebar-basic-test
(:require
[clojure.string :as string]
[clojure.test :refer [deftest is testing use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as p]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each fixtures/new-logseq-page fixtures/validate-graph)
(defn- use-dark-neutral-theme!
[]
(w/eval-js
"window.logseq.api.set_theme_mode('dark');
window.logseq.api.set_state_from_store(['ui/system-theme?'], false);
window.logseq.api.set_state_from_store(['ui/radix-color'], 'none');")
(util/wait-timeout 100))
(defn- right-sidebar-backgrounds
[]
(-> (w/eval-js
"(() => {
const topbar = document.querySelector('.cp__right-sidebar-topbar');
const inner = document.querySelector('.cp__right-sidebar-inner');
return [
getComputedStyle(topbar).backgroundColor,
getComputedStyle(inner).backgroundColor,
document.documentElement.dataset.theme,
document.documentElement.dataset.color
].join('|');
})()")
(string/split #"\|")))
(deftest right-sidebar-topbar-uses-dark-neutral-background
(testing "right sidebar header stays dark with neutral accent color"
(use-dark-neutral-theme!)
(w/click ".toggle-right-sidebar")
(assert/assert-is-visible ".cp__right-sidebar.open .cp__right-sidebar-topbar")
(assert/assert-is-visible ".cp__right-sidebar .sidebar-item")
(let [[topbar-bg inner-bg theme color] (right-sidebar-backgrounds)]
(is (= "dark" theme))
(is (= "none" color))
(is (= inner-bg topbar-bg)))))
(deftest right-sidebar-uses-only-content-scrollbar
(testing "the outer sidebar does not reserve a second scrollbar"
(w/click ".toggle-right-sidebar")
(assert/assert-is-visible ".cp__right-sidebar.open .sidebar-item-list")
(let [outer-overflow
(w/eval-js
"getComputedStyle(document.querySelector('.cp__right-sidebar-scrollable')).overflowY")
content-overflow
(w/eval-js
"getComputedStyle(document.querySelector('.sidebar-item-list')).overflowY")]
(is (= "visible" outer-overflow))
(is (= "auto" content-overflow)))))
(deftest same-block-updates-in-main-and-right-sidebar
(testing "one mounted UUID rerenders content and properties in both containers"
(let [page-name (p/get-page-name)
initial-title "sidebar live block before"
updated-title "sidebar live block after"
property-name "sidebar-live-property"
initial-property-value "sidebar property before"
updated-property-value "sidebar property after"
block (ls-api-call! :editor.appendBlockInPage
initial-title
{:properties {property-name initial-property-value}})
block-uuid (get block "uuid")
main-block (format ".ls-page-blocks #ls-block-%s" block-uuid)
sidebar-block (format ".cp__right-sidebar #ls-block-%s" block-uuid)]
(w/wait-for (format "%s .block-title-wrap:text('%s')" main-block initial-title))
(ls-api-call! :editor.openInRightSidebar block-uuid)
(w/wait-for sidebar-block)
(assert/assert-is-visible
(loc/filter ".cp__right-sidebar .sidebar-item-header .breadcrumb"
:has-text page-name))
(assert/assert-have-count (format "#ls-block-%s" block-uuid) 2)
(ls-api-call! :editor.updateBlock block-uuid updated-title)
(ls-api-call! :editor.upsertBlockProperty
block-uuid property-name updated-property-value)
(doseq [block-selector [main-block sidebar-block]]
(w/wait-for
(format "%s .block-title-wrap:text('%s')" block-selector updated-title))
(w/wait-for
(format "%s .property-k:text('%s')" block-selector property-name))
(w/wait-for
(format "%s .property-value :text('%s')"
block-selector updated-property-value)))
(w/wait-for-not-visible
(format "#ls-block-%s .block-title-wrap:text('%s')"
block-uuid initial-title)))))

View File

@@ -0,0 +1,99 @@
(ns logseq.e2e.rtc-basic-test
(:require
[clojure.test :refer [deftest testing is use-fixtures]]
[com.climate.claypoole :as cp]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.const :refer [*page1 *page2]]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.graph :as graph]
[logseq.e2e.page :as page]
[logseq.e2e.rtc :as rtc]
[logseq.e2e.util :as util]
[wally.main :as w]
[wally.repl :as repl]))
(use-fixtures :once fixtures/open-2-pages)
(use-fixtures :each fixtures/validate-graph)
(deftest rtc-basic-test
(let [graph-name (str "rtc-graph-" (.toEpochMilli (java.time.Instant/now)))
page-names (map #(str "rtc-test-page" %) (range 4))]
(testing "open 2 app instances, add a rtc graph, check this graph available on other instance"
(cp/prun!
2
#(w/with-page %
(util/login-test-account))
[@*page1 @*page2])
(testing "remote graph refresh waits until the button is enabled"
(w/with-page @*page2
(graph/goto-all-graphs)
(w/wait-for "button:not([disabled]):has-text(\"Refresh\")" {:timeout 30000})
(.setDefaultTimeout (w/get-page) 50)
(try
(w/eval-js
"() => {
const span = Array.from(document.querySelectorAll('span'))
.find((node) => node.textContent.trim() === 'Refresh');
const button = span.closest('button');
button.disabled = true;
setTimeout(() => { button.disabled = false; }, 500);
}")
(graph/refresh-all-remote-graphs)
(finally
(.setDefaultTimeout (w/get-page) 10000)))))
(w/with-page @*page1
(graph/new-graph graph-name true false))
(w/with-page @*page2
(graph/wait-for-remote-graph graph-name)
(graph/switch-graph graph-name true true)))
(testing "logseq pages add/delete"
(doseq [page-name page-names]
(let [{:keys [_local-tx remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(page/new-page page-name)))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)
(util/search-and-click page-name))))
(let [*last-remote-tx (atom nil)]
(doseq [page-name page-names]
(let [{:keys [_local-tx remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(page/delete-page page-name)))]
(reset! *last-remote-tx remote-tx)))
(w/with-page @*page2
(rtc/wait-tx-update-to @*last-remote-tx)
(doseq [page-name page-names]
(let [deleted-page (ls-api-call! :editor.getPage page-name)]
(is (number? (get deleted-page ":logseq.property/deleted-at")))
(is (= "Recycle" (get-in deleted-page ["parent" "title"]))))))))
(testing "Page reference created"
(let [page-name "test-page-reference"
{:keys [_local-tx remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(page/new-page page-name)))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)))
(let [test-page (str "random page " (random-uuid))
block-title (format "test ref [[%s]]" test-page)
{:keys [_local-tx remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(b/new-block block-title)
(b/new-block "add new-block to ensure last block saved")))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)
(util/search-and-click test-page)
(w/wait-for ".references .ls-block")
;; ensure ref exists
(let [refs (w/all-text-contents ".references .ls-block .block-title-wrap")]
(is (= refs [block-title]))))))
(testing "cleanup"
(w/with-page @*page2
(graph/remove-remote-graph graph-name)))))

View File

@@ -0,0 +1,461 @@
(ns logseq.e2e.rtc-extra-part2-test
(:require [clojure.java.io :as io]
[clojure.string :as string]
[clojure.test :refer [deftest testing is use-fixtures]]
[jsonista.core :as json]
[logseq.e2e.block :as b]
[logseq.e2e.const :refer [*page1 *page2 *graph-name*]]
[logseq.e2e.custom-report :as custom-report]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.graph :as graph]
[logseq.e2e.keyboard :as k]
[logseq.e2e.page :as page]
[logseq.e2e.rtc :as rtc]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once
fixtures/open-2-pages
(partial fixtures/prepare-rtc-graph-fixture "rtc-extra-part2-test-graph"))
(use-fixtures :each
fixtures/new-logseq-page-in-rtc)
(def ^:private stress-default-rounds 1)
(def ^:private stress-default-ops-per-client 50)
(def ^:private stress-default-seed-blocks 20)
(def ^:private stress-default-seed 20260330)
(def ^:private stress-max-seed-depth 4)
(def ^:private severe-sync-log-patterns
["db-sync/checksum-mismatch"
"db-sync/tx-rejected"
"db-sync/apply-remote-txs-failed"])
(def ^:private random-edit-actions
[:new :save :indent-outdent :delete-existing :undo :redo])
(defn- env-int
[k default]
(let [raw (System/getenv k)]
(if-not (string/blank? raw)
(try
(Integer/parseInt raw)
(catch Throwable _
default))
default)))
(defn- recent-console-logs
[]
(->> (some-> custom-report/*pw-page->console-logs* deref vals)
(mapcat identity)
vec))
(defn- assert-no-severe-sync-errors!
[]
(let [logs (recent-console-logs)
matched (->> logs
(filter (fn [line]
(some #(string/includes? line %) severe-sync-log-patterns)))
vec)]
(is (empty? matched)
(str "found severe sync errors in console logs: "
(pr-str (take-last 20 matched))))))
(defn- page-sync-state
[pw-page]
(w/with-page pw-page
(util/exit-edit)
{:rtc-tx (rtc/get-rtc-tx)
:blocks (util/get-page-blocks-contents)}))
(defn- assert-two-pages-synced!
[]
(let [s1 (page-sync-state @*page1)
s2 (page-sync-state @*page2)
tx1 (:rtc-tx s1)
tx2 (:rtc-tx s2)]
(is (= (:blocks s1) (:blocks s2))
(str "page blocks diverged: "
(pr-str {:page1-count (count (:blocks s1))
:page2-count (count (:blocks s2))
:page1-tail (take-last 8 (:blocks s1))
:page2-tail (take-last 8 (:blocks s2))})))
(is (= (:local-tx tx1) (:remote-tx tx1))
(str "page1 rtc-tx not converged: " (pr-str tx1)))
(is (= (:local-tx tx2) (:remote-tx tx2))
(str "page2 rtc-tx not converged: " (pr-str tx2)))))
(defn- try-indent!
[]
(if-let [editor (util/get-editor)]
(let [[x1 _] (util/bounding-xy editor)]
(k/tab)
(if-let [editor' (util/get-editor)]
(let [[x2 _] (util/bounding-xy editor')]
(> x2 x1))
false))
false))
(defn- try-outdent!
[]
(if-let [editor (util/get-editor)]
(let [[x1 _] (util/bounding-xy editor)]
(k/shift+tab)
(if-let [editor' (util/get-editor)]
(let [[x2 _] (util/bounding-xy editor')]
(> x1 x2))
false))
false))
(defn- align-depth!
[depth target]
(loop [d depth]
(cond
(< d target) (if (try-indent!)
(recur (inc d))
d)
(> d target) (if (try-outdent!)
(recur (dec d))
d)
:else d)))
(defn- new-block-safe!
[title]
(loop [attempt 4]
(let [created?
(try
(b/new-block "")
(b/save-block title)
true
(catch Throwable _
false))]
(if created?
true
(if (zero? attempt)
(throw (ex-info "new-block-safe failed" {:title title}))
(do
(util/exit-edit)
(util/wait-timeout 80)
(try
(b/open-last-block)
(catch Throwable _
nil))
(util/wait-timeout 80)
(recur (dec attempt))))))))
(defn- sync-by-trigger!
([tag]
(sync-by-trigger! tag nil))
([tag checkpoints]
(let [target-tx (some->> checkpoints
vals
(filter integer?)
seq
(apply max))]
;; Ensure both pages have observed all prior edit/undo-redo txs first.
(when target-tx
(w/with-page @*page1
(rtc/wait-tx-update-to target-tx))
(w/with-page @*page2
(rtc/wait-tx-update-to target-tx)))
(let [{:keys [remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(new-block-safe! (str "sync-trigger-" tag))))]
(w/with-page @*page1
(rtc/wait-tx-update-to remote-tx))
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx))))))
(defn- seed-long-nested-page!
[seed]
(let [seed-blocks (max 20 (env-int "DB_SYNC_E2E_STRESS_SEED_BLOCKS" stress-default-seed-blocks))
rng (java.util.Random. (long (+ seed 97)))
titles (w/with-page @*page1
(util/exit-edit)
(loop [i 0
depth 0
titles #{}]
(if (< i seed-blocks)
(let [title (format "seed-r%s-%03d" seed i)
target-depth (.nextInt rng (inc stress-max-seed-depth))]
(new-block-safe! title)
(recur (inc i)
(align-depth! depth target-depth)
(conj titles title)))
(do
(util/exit-edit)
titles))))]
(sync-by-trigger! (str "seed-" seed))
titles))
(defn- next-action
[rng]
(nth random-edit-actions
(.nextInt rng (count random-edit-actions))))
(defn- delete-existing-random-block!
[rng known-titles]
(loop [attempt 8]
(if (zero? attempt)
0
(let [titles (vec @known-titles)]
(if (empty? titles)
0
(let [title (nth titles (.nextInt rng (count titles)))
deleted?
(try
(b/jump-to-block title)
(b/delete-blocks)
true
(catch Throwable _
false))]
(if deleted?
(do
(swap! known-titles disj title)
1)
(recur (dec attempt)))))))))
(defn- random-edit-op!
[rng known-titles client-prefix round op-idx]
(let [base (format "%s-r%s-op%s" client-prefix round op-idx)]
(case (next-action rng)
:new
(let [title (str base "-new")]
(new-block-safe! title)
(swap! known-titles conj title)
1)
:save
(let [save-title (str base "-save-updated")]
(new-block-safe! (str base "-save"))
(b/save-block save-title)
(swap! known-titles conj save-title)
2)
:indent-outdent
(let [title (str base "-nest")]
(new-block-safe! title)
(swap! known-titles conj title)
(+ 1
(if (try-indent!) 1 0)
(if (try-outdent!) 1 0)))
:delete-existing
(delete-existing-random-block! rng known-titles)
:undo
(do
(b/undo)
0)
:redo
(do
(b/redo)
0))))
(defn- local-random-edit-batch!
[rng known-titles client-prefix round]
(let [ops (max 1 (env-int "DB_SYNC_E2E_STRESS_OPS_PER_CLIENT" stress-default-ops-per-client))]
(loop [i 0
undo-steps 0]
(if (< i ops)
(recur (inc i)
(+ undo-steps
(random-edit-op! rng known-titles client-prefix round i)))
(do
(util/exit-edit)
undo-steps)))))
(defn- local-undo-redo-batch!
[undo-steps]
(let [steps (max 1 undo-steps)]
;; Undo and redo exactly what this client edited in the current round.
(b/open-last-block)
(dotimes [_ steps]
(b/undo))
(dotimes [_ steps]
(b/redo))
(util/exit-edit)))
(defn- run-two-clients-without-sync!
[p1-fn p2-fn]
[(p1-fn)
(p2-fn)])
(deftest online-two-clients-undo-redo-stress-test
(testing "two online RTC clients survive random edits + undo/redo loops on a long nested page"
(let [rounds (max 1 (env-int "DB_SYNC_E2E_STRESS_ROUNDS" stress-default-rounds))
seed (long (env-int "DB_SYNC_E2E_STRESS_SEED" stress-default-seed))
p1-rng (java.util.Random. (long (+ seed 101)))
p2-rng (java.util.Random. (long (+ seed 202)))
known-titles (atom (seed-long-nested-page! seed))]
(dotimes [round rounds]
(let [p1-undo-steps (atom 0)
p2-undo-steps (atom 0)
;; Phase 1: edit both online clients without forcing sync between them.
[_ _]
(run-two-clients-without-sync!
#(w/with-page @*page1
(reset! p1-undo-steps
(local-random-edit-batch! p1-rng known-titles "p1" round)))
#(w/with-page @*page2
(reset! p2-undo-steps
(local-random-edit-batch! p2-rng known-titles "p2" round))))
p1-edit-remote-tx (w/with-page @*page1
(-> (rtc/get-rtc-tx) :local-tx))
p2-edit-remote-tx (w/with-page @*page2
(-> (rtc/get-rtc-tx) :local-tx))
;; Phase 2: undo+redo both online clients without forcing sync between them.
[_ _]
(run-two-clients-without-sync!
#(w/with-page @*page1
(local-undo-redo-batch! @p1-undo-steps))
#(w/with-page @*page2
(local-undo-redo-batch! @p2-undo-steps)))
p1-undo-remote-tx (w/with-page @*page1
(-> (rtc/get-rtc-tx) :local-tx))
p2-undo-remote-tx (w/with-page @*page2
(-> (rtc/get-rtc-tx) :local-tx))]
(sync-by-trigger!
round
{:p1-edit p1-edit-remote-tx
:p2-edit p2-edit-remote-tx
:p1-undo p1-undo-remote-tx
:p2-undo p2-undo-remote-tx})
(assert-two-pages-synced!)
(assert-no-severe-sync-errors!))))))
;;; https://github.com/logseq/db-test/issues/651
(deftest issue-651-block-title-double-transit-encoded-test
(testing "
1. create pages named \"bbb\", \"aaa\", and turn these pages into tag
2. set \"bbb\" parent to \"aaa\"
3. create a new page \"ccc\", and create a simple query with filter tags = aaa/bbb
wait for 5-10 seconds, will found that \"aaa/bbb\" became \"aaa/<encrypted-string>\"
"
(w/with-page @*page1
(page/new-page "aaa")
(page/convert-to-tag "aaa")
(page/new-page "bbb")
(page/convert-to-tag "bbb" :extends ["aaa"])
(page/new-page "ccc")
(b/new-block "")
(util/input-command "query")
(w/click (util/-query-last "button:text('filter')"))
(util/input "tags")
(w/click "a.menu-link:has-text('tags')")
(w/click "a.menu-link:has-text('bbb')")
(util/wait-timeout 5000) ;as described in issue-url
)
(let [{:keys [remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(b/new-block "done")))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)))
;; check the selected tag still exists after the query waits and syncs
(w/with-page @*page1
(page/goto-page "bbb"))
(w/with-page @*page2
(page/goto-page "bbb"))
(rtc/validate-graphs-in-2-pw-pages)))
(deftest paste-multiple-blocks-test
(testing "
1. create 3 blocks
- block1
- block2
- block3
2. copy these 3 blocks
3. when cursor at block3, press <enter> to create a new block
4. paste them at current position 5 times
5. validate blocks are same on both clients"
(w/with-page @*page1
(b/new-blocks ["block1" "block2" "block3"])
(util/exit-edit)
(b/select-blocks 2)
(b/copy)
(b/jump-to-block "block3")
(util/repeat-keyboard 1 "Enter"))
(dotimes [_ 5]
(let [{:keys [remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(b/paste)))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx))))
(let [{:keys [remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(b/new-block "sync-trigger")))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)))
(let [expected (vec (concat ["block1" "block2" "block3"]
(take (* 3 5) (cycle ["block1" "block2" "block3"]))
["sync-trigger"]))]
(w/with-page @*page1
(util/exit-edit)
(is (= expected
(util/get-page-blocks-contents))))
(w/with-page @*page2
(util/exit-edit)
(is (= expected
(util/get-page-blocks-contents)))))
(rtc/validate-graphs-in-2-pw-pages)))
(deftest asset-blocks-validate-after-init-downloaded-test
(testing "
- add some assets in client1
- remove local graph in client2
- re-download the remote graph in client2
- compare asset-blocks data in both clients"
(let [asset-file "../assets/icon.png"
page-title (w/with-page @*page1 (page/get-page-name))]
(w/with-page @*page1
(let [p (w/get-page)]
(.onFileChooser p (reify java.util.function.Consumer
(accept [_ fc]
(.setFiles fc (into-array java.nio.file.Path [(java.nio.file.Paths/get asset-file (into-array String []))])))))
(b/new-block "asset block")
(util/input-command "Upload an asset")
(w/wait-for ".ls-block img")))
(let [{:keys [remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(b/new-block "sync done")))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)))
(w/with-page @*page2
(graph/remove-local-graph *graph-name*)
(graph/wait-for-remote-graph *graph-name*)
(graph/switch-graph *graph-name* true false)
(page/goto-page page-title)
(w/wait-for ".ls-block img")
(is (some? (.getAttribute (w/-query ".ls-block img") "src"))))
(rtc/validate-graphs-in-2-pw-pages))))
(deftest issue-683-paste-large-block-test
(testing "Copying and pasting a large block of text into sync-ed graph causes sync to fail"
(let [large-text (slurp (io/resource "large_text.txt"))]
(w/with-page @*page1
(w/eval-js (str "navigator.clipboard.writeText(" (json/write-value-as-string large-text) ")")))
(let [{:keys [remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(b/new-block "")
(b/paste)))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)))
(rtc/validate-graphs-in-2-pw-pages))))

View File

@@ -0,0 +1,333 @@
(ns logseq.e2e.rtc-extra-test
(:require
[clojure.test :refer [deftest testing use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.const :refer [*page1 *page2]]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.outliner-basic-test :as outliner-basic-test]
[logseq.e2e.page :as page]
[logseq.e2e.property-basic-test :as property-basic-test]
[logseq.e2e.rtc :as rtc :refer [validate-graphs-in-2-pw-pages]]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once
fixtures/open-2-pages
(partial fixtures/prepare-rtc-graph-fixture "rtc-extra-test-graph"))
(use-fixtures :each
fixtures/new-logseq-page-in-rtc)
(defn- with-stop-restart-rtc
[pw-page f]
(w/with-page pw-page
(rtc/rtc-stop))
(f)
(w/with-page pw-page
(rtc/rtc-start)))
(def status->icon-name
{"Backlog" "Backlog"
"Todo" "Todo"
"Doing" "InProgress50"
"In review" "InReview"
"Done" "Done"
"Canceled" "Cancelled"})
(def priorities ["No priority" "Low" "Medium" "High" "Urgent"])
(defn- validate-task-blocks
[]
(let [icon-names (vals status->icon-name)
icon-name->count
(w/with-page @*page2
(into
{}
(map
(fn [icon-name]
[icon-name (.count (w/-query (str ".ls-icon-" icon-name)))])
icon-names)))]
(prn :validate-task-blocks icon-name->count)
(w/with-page @*page1
(doseq [[icon-name count*] icon-name->count]
(assert/assert-have-count (str ".ls-icon-" icon-name) count*)))))
(defn- insert-task-blocks
[title-prefix]
(doseq [status (keys status->icon-name)
priority priorities]
(b/new-block (str title-prefix "-" status "-" priority))
(util/input-command status)
(util/input-command priority)))
(defn- update-task-blocks
[]
(let [qs-partitions (partition-all 5 (seq (.all (loc/filter ".ls-block" :has ".ui__icon"))))]
(doseq [q-seq qs-partitions]
(doseq [q q-seq]
(w/click q)
(util/input-command (rand-nth (keys status->icon-name)))
(util/input-command (rand-nth priorities))))))
(deftest rtc-task-blocks-test
(let [insert-task-blocks-in-page2
(fn [*latest-remote-tx]
(w/with-page @*page2
(let [{:keys [_local-tx remote-tx]}
(rtc/with-wait-tx-updated
(insert-task-blocks "t1"))]
(reset! *latest-remote-tx remote-tx))
(util/exit-edit)))
update-task-blocks-in-page2
(fn [*latest-remote-tx]
(w/with-page @*page2
(let [{:keys [_local-tx remote-tx]}
(rtc/with-wait-tx-updated
(update-task-blocks))]
(reset! *latest-remote-tx remote-tx))))]
(testing "add some task blocks while rtc disconnected on page1"
(let [*latest-remote-tx (atom nil)]
(rtc/with-stop-restart-rtc
[@*page1]
[@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)]
(insert-task-blocks-in-page2 *latest-remote-tx))
(validate-task-blocks)
(validate-graphs-in-2-pw-pages)))
(testing "update task blocks while rtc disconnected on page1"
(let [*latest-remote-tx (atom nil)]
(rtc/with-stop-restart-rtc
[@*page1]
[@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)]
(update-task-blocks-in-page2 *latest-remote-tx))
(validate-task-blocks)
(validate-graphs-in-2-pw-pages)))
(fixtures/new-logseq-page-in-rtc*)
(testing "perform same operations on page2 while keeping rtc connected on page1"
(let [*latest-remote-tx (atom nil)]
(insert-task-blocks-in-page2 *latest-remote-tx)
(w/with-page @*page1
(rtc/wait-tx-update-to @*latest-remote-tx))
(validate-task-blocks)
(validate-graphs-in-2-pw-pages)))
(testing "update task blocks while rtc connected on page1"
(let [*latest-remote-tx (atom nil)]
(update-task-blocks-in-page2 *latest-remote-tx)
(w/with-page @*page1
(rtc/wait-tx-update-to @*latest-remote-tx))
(validate-task-blocks)
(validate-graphs-in-2-pw-pages)))))
(deftest rtc-property-test
(let [insert-new-property-blocks-in-page2
(fn [*latest-remote-tx title-prefix]
(w/with-page @*page2
(let [{:keys [_local-tx remote-tx]}
(rtc/with-wait-tx-updated
(property-basic-test/add-new-properties title-prefix))]
(reset! *latest-remote-tx remote-tx))))]
(testing "add different types user properties on page2 while keeping rtc connected on page1"
(let [*latest-remote-tx (atom nil)]
(rtc/with-stop-restart-rtc
[@*page1]
[@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)]
(insert-new-property-blocks-in-page2 *latest-remote-tx "rtc-property-test-1"))
(validate-graphs-in-2-pw-pages)))
(fixtures/new-logseq-page-in-rtc*)
(testing "perform same operations on page2 while keeping rtc connected on page1"
(let [*latest-remote-tx (atom nil)]
(insert-new-property-blocks-in-page2 *latest-remote-tx "rtc-property-test-2")
(w/with-page @*page1
(rtc/wait-tx-update-to @*latest-remote-tx))
(validate-graphs-in-2-pw-pages)))))
(deftest rtc-property-update-rerenders-mounted-block
(testing "a remote property transaction rerenders the mounted block"
(let [title "rtc live property block"
property-name "rtc-live-property"
property-value "rtc remote property value"
*block-uuid (atom nil)
{:keys [remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(let [block (ls-api-call! :editor.appendBlockInPage title)]
(reset! *block-uuid (get block "uuid")))))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx)
(w/wait-for
(format "#ls-block-%s .block-title-wrap:text('%s')"
@*block-uuid title)))
(let [{:keys [remote-tx]}
(w/with-page @*page2
(rtc/with-wait-tx-updated
(ls-api-call! :editor.upsertBlockProperty
@*block-uuid property-name property-value)))]
(w/with-page @*page1
(rtc/wait-tx-update-to remote-tx)
(w/wait-for
(format "#ls-block-%s .property-k:text('%s')"
@*block-uuid property-name))
(assert/assert-is-visible
(format "#ls-block-%s .property-value :text('%s')"
@*block-uuid property-value))))
(validate-graphs-in-2-pw-pages))))
(deftest rtc-outliner-test
(doseq [test-fn [outliner-basic-test/create-test-page-and-insert-blocks
outliner-basic-test/indent-and-outdent
outliner-basic-test/move-up-down
outliner-basic-test/delete
outliner-basic-test/delete-test-with-children]]
(let [test-fn-in-page2 (fn [*latest-remote-tx]
(w/with-page @*page2
(let [{:keys [_local-tx remote-tx]}
(rtc/with-wait-tx-updated
(test-fn))]
(reset! *latest-remote-tx remote-tx))))
*latest-remote-tx (atom nil)]
(fixtures/new-logseq-page-in-rtc*)
(rtc/with-stop-restart-rtc
[@*page1]
[@*page1 (rtc/wait-tx-update-to @*latest-remote-tx)]
(test-fn-in-page2 *latest-remote-tx))
(validate-graphs-in-2-pw-pages))))
(deftest rtc-outliner-conflict-update-test
(let [title-prefix "rtc-outliner-conflict-update-test"]
(testing "add some blocks, ensure them synced"
(let [*latest-remote-tx (atom nil)]
(w/with-page @*page1
(let [{:keys [_local-tx remote-tx]}
(rtc/with-wait-tx-updated
(b/new-blocks (map #(str title-prefix "-" %) (range 10))))]
(reset! *latest-remote-tx remote-tx)))
(w/with-page @*page2
(rtc/wait-tx-update-to @*latest-remote-tx))
(validate-graphs-in-2-pw-pages)))
(testing "page1: indent block1 as child of block0, page2: delete block0"
(rtc/with-stop-restart-rtc
[@*page1 @*page2]
[@*page1 (rtc/with-wait-tx-updated
(k/esc)
(assert/assert-in-normal-mode?)
(b/new-block "page1-done-1"))
@*page2 (rtc/with-wait-tx-updated
(k/esc)
(assert/assert-in-normal-mode?)
(b/new-block "page2-done-1"))]
(w/with-page @*page1
(w/click (format ".ls-block :text('%s')" (str title-prefix "-" 1)))
(b/indent))
(w/with-page @*page2
(w/click (format ".ls-block :text('%s')" (str title-prefix "-" 0)))
(b/delete-blocks)))
(validate-graphs-in-2-pw-pages))
(testing "
origin:
- block2
- block3
- block4
page1:
- block2
- block3
- block4
page2:
;; block2 deleted
- block4
- block3"
(rtc/with-stop-restart-rtc
[@*page1 @*page2]
[@*page1 (rtc/with-wait-tx-updated (b/new-block "page1-done-2"))
@*page2 (rtc/with-wait-tx-updated (b/new-block "page2-done-2"))]
(w/with-page @*page1
(w/click (format ".ls-block :text('%s')" (str title-prefix "-" 3)))
(b/indent)
(k/arrow-down)
(b/indent)
(b/indent))
(w/with-page @*page2
(w/click (format ".ls-block :text('%s')" (str title-prefix "-" 2)))
(b/delete-blocks)
(w/click (format ".ls-block :text('%s')" (str title-prefix "-" 3)))
(k/shift+arrow-down)
(k/meta+shift+arrow-down)
(k/enter)
(b/indent)))
(validate-graphs-in-2-pw-pages))))
(deftest rtc-page-test
(let [prefix "rtc-page-test-"]
(testing "create same name page in different clients.
- client1: offline, create page1
- client2: offline, create page1
- restart rtc"
(rtc/with-stop-restart-rtc
[@*page1 @*page2]
[@*page1 (rtc/with-wait-tx-updated (b/new-block "pw1-done-1"))
@*page2 (rtc/with-wait-tx-updated (b/new-block "pw2-done-1"))]
(w/with-page @*page1
(page/new-page (str prefix 1)))
(w/with-page @*page2
(page/new-page (str prefix 1))))
(validate-graphs-in-2-pw-pages))
(testing "
- client1: offline, add blocks on page-2
- client2: offline, delete page-2"
(let [page-name (str prefix 2)]
(let [*latest-remote-tx (atom nil)]
(w/with-page @*page1
(let [{:keys [_local-tx remote-tx]}
(rtc/with-wait-tx-updated
(page/new-page page-name))]
(reset! *latest-remote-tx remote-tx)))
(w/with-page @*page2
(rtc/wait-tx-update-to @*latest-remote-tx)))
(validate-graphs-in-2-pw-pages)
(rtc/with-stop-restart-rtc
[@*page1 @*page2]
[@*page1 (rtc/with-wait-tx-updated (b/new-block "pw1-done-2"))
@*page2 (rtc/with-wait-tx-updated (b/new-block "pw2-done-2"))]
(w/with-page @*page1
(b/new-blocks (map #(str "block-" %) (range 5))))
(w/with-page @*page2
(page/delete-page page-name)))
(validate-graphs-in-2-pw-pages)))
(testing "page rename"
(let [page-name (str prefix 3)]
(fixtures/new-logseq-page-in-rtc* page-name)
(validate-graphs-in-2-pw-pages)
(rtc/with-stop-restart-rtc
[@*page1 @*page2]
[@*page1 (rtc/with-wait-tx-updated (b/new-block "pw1-done-3"))
@*page2 (rtc/with-wait-tx-updated (b/new-block "pw2-done-3"))]
(w/with-page @*page1
(page/rename-page page-name (str page-name "-rename1")))
(w/with-page @*page2
(page/rename-page page-name (str page-name "-rename2"))))
(validate-graphs-in-2-pw-pages)))))
(deftest long-block-title-test
(testing "insert >4KB block/title"
(let [long-block-title (apply str (repeat 5000 "a"))
{:keys [_local-tx remote-tx]}
(w/with-page @*page1
(rtc/with-wait-tx-updated
(b/new-block "")
(b/save-block long-block-title)))]
(w/with-page @*page2
(rtc/wait-tx-update-to remote-tx))
(validate-graphs-in-2-pw-pages)
(w/with-page @*page2
(assert/assert-is-visible (loc/filter ".block-title-wrap" :has-text long-block-title))))))

View File

@@ -0,0 +1,57 @@
(ns logseq.e2e.tag-basic-test
(:require [clojure.test :refer [deftest use-fixtures]]
[logseq.e2e.assert :as assert]
[logseq.e2e.block :as b]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(defn add-new-tags
[title-prefix]
(b/new-block (str title-prefix 1 " #" title-prefix "1"))
(util/double-esc)
(b/new-block (str title-prefix 2))
(util/set-tag (str title-prefix 2)))
(deftest new-tag-test
(add-new-tags "tag-test-"))
(deftest page-tag-conversion-persists-and-removes-tag-from-objects-test
(let [tag-name "page-tag-conversion"
object-page "page-tag-object"]
(page/new-page tag-name)
(k/esc)
(page/convert-to-tag tag-name)
(assert/assert-is-visible "div[data-testid='page title'] :text('Tag')")
(page/new-page object-page)
(b/save-block "Tagged object")
(util/set-tag tag-name)
(k/esc)
(page/goto-page tag-name)
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text "Tagged object"))
(util/refresh-until-graph-loaded)
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text "Tagged object"))
(w/click ".toolbar-dots-btn")
(w/click (loc/filter "[role='menuitem']" :has-text "Convert Tag to Page"))
(w/click "div[role='alertdialog'] button:text('Confirm')")
(assert/assert-have-count "button:text('Add tag property')" 0)
(assert/assert-have-count ".ls-view-body" 0)
(page/goto-page object-page)
(assert/assert-have-count
(format ".block-tag :text('%s')" tag-name)
0)))

View File

@@ -0,0 +1,94 @@
(ns logseq.e2e.undo-redo-test
(:require
[clojure.set :as set]
[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.keyboard :as k]
[logseq.e2e.page :as p]
[logseq.e2e.util :as util]
[wally.main :as w]
[wally.repl :as repl]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(deftest undo-redo-paste
(testing "Undo redo paste blocks"
(b/new-blocks ["b1" "b2"])
(b/select-blocks 2)
(b/copy)
(b/new-block "")
(b/paste)
(util/exit-edit)
(is (= ["b1" "b2" "b1" "b2"] (util/get-page-blocks-contents)))
(b/undo)
(util/exit-edit)
(is (= ["b1" "b2" ""] (util/get-page-blocks-contents)))
(b/redo)
(util/exit-edit)
(is (= ["b1" "b2" "b1" "b2"] (util/get-page-blocks-contents)))))
(deftest undo-latest-saved-block-content-once
(testing "Undo reverts the latest saved block content on the first attempt"
(b/new-blocks ["b1"])
(util/wait-timeout 2000)
(util/move-cursor-to-end)
(util/press-seq " new text" :delay 20)
(util/wait-timeout 1000)
(b/undo)
(util/exit-edit)
(is (= ["b1"] (util/get-page-blocks-contents)))))
(deftest cut-and-paste-preserves-multiple-block-trees
(testing "Cut waits for structured clipboard data before deleting nested blocks"
(let [page-name (p/get-page-name)
page-uuid (get (ls-api-call! :editor.getBlock page-name) "uuid")
target-uuid (get (first (ls-api-call! :editor.getPageBlocksTree page-name))
"uuid")]
(util/exit-edit)
(ls-api-call! :editor.insertBatchBlock
page-uuid
[{:content "parent a"
:children [{:content "child a1"}
{:content "child a2"}]}
{:content "parent b"
:children [{:content "child b1"}
{:content "child b2"}]}])
(w/click (util/get-by-text "child b2" true))
(b/select-blocks 6)
(k/press "ControlOrMeta+x" {:delay 100})
(loop [remaining 50]
(let [blocks (ls-api-call! :editor.getPageBlocksTree page-name)]
(when (some #(not (string/blank? (get % "content"))) blocks)
(when (zero? remaining)
(throw (ex-info "Cut did not remove the selected block trees" {})))
(util/wait-timeout 100)
(recur (dec remaining)))))
(w/click (format "#ls-block-%s .block-content" target-uuid))
(b/paste)
(util/exit-edit)
(let [tree (loop [remaining 50]
(let [blocks (remove #(string/blank? (get % "content"))
(ls-api-call! :editor.getPageBlocksTree page-name))]
(if (or (= 2 (count blocks)) (zero? remaining))
blocks
(do
(util/wait-timeout 100)
(recur (dec remaining))))))
titles (fn [blocks]
(mapv #(get % "content") blocks))
children (fn [block]
(titles (get block "children")))]
(is (= ["parent a" "parent b"] (titles tree)))
(is (= ["child a1" "child a2"] (children (first tree))))
(is (= ["child b1" "child b2"] (children (second tree)))))
(util/refresh-until-graph-loaded)
(assert/assert-is-visible (util/get-by-text "parent a" true))
(assert/assert-is-visible (util/get-by-text "parent b" true)))))

View File

@@ -0,0 +1,229 @@
(ns logseq.e2e.view-basic-test
(:require [clojure.string :as string]
[clojure.test :refer [deftest is use-fixtures]]
[logseq.e2e.api :refer [ls-api-call!]]
[logseq.e2e.assert :as assert]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as page]
[logseq.e2e.util :as util]
[wally.main :as w]))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(defn- select-view-type
[view-type]
(w/click ".view-action-type")
(w/click (util/get-by-text view-type true)))
(defn- seed-table-view!
[tag-name]
(let [container-page (page/get-page-name)]
(ls-api-call! :editor.createTag
tag-name
{:tagProperties [{:name "Status"}
{:name "Priority"
:schema {:type "number"}}]})
(doseq [[title status priority]
[["Alpha table object" "Open" 2]
["Beta table object" "Closed" 1]]]
(ls-api-call! :editor.insertBlock
container-page
(str title " #" tag-name)
{:properties {"Status" status
"Priority" priority}}))
(page/goto-page tag-name)
(assert/assert-is-visible ".ls-view-body .ls-table-header-cell")
(w/click ".views button[title='Add new view']")
(assert/assert-is-visible
(loc/filter ".views > button" :has-text "New view"))
(select-view-type "List View")
(assert/assert-is-visible ".view-action-type .ls-icon-list")
(select-view-type "Table View")
(assert/assert-is-visible ".view-action-type .ls-icon-table")
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object"))
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object"))))
(defn- view-action-button
[icon]
(.first (.locator (w/get-page)
(str ".view-actions button:has(.ls-icon-" icon ")"))))
(defn- open-view-more-actions!
[]
(w/click (view-action-button "dots")))
(defn- open-view-submenu!
[label]
(let [item-index ({"Columns visibility" 0
"Group by" 1
"Sort groups by" 2
"Sort groups order" 3}
label)]
(k/press "Home")
(dotimes [_ item-index]
(k/arrow-down))
(k/arrow-right)))
(deftest table-row-selection-shows-action-bar-test
(seed-table-view! "table-row-selection-actions")
(doseq [title ["Alpha table object" "Beta table object"]]
(let [row (loc/filter ".ls-view-body .ls-table-row" :has-text title)]
(w/click (.locator row "[data-table-row-select]"))))
(assert/assert-is-visible ".ls-table-actions")
(assert/assert-is-visible
(loc/filter ".ls-table-actions .selection-count" :has-text "2")))
(deftest view-lifecycle-and-display-type-persistence-test
(let [tag-name "view-lifecycle"
container-page (page/get-page-name)
object-title "view lifecycle object"
view-title "Persistent view"]
(ls-api-call! :editor.createTag tag-name)
(ls-api-call! :editor.insertBlock
container-page
(str object-title " #" tag-name))
(page/goto-page tag-name)
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text object-title))
(w/click ".views button[title='Add new view']")
(w/click (loc/filter ".views > button" :has-text "New view"))
(w/click (loc/filter "[role='menuitem']" :has-text "Rename"))
(w/click "[role='menu'] .block-title-wrap")
(util/press-seq view-title)
(k/enter)
(assert/assert-is-visible
(loc/filter ".views > button" :has-text view-title))
(util/double-esc)
(select-view-type "List View")
(assert/assert-is-visible ".view-action-type .ls-icon-list")
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-block" :has-text object-title))
(select-view-type "Gallery View")
(assert/assert-is-visible ".view-action-type .ls-icon-layout-grid")
(assert/assert-is-visible
(loc/filter ".ls-card-item" :has-text object-title))
(util/refresh-until-graph-loaded)
(assert/assert-is-visible
(loc/filter ".views > button" :has-text view-title))
(assert/assert-is-visible ".view-action-type .ls-icon-table")
(w/click (loc/filter ".views > button" :has-text view-title))
(assert/assert-is-visible ".view-action-type .ls-icon-layout-grid")
(assert/assert-is-visible
(loc/filter ".ls-card-item" :has-text object-title))
(w/click (loc/filter ".views > button" :has-text view-title))
(w/click (loc/filter "[role='menuitem']" :has-text "Delete"))
(assert/assert-have-count
(loc/filter ".views > button" :has-text view-title)
0)
(assert/assert-is-visible
(loc/filter ".views > button" :has-text "All"))))
(deftest table-view-search-filter-and-new-record-actions-test
(seed-table-view! "table-search-filter-actions")
(w/click (view-action-button "search"))
(w/fill "input[placeholder='Type to search']" "Alpha")
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object"))
(assert/assert-have-count
(loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object")
0)
(w/click (view-action-button "search"))
(w/fill "input[placeholder='Type to search']" "")
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object"))
(w/click (view-action-button "filter"))
(w/click (loc/filter ".cp__select-results a" :has-text "Status"))
(assert/assert-is-visible ".cp__select-input[placeholder='Status']")
(w/click (util/get-by-text "Is Not Empty" true))
(assert/assert-is-visible ".filters-row")
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object"))
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "Beta table object"))
(util/double-esc)
(w/click ".filters-row button:has(.ls-icon-x)")
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "Alpha table object"))
(w/click (view-action-button "plus"))
(assert/assert-is-visible ".cp__right-sidebar.open")
(util/wait-editor-visible)
(util/press-seq "New table object")
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".ls-view-body .ls-table-row" :has-text "New table object")))
(deftest table-view-column-visibility-action-test
(seed-table-view! "table-column-actions")
(open-view-more-actions!)
(open-view-submenu! "Columns visibility")
(w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Status"))
(assert/assert-have-count
(loc/filter ".ls-table-header-cell" :has-text "Status")
0)
(util/double-esc)
(open-view-more-actions!)
(open-view-submenu! "Columns visibility")
(w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Status"))
(assert/assert-is-visible
(loc/filter ".ls-table-header-cell" :has-text "Status"))
(util/double-esc)
(util/refresh-until-graph-loaded)
(assert/assert-is-visible
(loc/filter ".ls-table-header-cell" :has-text "Status")))
(deftest table-view-group-and-export-actions-test
(seed-table-view! "table-group-export-actions")
(open-view-more-actions!)
(open-view-submenu! "Group by")
(w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Status"))
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text "Open"))
(assert/assert-is-visible
(loc/filter ".ls-view-body" :has-text "Closed"))
(util/double-esc)
(open-view-more-actions!)
(open-view-submenu! "Sort groups by")
(w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Page name"))
(util/double-esc)
(open-view-more-actions!)
(open-view-submenu! "Sort groups by")
(assert/assert-is-visible
(loc/filter "[role='menuitemcheckbox'][aria-checked='true']"
:has-text "Page name"))
(util/double-esc)
(open-view-more-actions!)
(open-view-submenu! "Sort groups order")
(w/click (loc/filter "[role='menuitemcheckbox']" :has-text "Descending"))
(util/double-esc)
(let [body-text (.innerText (.locator (w/get-page) ".ls-view-body"))]
(is (< (string/index-of body-text "Open")
(string/index-of body-text "Closed"))))
(open-view-more-actions!)
(w/click (.last (loc/filter "[role='menuitem']" :has-text "Export EDN")))
(assert/assert-is-visible
(util/get-by-text "Copied view nodes" false))
(let [content (w/eval-js "navigator.clipboard.readText()")]
(is (string/includes? content "Alpha table object"))
(is (string/includes? content "Beta table object"))))