Bladeren bron

enhance: add e2e tests for outliner ops (#11828)

* first e2e example

* Add wally repl pause/resume example

* use with-page-open

* add indent outdent test

* outliner add delete tests

* remove all timeout usage

* refactor: move fns to logseq.util

* Add commands tests for command trigger and node reference

* Add http-server

* add bb script to build release and run tests

* exit when tests finished

* Add clj e2e workflow

* increase slow-mo to 100
Tienson Qin 6 maanden geleden
bovenliggende
commit
d0a3e08958

+ 80 - 0
.github/workflows/clj-e2e.yml

@@ -0,0 +1,80 @@
+name: Clojure E2E
+
+on:
+  push:
+    branches: [test/db-e2e]
+    paths:
+      - 'clj-e2e/**'
+  pull_request:
+    branches: [test/db-e2e]
+    paths:
+      - 'clj-e2e/**'
+
+env:
+  CLOJURE_VERSION: '1.11.1.1413'
+  # This is the latest node version we can run.
+  NODE_VERSION: '20'
+  BABASHKA_VERSION: '1.0.168'
+
+jobs:
+  e2e-test-build:
+    name: Test
+    runs-on: ubuntu-22.04
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Set up Node
+        uses: actions/setup-node@v3
+        with:
+          node-version: ${{ env.NODE_VERSION }}
+          cache: 'yarn'
+          cache-dependency-path: |
+            yarn.lock
+
+      - name: Set up Clojure
+        uses: DeLaGuardo/[email protected]
+        with:
+          cli: ${{ env.CLOJURE_VERSION }}
+          bb: ${{ env.BABASHKA_VERSION }}
+
+      - name: Clojure cache
+        uses: actions/cache@v3
+        id: clojure-deps
+        with:
+          path: |
+            ~/.m2/repository
+            ~/.gitlibs
+          key: ${{ runner.os }}-clojure-deps-${{ hashFiles('deps.edn') }}
+          restore-keys: ${{ runner.os }}-clojure-deps-
+
+      - name: Fetch Clojure deps
+        if: steps.clojure-deps.outputs.cache-hit != 'true'
+        run: clojure -A:cljs -P
+
+      - name: Shadow-cljs cache
+        uses: actions/cache@v3
+        with:
+          path: .shadow-cljs
+          # ensure update cache every time
+          key: ${{ runner.os }}-shadow-cljs-${{ github.sha }}
+          # will match most recent upload
+          restore-keys: |
+            ${{ runner.os }}-shadow-cljs-
+
+      - name: Fetch yarn deps
+        run: yarn install
+        env:
+          PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true
+
+      # NOTE: require the app to be build with DEV-RELEASE flag
+      - name: Prepare E2E test build
+        run: |
+          yarn gulp:build && clojure -M:cljs release app --config-merge "{:closure-defines {frontend.config/DEV-RELEASE true}}" --debug
+          rsync -avz --exclude node_modules --exclude android --exclude ios ./static/ ./public/static/
+          ls -lR ./public
+
+      - name: Run e2e tests
+        run: cd clj-e2e && bb dev
+        env:
+          DEBUG: "pw:api"

+ 2 - 0
.gitignore

@@ -68,3 +68,5 @@ deps/shui/.lsp
 deps/shui/.lsp-cache
 deps/shui/.clj-kondo
 tx-log*
+clj-e2e/.wally
+clj-e2e/resources

+ 31 - 0
clj-e2e/.gitignore

@@ -0,0 +1,31 @@
+.calva/output-window/
+.calva/repl.calva-repl
+.classpath
+.clj-kondo/.cache
+.cpcache
+.DS_Store
+.eastwood
+.factorypath
+.hg/
+.hgignore
+.java-version
+.lein-*
+.lsp/.cache
+.lsp/sqlite.db
+.nrepl-history
+.nrepl-port
+.portal
+.project
+.rebel_readline_history
+.settings
+.socket-repl-port
+.sw*
+.vscode
+*.class
+*.jar
+*.swp
+*~
+/checkouts
+/classes
+/target
+.wally/

+ 20 - 0
clj-e2e/README.md

@@ -0,0 +1,20 @@
+# e2e
+
+e2e tests for Logseq app.
+
+## Usage
+
+Run the project's tests (they'll fail until you edit them):
+
+    $ clojure -T:build test
+
+## License
+
+Copyright © 2025 Tiensonqin
+
+_EPLv1.0 is just the default for projects generated by `clj-new`: you are not_
+_required to open source this project, nor are you required to use EPLv1.0!_
+_Feel free to remove or change the `LICENSE` file and remove or update this_
+_section of the `README.md` file!_
+
+Distributed under the Eclipse Public License version 1.0.

+ 21 - 0
clj-e2e/bb.edn

@@ -0,0 +1,21 @@
+{:deps {org.babashka/http-server {:mvn/version "0.1.13"}
+        org.babashka/cli {:mvn/version "0.2.23"}}
+ :tasks
+ {:requires ([babashka.cli :as cli])
+  :init (def cli-opts (cli/parse-opts *command-line-args* {:coerce {:port :int :headers :edn}}))
+
+  serve {:doc "Serve static assets"
+         :requires ([babashka.http-server :as server])
+         :task (server/exec (merge {:port 3002
+                                    :dir "../public"}
+                                   cli-opts))}
+
+  prn {:task (clojure "-X clojure.core/prn" cli-opts)}
+
+  test {:task (do
+                (clojure "-T:build test")
+                (System/exit 0))}
+
+  -dev {:depends [serve prn test]}
+
+  dev {:task (run '-dev {:parallel true})}}}

+ 18 - 0
clj-e2e/build.clj

@@ -0,0 +1,18 @@
+(ns build
+  (:refer-clojure :exclude [test])
+  (:require [clojure.tools.build.api :as b]
+            [clojure.tools.deps :as t]))
+
+(defn test "Run all the tests."
+  [_opts]
+  (println "\nRunning tests...")
+  (let [basis    (b/create-basis {:aliases [:test]})
+        combined (t/combine-aliases basis [:test])
+        cmds     (b/java-command
+                  {:basis basis
+                   :java-opts (:jvm-opts combined)
+                   :main      'clojure.main
+                   :main-args ["-m" "cognitect.test-runner"]})
+        {:keys [exit]} (b/process cmds)]
+    (when-not (zero? exit)
+      (throw (ex-info "Tests failed" {})))))

+ 21 - 0
clj-e2e/deps.edn

@@ -0,0 +1,21 @@
+{:paths ["src" "resources"]
+ :deps {org.clojure/clojure {:mvn/version "1.12.0"}
+        ;; io.github.pfeodrippe/wally {:local/root "../../../wally"}
+        io.github.pfeodrippe/wally {:git/url "https://github.com/logseq/wally"
+                                    :sha "6b0583701fc64ec5177eec6577e33bb8d9115d61"}
+        ;; io.github.zmedelis/bosquet {:mvn/version "2025.03.28"}
+        }
+ :aliases
+ {:run-m {:main-opts ["-m" "logseq.e2e"]}
+  :run-x {:ns-default logseq.e2e
+          :exec-fn greet
+          :exec-args {:name "Clojure"}}
+  :build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.5"}}
+          :ns-default build}
+  :serve {:deps {org.babashka/http-server {:mvn/version "0.1.13"}}
+          :main-opts ["-m" "babashka.http-server"]
+          :exec-fn babashka.http-server/exec}
+  :test {:extra-paths ["test"]
+         :extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}
+                      io.github.cognitect-labs/test-runner
+                      {:git/tag "v0.5.1" :git/sha "dfb30dd"}}}}}

+ 12 - 0
clj-e2e/src/logseq/e2e.clj

@@ -0,0 +1,12 @@
+(ns logseq.e2e
+  (:gen-class))
+
+(defn greet
+  "Callable entry point to the application."
+  [data]
+  (println (str "Hello, " (or (:name data) "World") "!")))
+
+(defn -main
+  "I don't do a whole lot ... yet."
+  [& args]
+  (greet {:name (first args)}))

+ 42 - 0
clj-e2e/test/logseq/editor_test.clj

@@ -0,0 +1,42 @@
+(ns logseq.editor-test
+  (:require
+   [clojure.string :as string]
+   [clojure.test :refer [deftest testing is use-fixtures]]
+   [logseq.fixtures :as fixtures]
+   [logseq.util :as util :refer [press]]
+   [wally.main :as w]))
+
+(use-fixtures :once fixtures/open-page)
+
+(deftest commands-test
+  (testing "/command trigger popup"
+    (util/new-page "Test")
+    (util/save-block "b1")
+    (util/type " /")
+    (w/wait-for ".ui__popover-content")
+    (is (some? (w/find-one-by-text "span" "Node reference")))
+    (press "Backspace")
+    (w/wait-for-not-visible ".ui__popover-content"))
+
+  (testing "Node reference"
+    (testing "Page reference"
+      (util/new-block "/")
+      (util/type "Node eferen")
+      (w/wait-for ".ui__popover-content")
+      (press "Enter")
+      (util/type "Another page")
+      (press "Enter")
+      (is (= "[[Another page]]" (util/get-edit-content)))
+      (util/exit-edit)
+      (is (= "Another page" (util/get-text "a.page-ref"))))
+    (testing "Block reference"
+      (util/new-block "/")
+      (util/type "Node eferen")
+      (w/wait-for ".ui__popover-content")
+      (press "Enter")
+      (util/type "b1")
+      (util/wait-timeout 300)
+      (press "Enter")
+      (is (string/includes? (util/get-edit-content) "[["))
+      (util/exit-edit)
+      (is (= "b1" (util/get-text ".block-ref"))))))

+ 17 - 0
clj-e2e/test/logseq/fixtures.clj

@@ -0,0 +1,17 @@
+(ns logseq.fixtures
+  (:require [wally.main :as w]))
+
+;; TODO: save trace
+;; TODO: parallel support
+(defn open-page
+  [f & {:keys [headless]
+        :or {headless true}}]
+  (w/with-page-open
+    (w/make-page {:headless headless
+                  :persistent false
+                  :slow-mo 100
+                  ;; Set `slow-mo` lower to find more flaky tests
+                  ;; :slow-mo 30
+                  })
+    (w/navigate "http://localhost:3002")
+    (f)))

+ 76 - 0
clj-e2e/test/logseq/outliner_test.clj

@@ -0,0 +1,76 @@
+(ns logseq.outliner-test
+  (:require
+   [clojure.test :refer [deftest testing is use-fixtures]]
+   [logseq.fixtures :as fixtures]
+   [logseq.util :as util :refer [press]]
+   [wally.main :as w]))
+
+(use-fixtures :once fixtures/open-page)
+
+(deftest create-test-page-and-insert-blocks
+  (util/new-page "p1")
+  ;; a page block and a child block
+  (is (= 2 (util/blocks-count)))
+  (util/new-blocks ["first block" "second block"])
+  (util/exit-edit)
+  (is (= 3 (util/blocks-count))))
+
+(deftest indent-and-outdent-test
+  (util/new-page "p2")
+  (util/new-blocks ["b1" "b2"])
+  (testing "simple indent and outdent"
+    (util/indent)
+    (util/outdent))
+
+  (testing "indent a block with its children"
+    (util/new-block "b3")
+    (util/indent)
+    (press "ArrowUp")
+    (util/indent)
+    (util/exit-edit)
+    (let [[x1 x2 x3] (map (comp first util/bounding-xy #(w/find-one-by-text "span" %)) ["b1" "b2" "b3"])]
+      (is (< x1 x2 x3))))
+
+  (testing "unindent a block with its children"
+    (util/open-last-block)
+    (util/new-blocks ["b4" "b5"])
+    (util/indent)
+    (util/press "ArrowUp")
+    (util/outdent)
+    (util/exit-edit)
+    (let [[x2 x3 x4 x5] (map (comp first util/bounding-xy #(w/find-one-by-text "span" %)) ["b2" "b3" "b4" "b5"])]
+      (is (and (= x2 x4) (= x3 x5) (< x2 x3))))))
+
+(deftest move-up-down-test
+  (util/new-page "p3")
+  (util/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"]))))
+
+(deftest delete-test
+  (testing "Delete blocks case 1"
+    (util/new-page "p4")
+    (util/new-blocks ["b1" "b2" "b3" "b4"])
+    (util/delete-blocks)                   ; delete b4
+    (util/repeat-keyboard 2 "Shift+ArrowUp") ; select b3 and b2
+    (util/delete-blocks)
+    (is (= "b1" (util/get-edit-content)))
+    (is (= 1 (util/page-blocks-count))))
+
+  (testing "Delete block with its children"
+    (util/new-page "p5")
+    (util/new-blocks ["b1" "b2" "b3" "b4"])
+    (util/indent)
+    (press "ArrowUp")
+    (util/indent)
+    (press "ArrowUp")
+    (util/delete-blocks)
+    (is (= "b1" (util/get-edit-content)))
+    (is (= 1 (util/page-blocks-count)))))

+ 38 - 0
clj-e2e/test/logseq/repl.clj

@@ -0,0 +1,38 @@
+(ns logseq.repl
+  "fns used on repl"
+  (:require [clojure.test :refer [run-tests run-test]]
+            [logseq.util :as util]
+            [wally.main :as w]
+            [wally.repl :as repl]))
+
+(comment
+
+  (future
+    (w/with-page-open
+      (w/make-page {:headless false
+                    :persistent false
+                    :slow-mo 20})
+      (w/navigate "http://localhost:3001")
+      (repl/pause)))
+
+  ;; You can put `(repl/pause)` in any test to pause the tests,
+  ;; this allows us to continue experimenting with the current page.
+  (repl/pause)
+
+  ;; To resume the tests, close the page/context/browser
+  (repl/resume)
+
+  ;; Run all the tests in specific ns with `future` to not block repl
+  (future (run-tests 'logseq.editor-test))
+  (future (run-tests 'logseq.outliner-test))
+
+  ;; Run specific test
+  (future (run-test logseq.editor-test/commands-test))
+
+  ;; after the test has been paused, you can do anything with the current page like this
+  (repl/with-page
+    (w/wait-for (first (util/get-edit-block-container))
+                {:state :detached}))
+
+  ;;
+  )

+ 149 - 0
clj-e2e/test/logseq/util.clj

@@ -0,0 +1,149 @@
+(ns logseq.util
+  (:refer-clojure :exclude [type])
+  (:require [clojure.string :as string]
+            [clojure.test :refer [is]]
+            [wally.main :as w]
+            [wally.selectors :as ws])
+  (:import (com.microsoft.playwright.assertions PlaywrightAssertions)))
+
+(def assert-that PlaywrightAssertions/assertThat)
+
+(defn wait-timeout
+  [ms]
+  (.waitForTimeout (w/get-page) ms))
+
+(defn get-active-element
+  []
+  (w/-query "*:focus"))
+
+(defn get-editor
+  []
+  (let [klass ".editor-wrapper textarea"
+        editor (w/-query klass)]
+    (when (w/visible? klass)
+      editor)))
+
+(defn get-edit-block-container
+  []
+  (first (w/query ".ls-block" {:has (w/-query ".editor-wrapper textarea")})))
+
+(defn input
+  "Notice this will replace the existing input value with `text`"
+  [text]
+  (w/fill "*:focus" text))
+
+(defn type
+  [text]
+  (let [input-node (w/-query "*:focus")]
+    (.type input-node text)))
+
+(def press w/keyboard-press)
+
+(defn search
+  [text]
+  (w/click :#search-button)
+  (w/fill ".cp__cmdk-search-input" text))
+
+(defn new-page
+  [title]
+  ;; Question: what's the best way to close all the popups?
+  ;; close popup, exit editing
+  (search title)
+  (w/click [(ws/text "Create page") (ws/nth= "0")])
+  (w/wait-for ".editor-wrapper textarea"))
+
+(defn count-elements
+  [q]
+  (w/count* (w/-query q)))
+
+(defn blocks-count
+  "Blocks count including page title"
+  []
+  (count-elements ".ls-block"))
+
+(defn page-blocks-count
+  []
+  (count-elements ".ls-page-blocks .ls-block"))
+
+(defn new-block
+  [title]
+  (press "Enter")
+  (input title))
+
+(defn save-block
+  [text]
+  (input text))
+
+(defn exit-edit
+  []
+  (press "Escape"))
+
+(defn delete-blocks
+  "Delete the current block if in editing mode, otherwise, delete all the selected blocks."
+  []
+  (let [editor (get-editor)]
+    (when editor (exit-edit))
+    (press "Backspace")))
+
+(defn get-text
+  [locator]
+  (if (string? locator)
+    (.textContent (w/-query locator))
+    (.textContent locator)))
+
+(defn get-edit-content
+  []
+  (when-let [editor (get-editor)]
+    (get-text editor)))
+
+;; TODO: support tree
+(defn new-blocks
+  [titles]
+  (let [value (get-edit-content)]
+    (if (string/blank? value)           ; empty block
+      (do
+        (save-block (first titles))
+        (doseq [title (rest titles)]
+          (new-block title)))
+      (doseq [title titles]
+        (new-block title)))))
+
+(defn bounding-xy
+  [locator]
+  (let [box (.boundingBox locator)]
+    [(.-x box) (.-y box)]))
+
+(defn indent-outdent
+  [indent?]
+  (let [editor (get-editor)
+        [x1 _] (bounding-xy editor)
+        _ (press (if indent? "Tab" "Shift+Tab"))
+        [x2 _] (bounding-xy editor)]
+    (if indent?
+      (is (< x1 x2))
+      (is (> x1 x2)))))
+
+(defn indent
+  []
+  (indent-outdent true))
+
+(defn outdent
+  []
+  (indent-outdent false))
+
+(defn open-last-block
+  []
+  (w/click (last (w/query ".ls-page-blocks .ls-block .block-content"))))
+
+(defn repeat-keyboard
+  [n shortcut]
+  (dotimes [_i n]
+    (press shortcut)))
+
+(defn get-page-blocks-contents
+  []
+  (w/all-text-contents ".ls-page-blocks .ls-block .block-title-wrap"))
+
+(def mac? (= "Mac OS X" (System/getProperty "os.name")))
+
+(def mod-key (if mac? "Meta" "Control"))

+ 2 - 2
src/main/frontend/worker/export.cljs

@@ -4,10 +4,10 @@
             [datascript.core :as d]
             [frontend.common.file.core :as common-file]
             [logseq.db :as ldb]
+            [logseq.db.sqlite.create-graph :as sqlite-create-graph]
             [logseq.db.sqlite.util :as sqlite-util]
             [logseq.graph-parser.property :as gp-property]
-            [logseq.outliner.tree :as otree]
-            [logseq.db.sqlite.create-graph :as sqlite-create-graph]))
+            [logseq.outliner.tree :as otree]))
 
 (defn- safe-keywordize
   [block]