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

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

1628 lines
66 KiB
Clojure

(ns logseq.e2e.editor-basic-test
(:require
[clojure.set :as set]
[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.custom-report :as custom-report]
[logseq.e2e.fixtures :as fixtures]
[logseq.e2e.keyboard :as k]
[logseq.e2e.locator :as loc]
[logseq.e2e.page :as p]
[logseq.e2e.util :as util]
[wally.main :as w])
(:import
(com.microsoft.playwright Locator$ClickOptions)
(com.microsoft.playwright.options KeyboardModifier)
(java.util.function Consumer)))
(use-fixtures :once fixtures/open-page)
(use-fixtures :each
fixtures/new-logseq-page
fixtures/validate-graph)
(defn- open-recycle!
[]
(w/click ".toolbar-dots-btn")
(w/click "[role='menuitem'] div:text('Recycle')"))
(defn- recycle-root
[page-name]
(loc/filter ".ls-recycle-page-content section > div > div"
:has-text page-name))
(defn- open-left-sidebar!
[]
(when-not (w/visible? "#left-sidebar.is-open")
(w/click "#left-menu")
(w/wait-for "#left-sidebar.is-open")))
(deftest recycle-restore-removes-row-immediately-test
(let [page-name (str "recycle-restore-" (random-uuid))]
(p/new-page page-name)
(p/delete-page page-name)
(open-recycle!)
(let [root (recycle-root page-name)]
(assert/assert-is-visible root)
(w/click (.locator root "button:text('Restore')"))
(assert/assert-have-count root 0)
(w/wait-for-not-visible
(loc/filter ".ui__toast.success" :has-text page-name)))))
(deftest recycle-delete-removes-row-and-recent-entry-test
(let [page-name (str "recycle-delete-" (random-uuid))
recent-item (loc/filter ".recent .recent-item" :has-text page-name)]
(open-left-sidebar!)
(p/new-page page-name)
(b/save-block "recycle delete content")
(p/delete-page page-name)
(open-recycle!)
(let [root (recycle-root page-name)]
(assert/assert-is-visible root)
(let [dialog* (atom nil)
handler (reify Consumer
(accept [_ dialog]
(reset! dialog* {:type (.type dialog)
:message (.message dialog)})
(.accept dialog)))]
(.onDialog (w/get-page) handler)
(try
(w/click (.locator root "button:text('Delete')"))
(finally
(.offDialog (w/get-page) handler)))
(is (= "confirm" (:type @dialog*)))
(is (string/includes? (:message @dialog*) "cannot be undone")))
(assert/assert-have-count root 0)
(assert/assert-have-count recent-item 0)
(w/wait-for-not-visible
(loc/filter ".ui__toast.success" :has-text page-name)))))
(defn- open-block-context-menu!
[]
(b/new-blocks ["hover target"])
(util/exit-edit)
(util/right-click
".ls-page-blocks .ls-block:not(.block-add-button) .bullet-container")
(w/wait-for ".ls-context-menu-content"))
(deftest block-context-menu-clickable-controls-use-pointer-test
(open-block-context-menu!)
(let [heading-button (w/-query "button[title='Auto heading']")
item (loc/filter "[role='menuitem']" :has-text "Add comment")
sub-trigger (loc/filter "[role='menuitem']" :has-text "Add reaction")]
(.hover heading-button)
(is (= "pointer"
(.evaluate heading-button "element => getComputedStyle(element).cursor")))
(.hover item)
(is (= "pointer"
(.evaluate item "element => getComputedStyle(element).cursor")))
(.hover sub-trigger)
(is (= "pointer"
(.evaluate sub-trigger "element => getComputedStyle(element).cursor")))))
(deftest block-context-menu-color-hover-shows-ring-test
(open-block-context-menu!)
(let [color (w/-query "a[title='Yellow'] .heading-bg")
before (.evaluate color "element => getComputedStyle(element).boxShadow")]
(.hover color)
(let [after (.evaluate color "element => getComputedStyle(element).boxShadow")]
(is (not= before after))
(is (not= "none" after)))))
(deftest notification-appears-at-top-right-test
(let [message-key "notification-position-test"
toast (loc/filter ".ui__toast" :has-text "notification position test")]
(ls-api-call! :show_msg
"notification position test"
"success"
{"key" message-key
"timeout" 0})
(try
(w/wait-for toast)
(is (= "top-right"
(w/eval-js
"(() => {
const toast = Array.from(document.querySelectorAll('.ui__toast'))
.find((element) => element.textContent.includes('notification position test'));
const rect = toast.getBoundingClientRect();
const vertical = rect.top < window.innerHeight / 2 ? 'top' : 'bottom';
const horizontal = window.innerWidth - rect.right <= 48 ? 'right' : 'left';
return `${vertical}-${horizontal}`;
})()")))
(finally
(ls-api-call! :ui.close_msg message-key)
(w/wait-for-not-visible toast)))))
(deftest favorites-and-recents-load-after-refresh-test
(let [page-name (str "sidebar-startup-" (random-uuid))
favorite-item (loc/filter ".favorites .favorite-item" :has-text page-name)
recent-item (loc/filter ".recent .recent-item" :has-text page-name)]
(p/new-page page-name)
(k/press "ControlOrMeta+Shift+f")
(assert/assert-is-visible favorite-item)
(assert/assert-is-visible recent-item)
(util/refresh-until-graph-loaded)
(util/wait-timeout 500)
(is (= [1 1]
[(.count favorite-item)
(.count recent-item)]))))
(deftest favorite-menu-and-sidebar-follow-page-updates-test
(let [page-name (str "favorite-reactivity-" (random-uuid))
renamed-page (str "renamed-favorite-" (random-uuid))
favorite-item #(loc/filter ".favorites .favorite-item" :has-text %)
recent-item #(loc/filter ".recent .recent-item" :has-text %)]
(p/new-page page-name)
(util/exit-edit)
(open-left-sidebar!)
(k/press "ControlOrMeta+Shift+f")
(assert/assert-is-visible (favorite-item page-name))
(w/click ".toolbar-dots-btn")
(w/click (loc/filter "[role='menuitem']" :has-text "Unfavorite page"))
(assert/assert-have-count (favorite-item page-name) 0)
(w/click ".toolbar-dots-btn")
(w/click (loc/filter "[role='menuitem']" :has-text "Add to Favorites"))
(assert/assert-is-visible (favorite-item page-name))
(p/rename-page page-name renamed-page)
(assert/assert-is-visible (favorite-item renamed-page))
(assert/assert-is-visible (recent-item renamed-page))
(assert/assert-have-count (favorite-item page-name) 0)
(assert/assert-have-count (recent-item page-name) 0)))
(deftest page-alias-can-be-added-and-removed-from-the-property-picker-test
(let [target-page (str "alias-target-" (random-uuid))
source-page (str "alias-source-" (random-uuid))
target-result (format ".property-select :text-is('%s')" target-page)
target-value (loc/filter ".ls-page-properties .property-value"
:has-text target-page)]
(p/new-page target-page)
(b/save-block "alias target content")
(util/exit-edit)
(p/new-page source-page)
(b/save-block "alias source content")
(util/exit-edit)
(w/click "button:text('Set property')")
(w/click (loc/and "strong" (util/get-by-text "Alias" true)))
(w/fill "input[placeholder='Set Alias']" target-page)
(w/click target-result)
(k/esc)
(is (= [target-page]
(mapv #(get % "title")
(get (ls-api-call! :editor.getPage source-page) "alias"))))
(assert/assert-is-visible target-value)
(.press (.locator target-value ".multi-values.jtrigger") "Enter")
(w/fill "input[placeholder='Set Alias']" target-page)
(w/click target-result)
(assert/assert-have-count target-value 0)
(w/click (w/get-by-test-id "page title"))))
(deftest theme-preview-images-load-test
(w/click ".toolbar-dots-btn")
(w/click (loc/filter "[role='menuitem']" :has-text "Settings"))
(assert/assert-is-visible ".cp__theme-modes-options")
(is
(true?
(w/eval-js
"(async () => {
const previews = Array.from(
document.querySelectorAll('.cp__theme-modes-options > li > i')
);
const urls = previews.map((preview) => {
const match = getComputedStyle(preview).backgroundImage.match(/^url\\([\"']?(.*?)[\"']?\\)$/);
return match?.[1];
});
if (urls.length !== 3 || urls.some((url) => !url)) {
return false;
}
const loaded = await Promise.all(urls.map((url) => new Promise((resolve) => {
const image = new Image();
image.onload = () => resolve(image.naturalWidth > 0);
image.onerror = () => resolve(false);
image.src = url;
})));
return loaded.every(Boolean);
})()"))))
(deftest language-select-shows-dropdown-indicator-test
(w/click ".toolbar-dots-btn")
(w/click (loc/filter "[role='menuitem']" :has-text "Settings"))
(assert/assert-is-visible ".ui__select-trigger")
(assert/assert-have-count
".ui__select-trigger .ui__select-icon svg"
1))
(deftest main-scrollbar-track-uses-main-background-test
(is (true?
(w/eval-js
"(() => {
const main = document.querySelector('#main-content-container');
const style = getComputedStyle(main);
const probe = document.createElement('span');
probe.style.color = style.getPropertyValue('--ls-primary-background-color');
document.body.appendChild(probe);
const mainBackground = getComputedStyle(probe).color;
probe.remove();
return style.scrollbarColor.endsWith(mainBackground);
})()"))))
(defn- choose-move-target!
[target]
(w/fill "input[placeholder=\"Move blocks to\"]" target)
(let [result (.first (w/get-by-test-id target))]
(assert/assert-is-visible result)
(w/click result)))
(defn- drag-and-drop-file!
[file-name file-type]
(w/eval-js
(format "(() => {
const container = document.querySelector('#main-content-container');
if (!container) {
throw new Error('main-content-container not found');
}
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File(['logseq-e2e-drag-drop'], %s, { type: %s }));
container.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true, cancelable: true }));
container.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true, cancelable: true }));
})();"
(pr-str file-name)
(pr-str file-type))))
(defn- enable-virtualized-rendering!
[]
(w/eval-js
"() => {
const url = new URL(location.href);
url.searchParams.set('virtualized', 'true');
history.replaceState(null, '', url.pathname + url.search + url.hash);
}")
(w/refresh)
(w/wait-for (.first (w/get-by-test-id "page title")) {:timeout 15000})
(assert/assert-graph-loaded?))
(defn- js-json
[script]
(json/read-value (w/eval-js script) json/keyword-keys-object-mapper))
(defn- start-edit-exit-frame-capture!
[]
(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
const block = editor?.closest('.ls-block');
if (!block) throw new Error('Expected an editing block');
window.__e2eEditExitFrames = [];
const startedAt = performance.now();
const sample = () => {
const currentEditor = block.querySelector('.editor-wrapper textarea');
const title = block.querySelector('.block-title-wrap');
const pageRef = block.querySelector('.page-reference .page-ref');
window.__e2eEditExitFrames.push({
editing: Boolean(currentEditor),
text: title?.textContent || '',
pageRefText: pageRef?.textContent || ''
});
if (performance.now() - startedAt < 400) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
return true;
})()"))
(defn- edit-exit-read-frames
[]
(util/wait-timeout 450)
(->> (js-json "JSON.stringify(window.__e2eEditExitFrames)")
(remove :editing)
vec))
(defn- insert-current-page-blocks!
[blocks]
(let [page (ls-api-call! :editor.getCurrentPage)
page-uuid (get page "uuid")]
(ls-api-call! :editor.insertBatchBlock
page-uuid
(mapv #(hash-map :content %) blocks)
{:sibling false})
(ls-api-call! :editor.exitEditingMode false)
(ls-api-call! :app.pushState "page" {:name page-uuid} nil)))
(deftest click-rendered-block-focuses-editor
(testing "clicking a rendered block leaves the editor textarea focused"
(let [title "click rendered block focuses editor"]
(insert-current-page-blocks! [title])
(w/click (format ".ls-block .block-content:has-text('%s')" title))
(assert/assert-editor-mode)
(let [{:keys [activeId activeTag editorId editorFocused]}
(json/read-value
(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
return JSON.stringify({
activeId: document.activeElement && document.activeElement.id,
activeTag: document.activeElement && document.activeElement.tagName,
editorId: editor && editor.id,
editorFocused: editor === document.activeElement
});
})();")
json/keyword-keys-object-mapper)]
(is editorFocused
{:active-id activeId
:active-tag activeTag
:editor-id editorId})))))
(defn- select-block-range-with-fast-scroll!
[blocks]
(w/eval-js
(format
"(async () => {
const blockTitles = %s;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const scrollContainer = document.querySelector('#main-content-container');
const blockByTitle = (title) => Array.from(document.querySelectorAll('.ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)'))
.find((block) => block.textContent.includes(title));
const scrollToBlock = async (title, step) => {
for (let i = 0; i < 120; i++) {
const block = blockByTitle(title);
if (block) {
block.scrollIntoView({ block: 'center' });
await nextFrame();
return block;
}
scrollContainer.scrollTop += step;
await nextFrame();
}
throw new Error(`Could not find mounted block ${title}`);
};
if (!document.querySelector('[data-virtuoso-scroller]')) {
throw new Error('Expected virtualized list scroller');
}
scrollContainer.scrollTop = 0;
await nextFrame();
const firstBlock = await scrollToBlock(blockTitles[0], -1000);
const firstContent = firstBlock.querySelector('.block-content');
const firstRect = firstContent.getBoundingClientRect();
const clientX = Math.floor(firstRect.left + 24);
const clientY = Math.floor(firstRect.top + Math.min(20, firstRect.height / 2));
const pointerInit = {
bubbles: true,
cancelable: true,
button: 0,
buttons: 1,
clientX,
clientY
};
firstContent.dispatchEvent(new PointerEvent('pointerdown', pointerInit));
await delay(100);
await scrollToBlock(blockTitles[blockTitles.length - 1], 1400);
await delay(200);
document.querySelector('#app-container-wrapper')?.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true,
cancelable: true,
button: 0,
buttons: 0,
clientX,
clientY
}));
return ((await window.logseq.api.get_selected_blocks()) || [])
.map((block) => block.title || block.content);
})();"
(json/write-value-as-string blocks))))
(defn- wait-for-copied-blocks!
[blocks]
(loop [remaining-attempts 50]
(let [clipboard (w/clipboard-text)
missing-blocks (filterv #(not (string/includes? clipboard %)) blocks)]
(if (or (empty? missing-blocks)
(zero? remaining-attempts))
{:clipboard-length (count clipboard)
:missing-blocks missing-blocks}
(do
(util/wait-timeout 100)
(recur (dec remaining-attempts)))))))
(defn- seed-journals!
[journals]
(doseq [{:keys [date blocks]} journals]
(let [page (ls-api-call! :editor.createJournalPage date)]
(ls-api-call! :editor.insertBatchBlock
(get page "uuid")
(mapv #(hash-map :content %) blocks)
{:sibling false})))
(ls-api-call! :editor.exitEditingMode false)
(util/goto-journals)
(w/wait-for "#journals"))
(defn- seed-journals-with-linked-ref!
[]
(let [target (ls-api-call! :editor.createJournalPage
"2026-03-01T12:00:00")
source (ls-api-call! :editor.createJournalPage
"2026-02-28T12:00:00")]
(ls-api-call! :editor.insertBatchBlock
(get target "uuid")
[{:content "journals linked refs visible target"}]
{:sibling false})
(ls-api-call! :editor.insertBatchBlock
(get source "uuid")
[{:content (format
"journals linked refs visible source [[%s]]"
(get target "name"))}]
{:sibling false})
(ls-api-call! :editor.exitEditingMode false)
(util/goto-journals)
(w/wait-for "#journals")))
(defn- scroll-journals-to-text!
[text]
(w/eval-js
(format
"(async () => {
const text = %s;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const scrollContainer = document.querySelector('#main-content-container');
if (!scrollContainer) {
throw new Error('Expected main content scroller');
}
const findText = () => Array.from(document.querySelectorAll('#journals .journal-item'))
.find((item) => item.textContent.includes(text));
scrollContainer.scrollTop = 0;
await nextFrame();
for (let i = 0; i < 240; i++) {
for (let settle = 0; settle < 40; settle++) {
const item = findText();
if (item) {
item.scrollIntoView({ block: 'center' });
await nextFrame();
return true;
}
const visibleItems = Array.from(document.querySelectorAll('#journals .journal-item'))
.filter((journal) => {
const rect = journal.getBoundingClientRect();
const containerRect = scrollContainer.getBoundingClientRect();
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
});
if (visibleItems.length > 0 && visibleItems.every((journal) => journal.textContent.trim())) {
break;
}
await delay(50);
}
scrollContainer.scrollTop += Math.max(280, Math.floor(scrollContainer.clientHeight * 0.7));
await delay(80);
}
throw new Error(`Could not find mounted journal text ${text}`);
})();"
(json/write-value-as-string text))))
(defn- journals-layout-metrics
[]
(js-json
"(async () => {
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
for (let i = 0; i < 40; i++) {
if (document.querySelector('#journals .journal-item')) {
break;
}
await nextFrame();
}
const journalItem = document.querySelector('#journals .journal-item');
if (!journalItem) {
throw new Error('Expected a mounted journal item');
}
const style = getComputedStyle(journalItem);
return JSON.stringify({
'journal-item-margin-bottom': Number.parseFloat(style.marginBottom),
'journal-item-padding-bottom': Number.parseFloat(style.paddingBottom),
'journals-scroller-count': document.querySelectorAll('#journals [data-virtuoso-scroller]').length
});
})();"))
(defn- journals-linked-refs-metrics
[]
(js-json
"(async () => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let references = null;
let foldableContent = null;
let viewBody = null;
for (let i = 0; i < 80; i++) {
references = document.querySelector('#journals .references');
foldableContent = references?.querySelector('.ls-foldable-content');
viewBody = references?.querySelector('.ls-view-body');
const expanded = foldableContent?.getAttribute('aria-hidden') !== 'true';
const bodyHeight = viewBody?.getBoundingClientRect().height || 0;
if (references && expanded && bodyHeight > 0) {
break;
}
await delay(100);
}
if (!references) {
throw new Error('Expected linked references in journals');
}
return JSON.stringify({
'collapsed': foldableContent?.getAttribute('aria-hidden') === 'true',
'body-mounted': Boolean(viewBody),
'body-height': viewBody?.getBoundingClientRect().height || 0
});
})();"))
(defn- set-journals-scroll-position!
[position]
(w/eval-js
(format
"(() => {
const scrollContainer = document.querySelector('#main-content-container');
if (!scrollContainer) {
throw new Error('Expected main content scroller');
}
scrollContainer.scrollTop = %s;
})();"
(case position
:start "0"
:end "scrollContainer.scrollHeight"))))
(defn- mounted-journal-height
[block-title]
(w/eval-js
"title => {
const journal = Array.from(document.querySelectorAll('#journals .journal-item'))
.find((item) => item.textContent.includes(title));
if (!journal) {
throw new Error(`Expected mounted journal containing ${title}`);
}
return Math.round(journal.getBoundingClientRect().height);
}"
block-title))
(defn- multiline-heading-bullet-alignment
[title]
(-> (w/eval-js
(format
"(async () => {
const title = %s;
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const block = Array.from(document.querySelectorAll('.ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)'))
.find((block) => block.textContent.includes(title));
if (!block) {
throw new Error(`Block not found: ${title}`);
}
const wrapper = block.querySelector('.block-content-wrapper');
const bullet = block.querySelector('.bullet-container');
const heading = block.querySelector('.block-title-wrap.as-heading');
if (!wrapper || !bullet || !heading) {
throw new Error('Expected heading block with bullet controls');
}
wrapper.style.maxWidth = '160px';
await nextFrame();
const bulletRect = bullet.getBoundingClientRect();
const headingRect = heading.getBoundingClientRect();
const lineHeight = Number.parseFloat(window.getComputedStyle(heading).lineHeight);
const firstLineCenterY = headingRect.top + (lineHeight / 2);
const bulletCenterY = bulletRect.top + (bulletRect.height / 2);
return JSON.stringify({
bulletCenterY,
firstLineCenterY,
delta: Math.abs(bulletCenterY - firstLineCenterY)
});
})();"
(json/write-value-as-string title)))
(json/read-value json/keyword-keys-object-mapper)))
(deftest multiline-heading-keeps-bullet-on-first-line
(testing "heading block bullet aligns with the first line when the heading wraps"
(doseq [heading ["h1" "h6"]]
(let [title (format "Multiline %s heading bullet should stay on the first visual line" heading)]
(b/new-block title)
(util/input-command heading)
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".block-title-wrap.as-heading" :has-text title))
(let [{:keys [delta] :as alignment} (multiline-heading-bullet-alignment title)]
(is (<= delta 3) (pr-str (assoc alignment :heading heading))))))))
(deftest copy-blocks-selected-after-fast-scroll-virtualized-list
(testing "copy includes virtualized blocks selected after fast scrolling a long page"
(let [blocks (mapv #(format "fast-scroll-copy-block-%03d" %) (range 1 31))]
(insert-current-page-blocks! blocks)
(enable-virtualized-rendering!)
(is (set/subset? (set blocks)
(set (select-block-range-with-fast-scroll! blocks))))
(b/copy)
(let [{:keys [missing-blocks] :as copy-result}
(wait-for-copied-blocks! blocks)]
(is (empty? missing-blocks) (pr-str copy-result))))))
(deftest journals-list-uses-measured-spacing-without-item-margins
(testing "journals list spacing does not use item margins that destabilize Virtuoso measurement"
(seed-journals!
[{:date "2026-03-05T12:00:00"
:blocks ["journals measured spacing first"]}
{:date "2026-03-04T12:00:00"
:blocks ["journals measured spacing second"]}])
(w/wait-for "#journals .journal-item")
(let [{:keys [journal-item-margin-bottom journal-item-padding-bottom] :as metrics} (journals-layout-metrics)]
(is (zero? journal-item-margin-bottom) metrics)
(is (pos? journal-item-padding-bottom) metrics))))
(deftest journals-list-does-not-nest-virtualized-scrollers-in-long-journal
(testing "long journals keep a single virtualized measurement owner"
(let [blocks (mapv #(format "journals long stable block %03d" %) (range 1 13))]
(seed-journals!
[{:date "2026-03-06T12:00:00"
:blocks blocks}])
(w/wait-for (format "#journals .journal-item:has-text('%s')"
(first blocks)))
(enable-virtualized-rendering!)
(w/wait-for "#journals [data-virtuoso-scroller]")
(scroll-journals-to-text! (first blocks))
(let [{:keys [journals-scroller-count] :as metrics} (journals-layout-metrics)]
(is (= 1 journals-scroller-count) metrics)))))
(deftest journals-list-remounts-complete-long-journal-with-one-scroller
(testing "an outer journal remount restores all content without a nested virtualizer"
(let [first-block-title "journals remount stable block 001"
last-block-title "journals remount stable block 012"
long-blocks (mapv #(format "journals remount stable block %03d %s"
%
(string/join " " (repeat 24 "wrapped-content")))
(range 1 13))
older-journals (mapv (fn [idx]
{:date (format "2026-03-%02dT12:00:00" idx)
:blocks [(format "journals remount spacer block %02d" idx)]})
(range 1 7))]
(seed-journals!
(into [{:date "2026-03-20T12:00:00"
:blocks long-blocks}]
older-journals))
(enable-virtualized-rendering!)
(let [journal-selector (format "#journals .journal-item:has-text('%s')"
first-block-title)
last-block-selector (format "%s .ls-block:has-text('%s')"
journal-selector last-block-title)]
(w/wait-for last-block-selector)
(let [initial-height (mounted-journal-height first-block-title)]
(set-journals-scroll-position! :end)
(w/wait-for-not-visible journal-selector)
(set-journals-scroll-position! :start)
(w/wait-for last-block-selector)
(let [{:keys [journals-scroller-count] :as metrics}
(journals-layout-metrics)
remounted-height (mounted-journal-height first-block-title)]
(is (>= remounted-height (dec initial-height))
(str (assoc metrics
:initial-height initial-height
:remounted-height remounted-height)))
(is (= 1 journals-scroller-count) metrics)
(assert/assert-have-count
(format "%s [data-virtuoso-scroller]" journal-selector)
0)))))))
(deftest journals-linked-refs-remain-visible
(testing "journals linked refs stay visible while journals layout owns the outer measurement"
(seed-journals!
(mapv (fn [day]
{:date (format "2026-03-%02dT12:00:00" day)
:blocks [(format "journals linked refs spacer %02d" day)]})
(range 2 6)))
(seed-journals-with-linked-ref!)
(scroll-journals-to-text! "journals linked refs visible target")
(let [{:keys [collapsed body-mounted body-height] :as metrics} (journals-linked-refs-metrics)]
(is (false? collapsed) metrics)
(is (true? body-mounted) metrics)
(is (pos? body-height) metrics))))
(defn- console-logs
[]
(->> (some-> custom-report/*pw-page->console-logs* deref vals)
(mapcat identity)
vec))
(defn- worker-op-logs
[logs op-names]
(->> logs
(filter #(and (string/includes? % ":db-worker/outliner-op-perf")
(string/includes? % (str ":op-names " op-names))))))
(defn- editor-input-state
[]
(json/read-value
(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
return JSON.stringify({
value: editor?.value ?? null,
focused: editor === document.activeElement,
selectionStart: editor?.selectionStart ?? null,
selectionEnd: editor?.selectionEnd ?? null,
blockTitles: Array.from(document.querySelectorAll('.ls-page-blocks .block-title-wrap'))
.map((node) => node.textContent.trim())
});
})();")
json/keyword-keys-object-mapper))
(deftest consecutive-enter-keeps-text-and-cursor-on-the-new-block
(b/new-block "rapid enter start")
(k/enter)
(util/press-seq "rapid enter alpha")
(k/enter)
(util/press-seq "rapid enter beta")
(util/wait-timeout 800)
(let [{:keys [value focused selectionStart selectionEnd]}
(editor-input-state)]
(is (= "rapid enter beta" value))
(is focused)
(is (= (count value) selectionStart selectionEnd)))
(util/exit-edit)
(let [block-titles (:blockTitles (editor-input-state))]
(is (some #{"rapid enter alpha"} block-titles) block-titles)
(is (some #{"rapid enter beta"} block-titles) block-titles)))
(deftest enter-delete-keeps-text-and-cursor-on-the-previous-block
(b/new-block "rapid delete start")
(k/enter)
(k/backspace)
(util/press-seq " tail")
(util/wait-timeout 800)
(let [{:keys [value focused selectionStart selectionEnd]}
(editor-input-state)]
(is (= "rapid delete start tail" value))
(is focused)
(is (= (count value) selectionStart selectionEnd)))
(util/exit-edit)
(let [block-titles (:blockTitles (editor-input-state))]
(is (some #{"rapid delete start tail"} block-titles) block-titles)))
(deftest parent-and-child-rapid-edits-keep-the-latest-child-title
(p/new-page "parent child pending edits")
(let [page-uuid (get (ls-api-call! :editor.getBlock "parent child pending edits") "uuid")
[parent child]
(ls-api-call! :editor.insertBatchBlock
page-uuid
[{:content "a"
:children [{:content "b"}]}])
parent-uuid (get parent "uuid")
child-uuid (get child "uuid")]
(w/click (str "#block-content-" parent-uuid))
(w/fill util/editor-q "ax")
(w/click (str "#block-content-" child-uuid))
(w/fill util/editor-q "bx")
(w/fill util/editor-q "b")
(k/arrow-up)
(util/wait-timeout 800)
(is (= "b" (get (ls-api-call! :editor.getBlock child-uuid) "content")))))
(deftest page-level-node-reference-renders-linked-references
(let [target-name (str "linked-reference-target-" (random-uuid))
source-name (str "linked-reference-source-" (random-uuid))]
(p/new-page target-name)
(let [target-uuid (get (ls-api-call! :editor.getBlock target-name) "uuid")]
(p/new-page source-name)
(b/save-block (str "[[" target-name "]]"))
(util/exit-edit)
(let [source-uuid (get (ls-api-call! :editor.getBlock source-name) "uuid")]
(ls-api-call! :editor.upsertBlockProperty
source-uuid
"linked-reference-node"
target-uuid
{:schema {:type "node"}})
(let [source (ls-api-call! :editor.getBlock source-uuid)]
(is (some (fn [[key value]]
(when (and (string/ends-with? key "/linked-reference-node")
value)
value))
source)
source))
(p/goto-page target-name)
(assert/assert-is-visible
(loc/filter ".references" :has-text source-name))
(assert/assert-have-count
(loc/filter ".references" :has-text "Unexpected error")
0)))))
(deftest consecutive-enter-and-delete-ops-complete-without-worker-errors
(util/wait-timeout 500)
(let [old-logs (set (console-logs))]
(doseq [idx (range 3)]
(b/new-block (str "render budget " idx))
(let [block-uuid (.getAttribute (util/get-edit-block-container) "blockid")]
(b/delete-blocks)
(assert/assert-have-count (str "#ls-block-" block-uuid) 0)))
(util/wait-timeout 800)
(let [logs (remove old-logs (console-logs))
enter-logs (concat
(worker-op-logs logs "[:insert-blocks]")
(worker-op-logs logs "[:save-block :insert-blocks]"))
delete-logs (worker-op-logs logs "[:delete-blocks]")]
(is (= 3 (count enter-logs)) (pr-str enter-logs))
(is (= 3 (count delete-logs)) (pr-str delete-logs))
(is (not-any? #(some (fn [message] (string/includes? % message))
["DB worker API failed"
"Missing renderer resource entity"
"Unsupported view resource row"
"Invalid renderer resource UUID"])
logs)))))
(deftest backspace-at-start-removes-pending-block-dom-test
(b/new-blocks ["source"])
(b/new-block "")
(b/save-block "pending block")
(let [block-uuid (.getAttribute (util/get-edit-block-container) "blockid")]
(util/move-cursor-to-start)
(k/backspace)
(is (nil? (ls-api-call! :editor.getBlock block-uuid)))
(assert/assert-have-count (str "#ls-block-" block-uuid) 0)))
(deftest today-queries-render-without-resource-errors
(let [page (ls-api-call! :editor.createJournalPage
(str (java.time.Instant/now)))]
(ls-api-call! :app.pushState "page" {:name (get page "uuid")} nil))
(util/wait-timeout 1500)
(assert/assert-have-count "#today-queries" 1)
(assert/assert-have-count "#today-queries .block-content-fallback-ui" 0))
(deftest drag-and-drop-asset-does-not-create-blank-asset
(testing "dragging and dropping a file should keep non-empty asset title"
(let [asset-title "drag-drop-regression"
file-name (str asset-title ".png")]
(b/new-block "")
(drag-and-drop-file! file-name "image/png")
(w/wait-for ".ls-page-blocks .ls-block .asset-container img")
;; Exit edit mode to trigger a save; this used to overwrite the new asset with blank content.
(util/exit-edit)
(assert/assert-have-count ".ls-page-blocks .ls-block .asset-container img" 1)
(assert/assert-is-visible
(format ".ls-page-blocks .ls-block .block-title-wrap:text('%s')" asset-title)))))
(deftest toggle-between-page-and-block
(testing "Convert block to page and back"
(b/new-block "b1")
(util/set-tag "Page" {:hidden? true})
(assert/assert-is-visible ".ls-page-blocks .ls-block .ls-icon-file")
(b/toggle-property "Tags" "Page")
(assert/assert-is-hidden ".ls-page-blocks .ls-block .ls-icon-file")))
(deftest toggle-between-page-and-block-for-selected-blocks
(testing "Convert selected blocks to pages and back"
(b/new-blocks ["b1" "b2" "b3"])
(b/select-blocks 3)
(b/toggle-property "Tags" "Page")
(assert/assert-is-visible ".ls-page-blocks .ls-block .ls-icon-file")
(w/wait-for (format ".menu-link:has-text('%s')" "Page"))
(k/esc)
(b/toggle-property "Tags" "Page")
(w/wait-for-not-visible ".ls-page-blocks .ls-block .ls-icon-file")))
(deftest disallow-adding-page-tag-to-normal-pages
(testing "Disallow adding #Page to normal pages"
(k/arrow-up)
(util/move-cursor-to-end)
(let [editor (w/-query "*:focus")
original-title (.inputValue editor)]
(util/press-seq " #" {:delay 20})
(util/press-seq "Page")
(assert/assert-is-hidden (format "#ac-0.menu-link:has-text('%s')" "Page"))
(w/fill editor original-title)
(is (= original-title (.inputValue editor))))
(util/exit-edit)))
(deftest move-blocks-mod+shift+m
(testing "move blocks using `mod+shift+m`"
(p/new-page "Target page")
(p/new-page "Source page")
(b/new-blocks ["b1" "b2" "b3"])
(b/select-blocks 3)
(k/press "ControlOrMeta+Shift+m")
(choose-move-target! "Target page")
(assert/assert-have-count ".ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)" 0)))
(deftest move-blocks-cmdk
(testing "move blocks using cmdk"
(let [target-page (str "Target page " (random-uuid))
source-page (str "Source page " (random-uuid))]
(p/new-page target-page)
(p/new-page source-page)
(b/new-blocks ["b1" "b2" "b3"])
(b/select-blocks 3)
(util/search-and-click "Move blocks to")
(choose-move-target! target-page)
(assert/assert-have-count ".ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)" 0))))
(deftest move-editing-block-cmdk
(testing "move the current editing block using cmdk"
(let [target-page (str "Editing block target " (random-uuid))
source-page (str "Editing block source " (random-uuid))]
(p/new-page target-page)
(p/new-page source-page)
(b/new-blocks ["editing block"])
(util/search-and-click "Move blocks to")
(choose-move-target! target-page)
(assert/assert-have-count ".ls-page-blocks .page-blocks-inner .ls-block:not(.block-add-button)" 0)
(p/goto-page target-page)
(w/wait-for ".ls-page-blocks .ls-block:has-text('editing block')")
(is (contains? (set (util/get-page-blocks-contents)) "editing block")))))
(deftest shift-open-page-in-sidebar
(testing "Shift+Enter opens an ordinary page search result in the sidebar"
(p/new-page "Ordinary sidebar page")
(b/new-blocks ["ordinary page block"])
(p/new-page "Sidebar search source")
(util/search "Ordinary sidebar page")
(let [result (.first (w/get-by-test-id "Ordinary sidebar page"))]
(assert/assert-is-visible result)
(.hover result))
(.focus (w/-query ".cp__cmdk-search-input"))
(k/shift+enter)
(assert/assert-is-visible
".cp__right-sidebar .sidebar-item :text('Ordinary sidebar page')")))
(deftest shift-click-page-title-opens-in-sidebar
(testing "Shift+click opens an ordinary page title in the sidebar"
(p/new-page "Shift click sidebar page")
(util/exit-edit)
(w/click "div[data-testid='page title'] .block-title-wrap"
(doto (Locator$ClickOptions.)
(.setModifiers [KeyboardModifier/SHIFT])))
(assert/assert-is-visible
".cp__right-sidebar .sidebar-item :text('Shift click sidebar page')")))
(deftest cmdk-block-results-render-breadcrumbs-test
(testing "CmdK block results include their ready breadcrumb"
(let [page-name (p/get-page-name)
title (str "cmdk breadcrumb target " (random-uuid))]
(b/new-block title)
(util/exit-edit)
(util/search title)
(assert/assert-is-visible
(loc/filter ".cp__cmdk .breadcrumb" :has-text page-name)))))
(deftest comments-update-and-title-edit
(testing "a submitted comment renders immediately and its thread title is editable"
(p/new-page "Comments reactivity")
(b/new-blocks ["comment target"])
(util/search-and-click "Add comment")
(assert/assert-is-visible ".ls-comments-area")
(assert/assert-is-hidden ".ls-block.is-comments-area .block-tags")
(w/fill ".ls-comment-add textarea" "first comment")
(w/click ".ls-comment-submit")
(assert/assert-is-visible ".ls-comment-row :text('first comment')")
(w/click ".ls-comments-label")
(assert/assert-is-visible ".ls-comments-title-editor textarea")
(assert/assert-is-hidden ":text('Something went wrong')")))
(deftest first-comment-actions-stay-inside-scroll-container
(p/new-page "Comment actions clipping")
(b/new-blocks ["comment target"])
(util/search-and-click "Add comment")
(w/fill ".ls-comment-add textarea" "first comment")
(w/click ".ls-comment-submit")
(assert/assert-is-visible ".ls-comment-row")
(is
(w/eval-js
"(() => {
const list = document.querySelector('.ls-comments-list');
const actions = document.querySelector('.ls-comment-row .ls-comment-actions');
const listRect = list.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
return actionsRect.top >= listRect.top && actionsRect.bottom <= listRect.bottom;
})()")))
(deftest move-pages-to-library
(testing "move pages using `mod+shift+m`"
(p/goto-page "Library")
(p/new-page "test page")
(b/new-blocks ["block1" "block2" "block3"])
(b/select-blocks 3)
(b/toggle-property "Tags" "Page")
(assert/assert-is-visible ".ls-page-blocks .ls-block .ls-icon-file")
(k/press "ControlOrMeta+Shift+m")
(w/fill "input[placeholder=\"Move blocks to\"]" "Library")
(w/wait-for (w/get-by-test-id "Library"))
(w/click (w/get-by-test-id "Library"))
(assert/assert-have-count
(loc/filter ".ls-page-blocks .ls-block" :has-text "block1")
0)
(p/goto-page "Library")
(assert/assert-is-visible
(loc/filter ".ls-page-blocks .block-title-wrap" :has-text "block1"))
(let [contents (set (util/get-page-blocks-contents))]
(is (set/subset? (set ["block1" "block2" "block3"]) contents)))))
(deftest create-nested-pages-in-library
(testing "create nested pages in Library"
(p/goto-page "Library")
(b/new-blocks ["page parent" "page child"])
(b/indent)
(b/new-block "another nested child")
(b/indent)))
(defn- selection-range
[]
(w/eval-js
"(() => {
const editor = document.querySelector('.editor-wrapper textarea');
return `${editor.selectionStart}:${editor.selectionEnd}`;
})()"))
(defn- assert-editor-value!
[value]
(-> (w/-query util/editor-q)
assert/assert-that
(.hasValue value)))
(defn- clipboard-text!
[text]
(w/eval-js "text => navigator.clipboard.writeText(text)" text))
(deftest editor-exit-and-unicode-persistence-test
(testing "Esc, outside click and navigation save Unicode once and clear transient editor state"
(let [content "中文🙂 e\u0301 editor persistence"
page-name (p/get-page-name)]
(b/new-block content)
(let [block-uuid (.getAttribute (util/get-edit-block-container) "blockid")]
(k/esc)
(assert/assert-have-count util/editor-q 0)
(assert/assert-is-visible
(loc/filter ".ls-page-blocks" :has-text content))
(w/click "#main-content-container")
(assert/assert-have-count ".ui__popover-content, .autocomplete" 0)
(p/new-page "editor exit destination")
(p/goto-page page-name)
(assert/assert-is-visible
(loc/filter ".ls-page-blocks" :has-text content))
(util/refresh-until-graph-loaded)
(is (= content
(get (ls-api-call! :editor.getBlock block-uuid) "content")))))))
(deftest empty-enter-and-soft-line-break-test
(testing "empty Enter obeys tree rules while Shift+Enter stays inside one block"
(b/new-block "")
(let [before (util/page-blocks-count)]
(k/enter)
(is (= (inc before) (util/page-blocks-count)))
(assert/assert-have-count util/editor-q 1))
(b/save-block "first line second line")
(k/press "Home")
(dotimes [_ 10] (k/arrow-right))
(k/press "Shift+Enter")
(is (= "first line\n second line" (util/get-edit-content)))
(util/exit-edit)
(is (= 1
(count (filter #(string/includes? % "first line")
(util/get-page-blocks-contents)))))
(b/new-blocks ["empty parent" ""])
(b/indent)
(let [before-count (util/page-blocks-count)
[before-x _] (util/bounding-xy (util/get-editor))]
(k/enter)
(let [[after-x _] (util/bounding-xy (util/get-editor))]
(is (= before-count (util/page-blocks-count)))
(is (< after-x before-x))))))
(deftest cursor-boundaries-word-motion-and-kill-test
(testing "cursor motion follows boundaries and range deletion is one undoable edit"
(let [word-modifier (if util/mac? "Alt" "Control")]
(b/new-blocks ["first cursor line" "second cursor line"])
(k/shift+enter)
(util/press-seq "continued")
(let [multiline "second cursor line\ncontinued"
second-line-start (inc (count "second cursor line"))]
(is (= multiline (util/get-edit-content)))
(k/press "Home")
(is (= (str second-line-start ":" second-line-start)
(selection-range)))
(k/arrow-up)
(is (= multiline (util/get-edit-content))))
(k/press "Home")
(is (= "0:0" (selection-range)))
(k/arrow-up)
(b/wait-editor-text "first cursor line")
(is (= "first cursor line" (util/get-edit-content)))
(util/move-cursor-to-end)
(let [before (util/get-edit-content)]
(k/press (str word-modifier "+ArrowLeft"))
(is (not= (str (count before) ":" (count before))
(selection-range))))
(k/press (str word-modifier "+Backspace"))
(assert-editor-value! "first line")
(b/undo)
(assert-editor-value! "first cursor line")
(k/press "ControlOrMeta+a")
(k/backspace)
(assert-editor-value! "")
(b/undo)
(assert-editor-value! "first cursor line"))))
(deftest text-format-shortcuts-and-source-roundtrip-test
(testing "format shortcuts wrap only the selection and rendered text round-trips"
(doseq [[shortcut source expected]
[["ControlOrMeta+b" "bold" "**bold**"]
["ControlOrMeta+i" "italic" "*italic*"]
["ControlOrMeta+Shift+h" "highlight" "==highlight=="]]]
(b/new-block source)
(k/press "ControlOrMeta+a")
(k/press shortcut)
(let [formatted (util/get-edit-content)]
(is (= expected formatted))
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".block-title-wrap" :has-text source))
(w/click (loc/filter ".block-title-wrap" :has-text source))
(is (= formatted (util/get-edit-content)))))
(b/new-block "escape \\* literal 🙂 longwordwithoutbreak0123456789")
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".block-title-wrap" :has-text "longwordwithoutbreak"))))
(deftest escape-save-never-paints-stale-block-content-test
(testing "the first rendered frame after Escape contains the saved text"
(let [title (str "escape-save-frame-" (random-uuid))]
(b/new-block "")
(w/fill util/editor-q title)
(start-edit-exit-frame-capture!)
(k/esc)
(assert/assert-non-editor-mode)
(let [frames (edit-exit-read-frames)]
(is (seq frames))
(is (every? #(= title (:text %)) frames)
frames)))))
(deftest new-page-reference-renders-on-the-first-frame-after-save-test
(testing "a newly created page reference is never blank after Escape"
(let [page-title (str "reference-first-frame-" (random-uuid))]
(b/new-block "")
(w/fill util/editor-q (str "[[" page-title "]]"))
(start-edit-exit-frame-capture!)
(k/esc)
(assert/assert-non-editor-mode)
(let [frames (edit-exit-read-frames)]
(is (seq frames))
(is (every? #(= page-title (:pageRefText %)) frames)
frames)))))
(deftest saved-page-reference-reopens-with-page-title-test
(testing "keyboard editing uses the page title instead of its UUID"
(let [page-title (str "reference-reedit-" (random-uuid))
source (format "[[%s]]" page-title)]
(b/new-block "")
(w/fill util/editor-q source)
(k/esc)
(assert/assert-non-editor-mode)
(assert/assert-is-visible
(loc/filter ".page-reference .page-ref" :has-text page-title))
(k/enter)
(assert/assert-editor-mode)
(is (= source (util/get-edit-content))))))
(deftest page-and-tag-autocomplete-test
(testing "page and tag autocomplete update, insert and open existing/new targets"
(p/new-page "autocomplete existing page")
(p/new-page "autocomplete host")
(b/new-block "")
(util/press-seq "[[autocomplete existing page")
(assert/assert-is-visible
(loc/filter ".ui__popover-content a.menu-link.chosen"
:has-text "autocomplete existing page"))
(k/enter)
(is (string/includes? (util/get-edit-content)
"[[autocomplete existing page]]"))
(util/exit-edit)
(w/click
(loc/filter ".page-reference .page-ref"
:has-text "autocomplete existing page"))
(is (= "autocomplete existing page" (p/get-page-name)))
(p/goto-page "autocomplete host")
(b/new-block "")
(util/press-seq "#autocomplete-new-tag")
(assert/assert-is-visible ".ui__popover-content")
(k/enter)
(assert/assert-is-visible
(loc/filter ".block-tag" :has-text "autocomplete-new-tag"))))
(deftest slash-menu-filter-scroll-and-cleanup-test
(testing "slash search keeps one highlighted result and Esc clears its state"
(b/new-block "")
(util/press-seq "/")
(assert/assert-is-visible ".ui__popover-content")
(util/press-seq "property")
(assert/assert-is-visible
(loc/filter ".ui__popover-content" :has-text "property"))
(k/arrow-down)
(k/arrow-up)
(assert/assert-have-count
".ui__popover-content a.menu-link.chosen, .ui__popover-content [data-kb-highlighted]"
1)
(k/esc)
(assert/assert-is-hidden ".ui__popover-content")
(is (= "/property" (util/get-edit-content)))
(util/press-seq "/")
(assert/assert-is-visible ".ui__popover-content")))
(deftest task-date-and-priority-slash-lifecycle-test
(testing "task commands add, update and clear visible properties immediately"
(b/new-block "sample task")
(util/input-command "TODO")
(util/exit-edit)
(let [block (loc/filter ".ls-page-blocks .ls-block"
:has-text "sample task")
uuid (.getAttribute block "blockid")
block-selector (format "#ls-block-%s" uuid)]
(assert/assert-is-visible
(loc/filter (str block-selector " .block-tag") :has-text "Task"))
(w/click (str block-selector " .block-title-wrap"))
(util/move-cursor-to-end)
(util/input-command "Priority High")
(util/exit-edit)
(assert/assert-have-count
(str block-selector " .positioned-properties.block-left .property-value-inner")
2)
(is (= "High"
(get-in (ls-api-call! :editor.getBlock uuid)
[":logseq.property/priority" "title"])))
(w/click (str block-selector " .block-title-wrap"))
(util/move-cursor-to-end)
(util/input-command "Scheduled")
(w/click
"[role='gridcell'][aria-selected='true'] button")
(util/exit-edit)
(assert/assert-is-visible
(loc/filter (str block-selector " .bottom-property-pill")
:has-text "Scheduled"))
(w/click (str block-selector " .block-title-wrap"))
(util/move-cursor-to-end)
(util/input-command "No priority")
(util/exit-edit)
(is (= ":logseq.property/empty-placeholder"
(get-in (ls-api-call! :editor.getBlock uuid)
[":logseq.property/priority" "ident"]))))))
(deftest virtualized-late-editor-and-code-editor-test
(testing "late-mounted rows and CodeMirror keep separate targets and persisted content"
(let [page-name (p/get-page-name)
page-uuid (get (ls-api-call! :editor.getPage page-name) "uuid")]
(ls-api-call! :editor.insertBatchBlock
page-uuid
(mapv #(hash-map :content (str "late editor row " %))
(range 30)))
(w/click (loc/filter ".block-title-wrap" :has-text "late editor row 29"))
(util/move-cursor-to-end)
(util/press-seq " edited")
(is (= "late editor row 29 edited" (util/get-edit-content))))
(b/new-block "")
(util/input-command "Code block")
(assert/assert-is-visible ".CodeMirror, .cm-editor")
(let [code-block-uuid
(.getAttribute (w/-query ".ls-block:has(.CodeMirror)") "blockid")]
(w/click (.first (w/-query "pre.CodeMirror-line")))
(util/input "const value = 1;\nvalue + 1;")
(k/esc)
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".extensions__code" :has-text "const value = 1"))
(util/refresh-until-graph-loaded)
(is (string/includes?
(get (ls-api-call! :editor.getBlock code-block-uuid) "content")
"const value = 1")))))
(deftest multi-selection-indent-roundtrip-test
(testing "multi-indent preserves order/subtrees and is one undo step"
(let [page-name (p/get-page-name)]
(b/new-blocks ["multi a" "multi a child" "multi b" "multi b child"])
(k/tab)
(k/arrow-up)
(k/arrow-up)
(k/tab)
(is (= 2 (count (ls-api-call! :editor.getPageBlocksTree page-name))))
(k/arrow-down)
(k/arrow-down)
(b/select-blocks 2)
(k/tab)
(let [indented (ls-api-call! :editor.getPageBlocksTree page-name)]
(is (= 1 (count indented))))
(b/undo)
(is (= 2 (count (ls-api-call! :editor.getPageBlocksTree page-name))))
(b/redo)
(is (= 1 (count (ls-api-call! :editor.getPageBlocksTree page-name)))))))
(deftest collapse-single-multiple-and-sidebar-test
(testing "single and selected collapse states update mounted containers predictably"
(let [page-name (p/get-page-name)
parent (ls-api-call! :editor.appendBlockInPage page-name "collapse parent")
uuid (get parent "uuid")]
(ls-api-call! :editor.insertBlock uuid "collapse child" {:sibling false})
(w/click (format ".ls-page-blocks #control-%s" uuid))
(assert/assert-is-hidden
(loc/filter (format "#ls-block-%s" uuid) :has-text "collapse child"))
(ls-api-call! :editor.openInRightSidebar uuid)
(assert/assert-is-visible (format ".cp__right-sidebar #ls-block-%s" uuid))
(assert/assert-is-visible
(loc/filter ".cp__right-sidebar" :has-text "collapse child"))
(w/click
(format ".cp__right-sidebar #control-%s" uuid))
(assert/assert-is-hidden
(loc/filter ".cp__right-sidebar" :has-text "collapse child"))
(w/click
(format ".cp__right-sidebar #control-%s" uuid))
(assert/assert-is-visible
(loc/filter ".cp__right-sidebar" :has-text "collapse child"))
(w/refresh)
(assert/assert-is-hidden ".ui__loading, .loading-graph")
(assert/assert-is-visible
(format ".ls-page-blocks #ls-block-%s" uuid)))))
(deftest selection-direction-and-hierarchical-select-all-test
(testing "range and select-all remain inside the current visible container"
(b/new-blocks ["select a" "select b" "select c" "select d"])
(b/select-blocks 3)
(assert/assert-have-count ".ls-page-blocks .ls-block.selected" 3)
(is (= ["select b" "select c" "select d"]
(mapv string/trim
(w/all-text-contents
".ls-page-blocks .ls-block.selected .block-title-wrap"))))
(k/press "ControlOrMeta+a")
(assert/assert-have-count ".ls-page-blocks .ls-block.selected" 4)
(assert/assert-have-count ".cp__right-sidebar .ls-block.selected" 0)))
(deftest structured-and-plain-text-copy-test
(testing "structured copy preserves trees while plain copy exposes readable indentation"
(let [target-page "copy target"]
(b/new-blocks ["copy parent" "copy child" "copy sibling"])
(k/arrow-up)
(b/indent)
(k/arrow-down)
(b/select-blocks 3)
(b/copy)
(p/new-page target-page)
(b/paste)
(util/exit-edit)
(let [tree (ls-api-call! :editor.getPageBlocksTree target-page)]
(is (= ["copy parent" "copy sibling"]
(mapv #(get % "content") tree)))
(is (= ["copy child"]
(mapv #(get % "content") (get (first tree) "children")))))
(w/click (loc/filter ".block-title-wrap" :has-text "copy sibling"))
(b/select-blocks 3)
(k/press "ControlOrMeta+Shift+c")
(let [{:keys [missing-blocks] :as copy-result}
(wait-for-copied-blocks! ["copy parent" "copy child"])]
(is (empty? missing-blocks) (pr-str copy-result))))))
(deftest plain-multiline-and-html-paste-test
(testing "paste mode preserves cursor text, multiline structure and safe rich content"
(b/new-block "before-after")
(k/press "Home")
(dotimes [_ 7] (k/arrow-right))
(clipboard-text! "middle")
(b/paste)
(is (= "before-middleafter" (util/get-edit-content)))
(clipboard-text! "root\n child\nsibling")
(b/new-block "")
(b/paste)
(util/exit-edit)
(assert/assert-is-visible (loc/filter ".ls-page-blocks" :has-text "child"))
(b/new-block "")
(w/eval-js
"(() => {
const transfer = new DataTransfer();
transfer.setData('text/html',
'<h2>Rich title</h2><ul><li><strong>Bold item</strong></li></ul><script>window.__e2eInjected=true</script>');
transfer.setData('text/plain', 'Rich title\\nBold item');
document.querySelector('.editor-wrapper textarea').dispatchEvent(
new ClipboardEvent('paste', {bubbles: true, cancelable: true, clipboardData: transfer})
);
})()")
(assert/assert-is-visible
(loc/filter ".ls-page-blocks" :has-text "Rich title"))
(k/esc)
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".block-title-wrap" :has-text "Rich title"))
(is (not (true? (w/eval-js "window.__e2eInjected === true"))))))
(deftest mixed-height-virtual-page-keeps-blocks-separated-test
(testing "mixed text, code and headings do not overlap after fast scrolling"
(let [long-text (apply str (repeat 8 "long mixed-height content "))
blocks (vec
(mapcat
(fn [index]
[(format "mixed plain %03d" index)
(str "## mixed heading " index)
(str "```clojure\n(+ " index " 1)\n```")
(str long-text index)])
(range 8)))]
(insert-current-page-blocks! blocks)
(enable-virtualized-rendering!)
(assert/assert-is-visible
(util/-query-last ".ls-page-blocks [data-virtuoso-scroller]"))
(doseq [position [1.0 0.0 0.5 0.9 0.1]]
(w/eval-js
"position => {
const scroller = Array.from(document.querySelectorAll(
'.ls-page-blocks [data-virtuoso-scroller]'
)).find((node) => node.getClientRects().length > 0);
scroller.scrollTop =
Math.max(0, (scroller.scrollHeight - scroller.clientHeight) * position);
scroller.dispatchEvent(new Event('scroll'));
}"
position)
(util/wait-timeout 100)
(let [{:strs [overlap visibleCount]}
(w/eval-js
"(() => {
const blocks = Array.from(
document.querySelectorAll('.ls-page-blocks .ls-block')
).filter((node) => {
const rect = node.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight;
});
const rects = blocks.map((node) => node.getBoundingClientRect());
return {
visibleCount: rects.length,
overlap: rects.some(
(rect, index) => index > 0 && rect.top < rects[index - 1].bottom
)
};
})()")]
(is (pos? visibleCount))
(is (false? overlap)))))))
(deftest journals-consecutive-input-test
(testing "consecutive journal input creates one stable editor and distinct blocks"
(util/goto-journals)
(b/new-blocks ["journal e2e first" "journal e2e second" "journal e2e third"])
(assert/assert-have-count util/editor-q 1)
(is (= "journal e2e third" (util/get-edit-content)))
(util/exit-edit)
(let [journal-selector "#journals .journal-item:has-text('journal e2e third')"
block-selector (str journal-selector
" .ls-block:not(.block-add-button) .block-title-wrap")]
(w/wait-for journal-selector)
(is (= ["journal e2e first" "journal e2e second" "journal e2e third"]
(take-last 3 (w/all-text-contents block-selector)))))))
(deftest worker-missing-read-is-recoverable-test
(testing "a missing worker entity returns nil without poisoning a later read"
(let [missing-uuid (str (random-uuid))]
(is (nil? (ls-api-call! :editor.getBlock missing-uuid))))
(assert/assert-is-hidden ".ui__loading, .loading-graph")
(b/new-block "worker recovery target")
(let [uuid (.getAttribute (util/get-edit-block-container) "blockid")]
(util/exit-edit)
(is (string? uuid))
(is (= "worker recovery target"
(get (ls-api-call! :editor.getBlock uuid) "content"))))))
(deftest enter-splits-block-at-cursor-test
(testing "Enter in the middle preserves both halves and moves focus to the second"
(b/new-block "alphaomega")
(k/press "Home")
(dotimes [_ 5] (k/arrow-right))
(k/enter)
(util/press-seq "middle-")
(assert/assert-have-count util/editor-q 1)
(is (= "middle-omega" (util/get-edit-content)))
(util/exit-edit)
(is (= ["alpha" "middle-omega"]
(take-last 2 (util/get-page-blocks-contents))))))
(deftest node-reference-autocomplete-test
(testing "node reference search inserts a reference and rerenders target updates"
(let [source-page (p/get-page-name)]
(b/new-block "reference autocomplete unique target")
(let [target-uuid (.getAttribute (util/get-edit-block-container) "blockid")]
(b/new-block "")
(util/press-seq "[[reference autocomplete unique")
(assert/assert-is-visible ".ui__popover-content")
(assert/assert-is-visible
(loc/filter ".ui__popover-content .breadcrumb" :has-text source-page))
(w/click (.first (loc/filter ".ui__popover-content a" :has-text
"reference autocomplete unique target")))
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".page-reference"
:has-text "reference autocomplete unique target"))
(b/jump-to-block "reference autocomplete unique target")
(k/press "ControlOrMeta+a")
(util/press-seq "reference autocomplete updated target")
(util/exit-edit)
(assert/assert-is-visible
(loc/filter ".page-reference"
:has-text "reference autocomplete updated target"))
(is (= source-page (p/get-page-name)))
(w/click (.first (loc/filter ".page-reference .page-ref"
:has-text "reference autocomplete updated target")))
(is (string/includes? (.url (w/get-page)) target-uuid))
(assert/assert-is-visible
(loc/filter ".ls-page-blocks .block-title-wrap"
:has-text "reference autocomplete updated target"))))))
(deftest quick-add-moves-all-blocks-to-today-test
(testing "Quick add moves every temporary block to today's journal exactly once"
(k/press (if util/mac? "Meta+e" "Control+Alt+e"))
(assert/assert-is-visible ".ls-dialog-quick-add")
(w/wait-for util/editor-q)
(b/new-blocks ["quick add first" "quick add second"])
(w/click
(loc/filter ".ls-dialog-quick-add button" :has-text "Add to today"))
(w/wait-for-not-visible ".ls-dialog-quick-add")
(util/goto-journals)
(assert/assert-have-count
(loc/filter "#journals .block-title-wrap" :has-text "quick add first")
1)
(assert/assert-have-count
(loc/filter "#journals .block-title-wrap" :has-text "quick add second")
1)))
(deftest external-property-update-preserves-edit-buffer-test
(testing "an external property/child delta does not replace unrelated active text"
(b/new-block "active editor text")
(let [uuid (.getAttribute (util/get-edit-block-container) "blockid")]
(util/move-cursor-to-end)
(util/press-seq " local draft")
(ls-api-call! :editor.upsertBlockProperty uuid "external-property" "updated")
(ls-api-call! :editor.insertBlock uuid "external child" {:sibling false})
(is (= "active editor text local draft" (util/get-edit-content)))
(assert/assert-is-visible
(loc/filter ".property-pair" :has-text "external-property"))
(assert/assert-is-visible
(loc/filter ".ls-block" :has-text "external child")))))
(deftest operation-completion-restores-mounted-focus-test
(testing "completed insert/update/delete operations expose their DOM result immediately"
(let [page-name (p/get-page-name)
inserted (ls-api-call! :editor.appendBlockInPage page-name "completion inserted")
uuid (get inserted "uuid")]
(assert/assert-have-count (str "#ls-block-" uuid) 1)
(ls-api-call! :editor.updateBlock uuid "completion updated")
(assert/assert-is-visible
(format "#ls-block-%s .block-title-wrap:text('completion updated')" uuid))
(w/click (format "#ls-block-%s .block-content" uuid))
(util/move-cursor-to-end)
(util/press-seq " and focused")
(is (= "completion updated and focused" (util/get-edit-content)))
(ls-api-call! :editor.removeBlock uuid)
(assert/assert-have-count (str "#ls-block-" uuid) 0))))