023-logseq-cli-help-show-styling.md

This commit is contained in:
rcmerci
2026-01-30 18:06:05 +08:00
parent 35ae52b63b
commit 8a67ef5552
14 changed files with 868 additions and 390 deletions

View File

@@ -6,17 +6,35 @@
[clojure.string :as string]
[logseq.cli.common.graph :as cli-common-graph]
[logseq.cli.spec :as cli-spec]
[logseq.cli.style :as style]
[logseq.cli.text-util :as cli-text-util]
[nbb.error]
[promesa.core :as p]))
(defn- escape-regex
[value]
(string/replace value #"[\\.^$|?*+()\\[\\]{}]" "\\\\$&"))
(defn- bold-command-names
[value commands]
(reduce (fn [acc command]
(let [pattern (re-pattern (str "(?m)^(\\s*)" (escape-regex command) "(\\s+)"))]
(string/replace acc pattern (fn [[_ prefix spacing]]
(str prefix (style/bold command) spacing)))))
value
commands))
(defn- format-commands [{:keys [table]}]
(let [table (mapv (fn [{:keys [cmds desc spec]}]
(cond-> [(str (string/join " " cmds)
(when spec " [options]"))]
desc (conj desc)))
(filter (comp seq :cmds) table))]
(cli/format-table {:rows table})))
(let [entries (->> table
(filter (comp seq :cmds)))
rows (mapv (fn [{:keys [cmds desc spec]}]
(cond-> [(str (string/join " " cmds)
(when spec " [options]"))]
desc (conj desc)))
entries)
commands (map (comp #(string/join " " %) :cmds) entries)]
(-> (cli/format-table {:rows rows})
(bold-command-names commands))))
(def ^:private default-spec
{:version {:coerce :boolean
@@ -25,9 +43,10 @@
(declare table)
(defn- print-general-help [_m]
(println (str "Usage: logseq [command] [options]\n\nOptions:\n"
(cli/format-opts {:spec default-spec})))
(println (str "\nCommands:\n" (format-commands {:table table}))))
(println (str "Usage: logseq [command] [options]\n\n"
(style/bold "Options") ":\n"
(style/bold-options (cli/format-opts {:spec default-spec}))))
(println (str "\n" (style/bold "Commands") ":\n" (format-commands {:table table}))))
(defn- default-command
[{{:keys [version]} :opts :as m}]
@@ -45,10 +64,11 @@
(str " " (string/join " "
(map #(str "[" (name %) "]") (:args->opts cmd-map)))))
(when (:spec cmd-map)
(str " [options]\n\nOptions:\n"
(cli/format-opts {:spec (:spec cmd-map)})))
(str " [options]\n\n" (style/bold "Options") ":\n"
(style/bold-options (cli/format-opts {:spec (:spec cmd-map)}))))
(when (:description cmd-map)
(str "\n\nDescription:\n" (cli-text-util/wrap-text (:description cmd-map) 80))))))
(str "\n\n" (style/bold "Description") ":\n"
(cli-text-util/wrap-text (:description cmd-map) 80))))))
(defn- help-command [{{:keys [command help]} :opts}]
(if-let [cmd-map (and command (some #(when (= command (first (:cmds %))) %) table))]
@@ -56,7 +76,7 @@
;; handle help --help
(if-let [cmd-map (and help (some #(when (= "help" (first (:cmds %))) %) table))]
(print-command-help "help" cmd-map)
(println "Command" (pr-str command) "does not exist"))))
(println (style/bold "Command") (pr-str command) "does not exist"))))
(defn- lazy-load-fn
"Lazy load fn to speed up start time. After nbb requires ~30 namespaces, start time gets close to 1s.
@@ -146,9 +166,12 @@
(if (and (= :org.babashka/cli type')
(= :require cause))
(do
(println "Error: Command missing required"
(if (get-in data [:spec option]) "option" "argument")
(pr-str (name option)))
(println (style/bold-keywords
(str "Error: Command missing required "
(if (get-in data [:spec option]) "option" "argument")
" "
(style/bold (pr-str (name option))))
["command" "option" "argument"]))
(when-let [cmd-m (some #(when (= {:spec (:spec %)
:require (:require %)}
(select-keys data [:spec :require])) %) table)]

View File

@@ -0,0 +1,108 @@
# Logseq CLI Help And Show Styling Implementation Plan
Goal: Add picocolors-based styling for help output and show human output in logseq-cli and db-worker-node.
Architecture: Introduce a small shared styling helper that wraps picocolors and is used by CLI help renderers and show tree formatting.
Help output headings and error strings will apply bold to keywords while show output will color status labels and bold tag suffixes without changing data payloads.
Tech Stack: ClojureScript, Node.js, picocolors, babashka.cli.
Related: Relates to docs/agent-guide/022-logseq-cli-help-show-output.md.
## Problem statement
The current help output in logseq-cli and db-worker-node is plain text and does not emphasize command names or option names, which makes scanning harder.
The show command human output also does not visually differentiate status values or tags, which makes scanning large trees harder.
The tree glyphs in show output currently have the same visual weight as content, which makes the structure harder to scan.
## Testing Plan
I will add unit tests for help summary formatting that assert bold styling is applied to command names, option names, and error messages for missing options.
I will add unit tests for show tree text rendering that verify status labels are colorized and tag suffixes are bolded while preserving the existing tree glyph alignment when ANSI codes are stripped.
I will add a unit test that verifies tree glyphs are rendered in a lighter style without altering alignment when ANSI is stripped.
I will add a unit test for db-worker-node help output that asserts bold styling on command names and option names and that the help text still omits auth-token.
I will add unit tests for the styling helper to ensure it can be disabled for tests by stripping ANSI when comparing output.
NOTE: I will write all tests before I add any implementation behavior.
## Scope and constraints
This plan targets logseq-cli in src/main/logseq/cli and db-worker-node help output in src/main/frontend/worker/db_worker_node.cljs.
This plan must use picocolors for color and bold styling and should not change JSON or EDN output formats.
This plan should not introduce new CLI options unless required to gate coloring for tests.
Styling must only be applied when color is supported, and dumb terminals must receive plaintext output.
The `logseq -h` help output should omit the commands list section.
## Files and ownership
| Area | Path | Notes |
| --- | --- | --- |
| npm dependency | package.json | Add picocolors dependency used by ClojureScript Node targets. |
| npm lockfile | yarn.lock | Update to include picocolors. |
| CLI help summary | src/main/logseq/cli/command/core.cljs | Apply bold styling to command names and option names in help summaries and error text. |
| CLI show output | src/main/logseq/cli/command/show.cljs | Apply status color and tag bold styling in tree labels. |
| CLI show output | src/main/logseq/cli/command/show.cljs | Apply lighter styling to tree glyphs in human output. |
| CLI formatting helpers | src/main/logseq/cli/format.cljs | Avoid impacting non-human output, and ensure show uses styled message for human output only. |
| CLI legacy help | deps/cli/src/logseq/cli.cljs | Apply bold styling to command names and option names in help output for legacy cli entrypoint. |
| db-worker-node help | src/main/frontend/worker/db_worker_node.cljs | Apply bold styling to command names and option names in help output lines. |
| CLI tests | src/test/logseq/cli/commands_test.cljs | Update help summary and show tree tests to tolerate ANSI and assert styling intent. |
| CLI format tests | src/test/logseq/cli/format_test.cljs | Add or update tests to ensure human show output includes styled text but JSON and EDN do not. |
| db-worker-node tests | src/test/frontend/worker/db_worker_node_test.cljs | Extend help output test to validate bold styling. |
## Implementation plan
1. Add picocolors to package.json dependencies and update yarn.lock with the new dependency using the existing package manager.
2. Create a small styling helper in a new namespace such as src/main/logseq/cli/style.cljs that wraps picocolors functions for bold and color and exposes a no-color flag for tests.
3. Add a companion helper in a shared location for db-worker-node, or reuse the same namespace if it is available in that build target, to avoid duplicated color logic.
4. In src/main/logseq/cli/style.cljs, add a color support check that disables styling when color is not supported or TERM is dumb.
5. In src/main/logseq/cli/command/core.cljs, wrap help summary command names and option names with the new bold helper.
6. In src/main/logseq/cli/command/core.cljs, update the invalid options error formatting so missing required option names are bolded in the error message.
7. In deps/cli/src/logseq/cli.cljs, apply the same bold styling to command names and option names in the help output for the legacy cli entrypoint.
8. In src/main/frontend/worker/db_worker_node.cljs, update show-help! output to bold command names and option names in the help text.
9. In src/main/logseq/cli/command/show.cljs, add a status style function that maps known status labels to distinct colors, and bolds the status text, using picocolors.
10. In src/main/logseq/cli/command/show.cljs, update the tag suffix rendering to wrap each #tag with bold styling and ensure tags remain separated by spaces.
11. In src/main/logseq/cli/command/show.cljs, style the tree glyphs with a dim or gray color using picocolors while leaving ids and labels unstyled.
12. In src/main/logseq/cli/command/show.cljs, ensure status formatting and glyph styling are only applied to the human output path and do not alter the underlying data used for JSON or EDN outputs.
13. Update src/test/logseq/cli/commands_test.cljs to compare help summaries using an ANSI-stripping helper so assertions remain stable, and to assert bold styling for command and option names.
14. Update src/test/logseq/cli/commands_test.cljs show tree text tests to assert that the status prefix and tag suffix are styled when ANSI is preserved, and to verify tree alignment and glyph lightening using stripped output.
15. Add or update tests in src/test/logseq/cli/format_test.cljs to verify that human show output includes styled prefixes while JSON and EDN outputs remain unchanged.
16. Update src/test/frontend/worker/db_worker_node_test.cljs to assert that the help output bolds command and option names and still omits auth-token.
17. Run bb dev:lint-and-test to ensure all lint and unit tests pass.
## Edge cases
The status value may be a keyword with namespaces such as :logseq.property.status/todo and should still map to the same color for TODO.
The status label may be missing or blank, and the show output should remain unchanged in that case.
Tag labels may include uppercase or punctuation and should still render as bolded tags without losing the leading #.
Help output should still be readable when ANSI colors are not supported, and tests should be resilient by stripping ANSI sequences.
Tree glyph styling should not break alignment when ANSI codes are stripped.
Styling should be fully disabled when color is not supported or TERM is dumb.
## Open questions
Should picocolors styling be applied only when stdout is a TTY, or should it always render for human output regardless of terminal support.
Which specific status to color mapping is preferred for the full set of Logseq statuses such as NOW, LATER, WAITING, CANCELLED, and TODO variants.
## Testing Details
The tests will verify visible behavior by asserting that help output includes bolded command names and option names and that show output includes styled status and tags when rendered to human text.
The tests will also assert that JSON and EDN outputs remain unchanged and that ANSI codes do not break alignment by validating stripped output.
The tests will continue to avoid asserting internal data structures and instead focus on rendered output behavior.
## Implementation Details
- Use a small helper that can apply bold and color via picocolors and also expose a strip-ansi helper for tests.
- Keep styling limited to human output paths and avoid touching transport or data payloads.
- Centralize the status to color mapping in one function to keep future changes easy.
- Apply bold to command names and option names in help output and error strings.
- Preserve existing spacing and alignment by applying styling after label construction rather than before width calculations.
- Apply a lighter style to tree glyphs only, not to ids or labels.
- Gate styling behind color support checks so dumb terminals get plaintext output.
- Ensure any new helper is available to both the CLI and db-worker-node build targets.
- Update tests to use ANSI stripping for alignment assertions and explicit style presence for keyword checks.
- Avoid adding new configuration flags unless tests cannot reliably assert output without them.
## Question
Styling is limited to help info and show human output for now.
---

View File

@@ -158,8 +158,8 @@
"@tabler/icons-react": "^2.47.0",
"@tabler/icons-webfont": "^2.47.0",
"@tippyjs/react": "4.2.5",
"bignumber.js": "^9.0.2",
"better-sqlite3": "12.6.0",
"bignumber.js": "^9.0.2",
"chokidar": "3.5.1",
"chrono-node": "2.2.4",
"codemirror": "5.65.18",
@@ -185,6 +185,7 @@
"path-complete-extname": "1.0.0",
"pdfjs-dist": "4.2.67",
"photoswipe": "^5.3.7",
"picocolors": "^1.1.1",
"pixi-graph-fork": "0.2.0",
"pixi.js": "6.2.0",
"posthog-js": "1.10.2",

View File

@@ -8,6 +8,7 @@
[frontend.worker.platform.node :as platform-node]
[frontend.worker.state :as worker-state]
[lambdaisland.glogi :as log]
[logseq.cli.style :as style]
[logseq.cli.data-dir :as data-dir]
[logseq.db :as ldb]
[promesa.core :as p]))
@@ -225,11 +226,11 @@
(defn- show-help!
[]
(println "db-worker-node options:")
(println " --data-dir <path> (default ~/logseq/cli-graphs)")
(println " --repo <name> (required)")
(println " --rtc-ws-url <url> (optional)")
(println " --log-level <level> (default info)")
(println (str (style/bold "db-worker-node") " " (style/bold "options") ":"))
(println (str " " (style/bold "--data-dir") " <path> (default ~/logseq/cli-graphs)"))
(println (str " " (style/bold "--repo") " <name> (required)"))
(println (str " " (style/bold "--rtc-ws-url") " <url> (optional)"))
(println (str " " (style/bold "--log-level") " <level> (default info)"))
(println " logs: <data-dir>/<graph-dir>/db-worker-node-YYYYMMDD.log (retains 7)"))
(defn- pad2

View File

@@ -2,6 +2,7 @@
"Shared CLI parsing utilities."
(:require [babashka.cli :as cli]
[clojure.string :as string]
[logseq.cli.style :as style]
[logseq.common.config :as common-config]))
(def ^:private global-spec*
@@ -58,28 +59,33 @@
(map (fn [{:keys [cmds desc]}]
(let [command (command-label cmds)]
{:command command
:command-styled (style/bold command)
:desc desc}))))
width (apply max 0 (map (comp count :command) rows))]
(->> rows
(map (fn [{:keys [command desc]}]
(map (fn [{:keys [command command-styled desc]}]
(let [padding (apply str (repeat (- width (count command)) " "))]
(cond-> (str " " command padding)
(cond-> (str " " command-styled padding)
(seq desc) (str " " desc)))))
(string/join "\n"))))
(defn- format-opts
[spec]
(style/bold-options (cli/format-opts {:spec spec})))
(defn group-summary
[group table]
(let [group-table (filter #(= group (first (:cmds %))) table)]
(string/join "\n"
[(str "Usage: logseq " group " <subcommand> [options]")
""
"Subcommands:"
(str (style/bold "Subcommands") ":")
(format-commands group-table)
""
"Global options:"
(cli/format-opts {:spec global-spec*})
(str "Global " (style/bold "options") ":")
(format-opts global-spec*)
""
"Command options:"
(str "Command " (style/bold "options") ":")
(str " See `logseq " group " <subcommand> --help`")])))
(defn top-level-summary
@@ -94,13 +100,13 @@
(string/join "\n"
["Usage: logseq <command> [options]"
""
"Commands:"
(str (style/bold "Commands") ":")
(string/join "\n\n" (map render-group groups))
""
"Global options:"
(cli/format-opts {:spec global-spec*})
(str "Global " (style/bold "options") ":")
(format-opts global-spec*)
""
"Command options:"
(str "Command " (style/bold "options") ":")
" See `logseq <command> --help`"])))
(defn command-summary
@@ -109,11 +115,11 @@
(string/join "\n"
[(str "Usage: logseq " (command-usage cmds spec))
""
"Global options:"
(cli/format-opts {:spec global-spec*})
(str "Global " (style/bold "options") ":")
(format-opts global-spec*)
""
"Command options:"
(cli/format-opts {:spec command-spec})])))
(str "Command " (style/bold "options") ":")
(format-opts command-spec)])))
(defn normalize-opts
[opts]
@@ -139,7 +145,7 @@
[summary message]
{:ok? false
:error {:code :invalid-options
:message message}
:message (style/bold-options message)}
:summary summary})
(defn unknown-command-result

View File

@@ -5,6 +5,7 @@
[logseq.cli.command.id :as id-command]
[logseq.cli.command.core :as core]
[logseq.cli.server :as cli-server]
[logseq.cli.style :as style]
[logseq.cli.transport :as transport]
[logseq.common.util :as common-util]
[promesa.core :as p]))
@@ -97,7 +98,7 @@
(map tag-label)
(remove string/blank?))]
(when (seq labels)
(string/join " " (map #(str "#" %) labels)))))
(string/join " " (map #(style/bold (str "#" %)) labels)))))
(defn- status-from-ident
[ident]
@@ -106,6 +107,24 @@
status (or (last parts) name*)]
(string/upper-case status)))
(def ^:private status-color-map
{"TODO" style/yellow
"DOING" style/blue
"NOW" style/cyan
"LATER" style/magenta
"WAITING" style/magenta
"DONE" style/green
"CANCELED" style/red
"CANCELLED" style/red})
(defn- style-status
[status]
(when (seq status)
(let [label (str status)
lookup (string/upper-case label)
color-fn (get status-color-map lookup identity)]
(style/bold (color-fn label)))))
(defn- status-label
[node]
(let [status (:logseq.property/status node)]
@@ -123,10 +142,11 @@
(let [title (:block/title node)
content (:block/content node)
status (status-label node)
status* (style-status status)
uuid->label (:uuid->label node)
text (or title content)
base (cond
(and text (seq status)) (str status " " text)
(and text (seq status)) (str status* " " text)
text text
(:block/name node) (:block/name node)
(:block/uuid node) (some-> (:block/uuid node) str))
@@ -397,6 +417,8 @@
id-padding (apply str (repeat (inc id-width) " "))
split-lines (fn [value]
(string/split (or value "") #"\n"))
style-glyph (fn [value]
(style/dim value))
lines (atom [])
walk (fn walk [node prefix]
(let [children (:block/children node)
@@ -408,10 +430,13 @@
rows (split-lines (label child))
first-row (first rows)
rest-rows (rest rows)
line (str (pad-id child) " " prefix branch first-row)]
line (str (pad-id child) " "
(style-glyph prefix)
(style-glyph branch)
first-row)]
(swap! lines conj line)
(doseq [row rest-rows]
(swap! lines conj (str id-padding next-prefix row)))
(swap! lines conj (str id-padding (style-glyph next-prefix) row)))
(walk child next-prefix)))))]
(let [rows (split-lines (label root))
first-row (first rows)

View File

@@ -3,6 +3,7 @@
(:require [clojure.string :as string]
[clojure.walk :as walk]
[logseq.cli.command.core :as command-core]
[logseq.cli.style :as style]
[logseq.common.util :as common-util]))
(defn- normalize-json
@@ -94,8 +95,9 @@
(defn- format-error
[error]
(let [{:keys [code message]} error
hint (error-hint error)]
(cond-> (str "Error (" (name (or code :error)) "): " message)
hint (error-hint error)
message* (style/bold-keywords message ["option" "command" "argument"])]
(cond-> (str "Error (" (name (or code :error)) "): " message*)
hint (str "\nHint: " hint))))
(defn- maybe-ident-header

View File

@@ -13,8 +13,6 @@
[summary]
(string/join "\n"
["logseq <command> [options]"
""
"Commands: list page, list tag, list property, add block, add page, move, remove, query, query list, show, graph list, graph create, graph switch, graph remove, graph validate, graph info, graph export, graph import, server list, server status, server start, server stop, server restart"
""
"Options:"
summary]))

View File

@@ -0,0 +1,78 @@
(ns logseq.cli.style
"CLI styling helpers based on picocolors."
(:require ["picocolors" :as pc]
[clojure.string :as string]))
(def ansi-pattern
#"\u001b\[[0-9;]*m")
(def ^:private option-pattern
#"--[A-Za-z0-9][A-Za-z0-9-]*")
(def ^:dynamic *color-enabled?*
nil)
(defn- term-dumb?
[]
(= "dumb" (some-> js/process .-env (aget "TERM"))))
(defn- color-supported?
[]
(and (some-> js/process .-stdout .-isTTY)
(.-isColorSupported pc)
(not (term-dumb?))))
(defn- color-enabled?
[]
(if (some? *color-enabled?*)
(boolean *color-enabled?*)
(color-supported?)))
(defn- ->text
[value]
(if (nil? value)
""
(str value)))
(defn strip-ansi
[value]
(string/replace (or value "") ansi-pattern ""))
(defn- colors
[]
(.createColors pc (color-enabled?)))
(defn- apply-style
[style-key value]
(let [text (->text value)]
(if (seq text)
(let [palette (colors)
style-fn (aget palette style-key)]
(if (fn? style-fn)
(style-fn text)
text))
text)))
(defn bold [value] (apply-style "bold" value))
(defn dim [value] (apply-style "dim" value))
(defn red [value] (apply-style "red" value))
(defn green [value] (apply-style "green" value))
(defn yellow [value] (apply-style "yellow" value))
(defn blue [value] (apply-style "blue" value))
(defn magenta [value] (apply-style "magenta" value))
(defn cyan [value] (apply-style "cyan" value))
(defn bold-keywords
[value keywords]
(reduce (fn [acc word]
(let [pattern (js/RegExp. (str "\\b" word "\\b") "gi")]
(string/replace acc pattern (fn [match]
(bold match)))))
(->text value)
keywords))
(defn bold-options
[value]
(let [text (->text value)]
(string/replace text option-pattern (fn [match]
(bold match)))))

View File

@@ -1,15 +1,16 @@
(ns frontend.worker.db-worker-node-test
(:require ["http" :as http]
(:require ["fs" :as fs]
["http" :as http]
["path" :as node-path]
[cljs.test :refer [async deftest is]]
[clojure.string :as string]
[frontend.test.node-helper :as node-helper]
[frontend.worker-common.util :as worker-util]
[frontend.worker.db-worker-node :as db-worker-node]
[goog.object :as gobj]
[logseq.cli.style :as style]
[logseq.db :as ldb]
[promesa.core :as p]
["fs" :as fs]
["path" :as node-path]))
[promesa.core :as p]))
(defn- http-request
[opts body]
@@ -28,6 +29,17 @@
(.on req "error" reject)
(finish!)))))
(defn- escape-regex
[value]
(let [pattern (js/RegExp. "[.*+?^${}()|[\\]\\\\]" "g")]
(string/replace value pattern "\\\\$&")))
(defn- contains-bold?
[value token]
(let [token (escape-regex token)
pattern (re-pattern (str "\\u001b\\[[0-9;]*m" token "\\u001b\\[[0-9;]*m"))]
(boolean (re-find pattern value))))
(defn- http-get
[host port path]
(http-request {:hostname host
@@ -97,60 +109,60 @@
(deftest db-worker-node-data-dir-permission-error
(async done
(let [data-dir (node-helper/create-tmp-dir "db-worker-readonly")
repo (str "logseq_db_perm_" (subs (str (random-uuid)) 0 8))]
(fs/chmodSync data-dir 365)
(-> (db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
(p/then (fn [_]
(is false "expected data-dir permission error")))
(p/catch (fn [e]
(let [data (ex-data e)]
(is (= :data-dir-permission (:code data)))
(is (= (node-path/resolve data-dir) (:path data))))))
(p/finally (fn [] (done)))))))
(let [data-dir (node-helper/create-tmp-dir "db-worker-readonly")
repo (str "logseq_db_perm_" (subs (str (random-uuid)) 0 8))]
(fs/chmodSync data-dir 365)
(-> (db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
(p/then (fn [_]
(is false "expected data-dir permission error")))
(p/catch (fn [e]
(let [data (ex-data e)]
(is (= :data-dir-permission (:code data)))
(is (= (node-path/resolve data-dir) (:path data))))))
(p/finally (fn [] (done)))))))
(deftest db-worker-node-creates-log-file
(async done
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-log")
repo (str "logseq_db_log_" (subs (str (random-uuid)) 0 8))
log-file (log-path data-dir repo)]
(-> (p/let [{:keys [stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:stop! stop!})
_ (p/delay 50)]
(is (fs/existsSync log-file)))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-log")
repo (str "logseq_db_log_" (subs (str (random-uuid)) 0 8))
log-file (log-path data-dir repo)]
(-> (p/let [{:keys [stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:stop! stop!})
_ (p/delay 50)]
(is (fs/existsSync log-file)))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))
(deftest db-worker-node-log-file-has-entries
(async done
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-log-entries")
repo (str "logseq_db_log_entries_" (subs (str (random-uuid)) 0 8))
log-file (log-path data-dir repo)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo {}])
_ (p/delay 50)
contents (when (fs/existsSync log-file)
(.toString (fs/readFileSync log-file) "utf8"))]
(is (fs/existsSync log-file))
(is (pos? (count contents))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-log-entries")
repo (str "logseq_db_log_entries_" (subs (str (random-uuid)) 0 8))
log-file (log-path data-dir repo)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo {}])
_ (p/delay 50)
contents (when (fs/existsSync log-file)
(.toString (fs/readFileSync log-file) "utf8"))]
(is (fs/existsSync log-file))
(is (pos? (count contents))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))
(deftest db-worker-node-log-retention
(let [enforce-log-retention! #'db-worker-node/enforce-log-retention!
@@ -201,8 +213,15 @@
(deftest db-worker-node-help-omits-auth-token
(let [show-help! #'db-worker-node/show-help!
output (with-out-str (show-help!))]
(is (not (string/includes? output "--auth-token")))))
output (binding [style/*color-enabled?* true]
(with-out-str (show-help!)))]
(is (not (string/includes? (style/strip-ansi output) "--auth-token")))
(is (re-find #"\u001b\[[0-9;]*moptions\u001b\[[0-9;]*m:" output))
(is (contains-bold? output "db-worker-node"))
(is (contains-bold? output "--data-dir"))
(is (contains-bold? output "--repo"))
(is (contains-bold? output "--rtc-ws-url"))
(is (contains-bold? output "--log-level"))))
(deftest db-worker-node-repo-error-handles-keyword-methods
(let [repo-error #'db-worker-node/repo-error
@@ -222,233 +241,233 @@
(deftest db-worker-node-daemon-smoke-test
(async done
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-daemon")
repo (str "logseq_db_smoke_" (subs (str (random-uuid)) 0 8))
now (js/Date.now)
page-uuid (random-uuid)
block-uuid (random-uuid)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon!
{:data-dir data-dir
:repo repo})
health (http-get host port "/healthz")
ready (http-get host port "/readyz")
_ (do
(reset! daemon {:host host :port port :stop! stop!})
(println "[db-worker-node-test] daemon started" {:host host :port port})
(println "[db-worker-node-test] /healthz" health)
(is (= 200 (:status health)))
(println "[db-worker-node-test] /readyz" ready)
(is (= 200 (:status ready)))
(println "[db-worker-node-test] repo" repo))
_ (invoke host port "thread-api/create-or-open-db" [repo {}])
dbs (invoke host port "thread-api/list-db" [])
_ (do
(println "[db-worker-node-test] list-db" dbs)
(is (some #(= repo (:name %)) dbs)))
lock-file (lock-path data-dir repo)
_ (is (fs/existsSync lock-file))
lock-contents (js/JSON.parse (.toString (fs/readFileSync lock-file) "utf8"))
_ (is (= repo (gobj/get lock-contents "repo")))
_ (is (= host (gobj/get lock-contents "host")))
_ (invoke host port "thread-api/transact"
[repo
[{:block/uuid page-uuid
:block/title "Smoke Page"
:block/name "smoke-page"
:block/tags #{:logseq.class/Page}
:block/created-at now
:block/updated-at now}
{:block/uuid block-uuid
:block/title "Smoke Test"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]
:block/order "a0"
:block/created-at now
:block/updated-at now}]
{}
nil])
result (invoke host port "thread-api/q"
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-daemon")
repo (str "logseq_db_smoke_" (subs (str (random-uuid)) 0 8))
now (js/Date.now)
page-uuid (random-uuid)
block-uuid (random-uuid)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon!
{:data-dir data-dir
:repo repo})
health (http-get host port "/healthz")
ready (http-get host port "/readyz")
_ (do
(reset! daemon {:host host :port port :stop! stop!})
(println "[db-worker-node-test] daemon started" {:host host :port port})
(println "[db-worker-node-test] /healthz" health)
(is (= 200 (:status health)))
(println "[db-worker-node-test] /readyz" ready)
(is (= 200 (:status ready)))
(println "[db-worker-node-test] repo" repo))
_ (invoke host port "thread-api/create-or-open-db" [repo {}])
dbs (invoke host port "thread-api/list-db" [])
_ (do
(println "[db-worker-node-test] list-db" dbs)
(is (some #(= repo (:name %)) dbs)))
lock-file (lock-path data-dir repo)
_ (is (fs/existsSync lock-file))
lock-contents (js/JSON.parse (.toString (fs/readFileSync lock-file) "utf8"))
_ (is (= repo (gobj/get lock-contents "repo")))
_ (is (= host (gobj/get lock-contents "host")))
_ (invoke host port "thread-api/transact"
[repo
['[:find ?e
:in $ ?uuid
:where [?e :block/uuid ?uuid]]
block-uuid]])]
(println "[db-worker-node-test] q result" result)
(is (seq result)))
(p/catch (fn [e]
(println "[db-worker-node-test] e:" e)
(is false (str e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!)
(p/finally (fn []
(is (not (fs/existsSync (lock-path data-dir repo))))
(done))))
(done))))))))
[{:block/uuid page-uuid
:block/title "Smoke Page"
:block/name "smoke-page"
:block/tags #{:logseq.class/Page}
:block/created-at now
:block/updated-at now}
{:block/uuid block-uuid
:block/title "Smoke Test"
:block/page [:block/uuid page-uuid]
:block/parent [:block/uuid page-uuid]
:block/order "a0"
:block/created-at now
:block/updated-at now}]
{}
nil])
result (invoke host port "thread-api/q"
[repo
['[:find ?e
:in $ ?uuid
:where [?e :block/uuid ?uuid]]
block-uuid]])]
(println "[db-worker-node-test] q result" result)
(is (seq result)))
(p/catch (fn [e]
(println "[db-worker-node-test] e:" e)
(is false (str e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!)
(p/finally (fn []
(is (not (fs/existsSync (lock-path data-dir repo))))
(done))))
(done))))))))
(deftest db-worker-node-import-edn
(async done
(let [daemon-a (atom nil)
daemon-b (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-import-edn")
repo-a (str "logseq_db_import_edn_a_" (subs (str (random-uuid)) 0 8))
repo-b (str "logseq_db_import_edn_b_" (subs (str (random-uuid)) 0 8))
now (js/Date.now)
page-uuid (random-uuid)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-a})
_ (reset! daemon-a {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo-a {}])
_ (invoke host port "thread-api/transact"
[repo-a
[{:block/uuid page-uuid
:block/title "Import Page"
:block/name "import-page"
:block/tags #{:logseq.class/Page}
:block/created-at now
:block/updated-at now}]
{}
nil])
export-edn (invoke host port "thread-api/export-edn" [repo-a {:export-type :graph}])]
(is (map? export-edn))
(p/let [_ ((:stop! @daemon-a))
{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-b})
_ (reset! daemon-b {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo-b {}])
_ (invoke host port "thread-api/import-edn" [repo-b export-edn])
result (invoke host port "thread-api/q"
[repo-b
['[:find ?e
:in $ ?title
:where [?e :block/title ?title]]
"Import Page"]])]
(is (seq result))))
(p/catch (fn [e]
(println "[db-worker-node-test] import-edn error:" e)
(is false (str e))))
(p/finally (fn []
(let [stop-a (:stop! @daemon-a)
stop-b (:stop! @daemon-b)]
(cond
(and stop-a stop-b)
(-> (stop-a)
(p/finally (fn [] (-> (stop-b) (p/finally (fn [] (done)))))))
(let [daemon-a (atom nil)
daemon-b (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-import-edn")
repo-a (str "logseq_db_import_edn_a_" (subs (str (random-uuid)) 0 8))
repo-b (str "logseq_db_import_edn_b_" (subs (str (random-uuid)) 0 8))
now (js/Date.now)
page-uuid (random-uuid)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-a})
_ (reset! daemon-a {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo-a {}])
_ (invoke host port "thread-api/transact"
[repo-a
[{:block/uuid page-uuid
:block/title "Import Page"
:block/name "import-page"
:block/tags #{:logseq.class/Page}
:block/created-at now
:block/updated-at now}]
{}
nil])
export-edn (invoke host port "thread-api/export-edn" [repo-a {:export-type :graph}])]
(is (map? export-edn))
(p/let [_ ((:stop! @daemon-a))
{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-b})
_ (reset! daemon-b {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo-b {}])
_ (invoke host port "thread-api/import-edn" [repo-b export-edn])
result (invoke host port "thread-api/q"
[repo-b
['[:find ?e
:in $ ?title
:where [?e :block/title ?title]]
"Import Page"]])]
(is (seq result))))
(p/catch (fn [e]
(println "[db-worker-node-test] import-edn error:" e)
(is false (str e))))
(p/finally (fn []
(let [stop-a (:stop! @daemon-a)
stop-b (:stop! @daemon-b)]
(cond
(and stop-a stop-b)
(-> (stop-a)
(p/finally (fn [] (-> (stop-b) (p/finally (fn [] (done)))))))
stop-a
(-> (stop-a) (p/finally (fn [] (done))))
stop-a
(-> (stop-a) (p/finally (fn [] (done))))
stop-b
(-> (stop-b) (p/finally (fn [] (done))))
stop-b
(-> (stop-b) (p/finally (fn [] (done))))
:else
(done)))))))))
:else
(done)))))))))
(deftest db-worker-node-import-db-base64
(async done
(let [daemon-a (atom nil)
daemon-b (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-import-sqlite")
repo-a (str "logseq_db_import_sqlite_a_" (subs (str (random-uuid)) 0 8))
repo-b (str "logseq_db_import_sqlite_b_" (subs (str (random-uuid)) 0 8))
now (js/Date.now)
page-uuid (random-uuid)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-a})
_ (reset! daemon-a {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo-a {}])
_ (invoke host port "thread-api/transact"
[repo-a
[{:block/uuid page-uuid
:block/title "SQLite Import Page"
:block/name "sqlite-import-page"
:block/tags #{:logseq.class/Page}
:block/created-at now
:block/updated-at now}]
{}
nil])
export-base64 (invoke host port "thread-api/export-db-base64" [repo-a])]
(is (string? export-base64))
(is (pos? (count export-base64)))
(p/let [_ ((:stop! @daemon-a))
{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-b})
_ (reset! daemon-b {:stop! stop!})
_ (invoke host port "thread-api/import-db-base64" [repo-b export-base64])
_ (invoke host port "thread-api/create-or-open-db" [repo-b {}])
result (invoke host port "thread-api/q"
[repo-b
['[:find ?e
:in $ ?title
:where [?e :block/title ?title]]
"SQLite Import Page"]])]
(is (seq result))))
(p/catch (fn [e]
(println "[db-worker-node-test] import-sqlite error:" e)
(is false (str e))))
(p/finally (fn []
(let [stop-a (:stop! @daemon-a)
stop-b (:stop! @daemon-b)]
(cond
(and stop-a stop-b)
(-> (stop-a)
(p/finally (fn [] (-> (stop-b) (p/finally (fn [] (done)))))))
(let [daemon-a (atom nil)
daemon-b (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-import-sqlite")
repo-a (str "logseq_db_import_sqlite_a_" (subs (str (random-uuid)) 0 8))
repo-b (str "logseq_db_import_sqlite_b_" (subs (str (random-uuid)) 0 8))
now (js/Date.now)
page-uuid (random-uuid)]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-a})
_ (reset! daemon-a {:stop! stop!})
_ (invoke host port "thread-api/create-or-open-db" [repo-a {}])
_ (invoke host port "thread-api/transact"
[repo-a
[{:block/uuid page-uuid
:block/title "SQLite Import Page"
:block/name "sqlite-import-page"
:block/tags #{:logseq.class/Page}
:block/created-at now
:block/updated-at now}]
{}
nil])
export-base64 (invoke host port "thread-api/export-db-base64" [repo-a])]
(is (string? export-base64))
(is (pos? (count export-base64)))
(p/let [_ ((:stop! @daemon-a))
{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo-b})
_ (reset! daemon-b {:stop! stop!})
_ (invoke host port "thread-api/import-db-base64" [repo-b export-base64])
_ (invoke host port "thread-api/create-or-open-db" [repo-b {}])
result (invoke host port "thread-api/q"
[repo-b
['[:find ?e
:in $ ?title
:where [?e :block/title ?title]]
"SQLite Import Page"]])]
(is (seq result))))
(p/catch (fn [e]
(println "[db-worker-node-test] import-sqlite error:" e)
(is false (str e))))
(p/finally (fn []
(let [stop-a (:stop! @daemon-a)
stop-b (:stop! @daemon-b)]
(cond
(and stop-a stop-b)
(-> (stop-a)
(p/finally (fn [] (-> (stop-b) (p/finally (fn [] (done)))))))
stop-a
(-> (stop-a) (p/finally (fn [] (done))))
stop-a
(-> (stop-a) (p/finally (fn [] (done))))
stop-b
(-> (stop-b) (p/finally (fn [] (done))))
stop-b
(-> (stop-b) (p/finally (fn [] (done))))
:else
(done)))))))))
:else
(done)))))))))
(deftest db-worker-node-repo-mismatch-test
(async done
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-repo-mismatch")
repo (str "logseq_db_mismatch_" (subs (str (random-uuid)) 0 8))
other-repo (str repo "_other")]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:host host :port port :stop! stop!})
{:keys [status body]} (invoke-raw host port "thread-api/create-or-open-db" [other-repo {}])
parsed (js->clj (js/JSON.parse body) :keywordize-keys true)]
(is (= 409 status))
(is (= false (:ok parsed)))
(is (= "repo-mismatch" (get-in parsed [:error :code]))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-repo-mismatch")
repo (str "logseq_db_mismatch_" (subs (str (random-uuid)) 0 8))
other-repo (str repo "_other")]
(-> (p/let [{:keys [host port stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:host host :port port :stop! stop!})
{:keys [status body]} (invoke-raw host port "thread-api/create-or-open-db" [other-repo {}])
parsed (js->clj (js/JSON.parse body) :keywordize-keys true)]
(is (= 409 status))
(is (= false (:ok parsed)))
(is (= "repo-mismatch" (get-in parsed [:error :code]))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))
(deftest db-worker-node-lock-prevents-multiple-daemons
(async done
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-lock")
repo (str "logseq_db_lock_" (subs (str (random-uuid)) 0 8))]
(-> (p/let [{:keys [stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:stop! stop!})]
(-> (db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
(p/then (fn [_]
(is false "expected lock error")))
(p/catch (fn [e]
(is (= :repo-locked (-> (ex-data e) :code)))))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))
(let [daemon (atom nil)
data-dir (node-helper/create-tmp-dir "db-worker-lock")
repo (str "logseq_db_lock_" (subs (str (random-uuid)) 0 8))]
(-> (p/let [{:keys [stop!]}
(db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
_ (reset! daemon {:stop! stop!})]
(-> (db-worker-node/start-daemon! {:data-dir data-dir
:repo repo})
(p/then (fn [_]
(is false "expected lock error")))
(p/catch (fn [e]
(is (= :repo-locked (-> (ex-data e) :code)))))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally (fn []
(if-let [stop! (:stop! @daemon)]
(-> (stop!) (p/finally (fn [] (done))))
(done))))))))

View File

@@ -5,12 +5,32 @@
[logseq.cli.command.show :as show-command]
[logseq.cli.commands :as commands]
[logseq.cli.server :as cli-server]
[logseq.cli.style :as style]
[logseq.cli.transport :as transport]
[promesa.core :as p]))
(defn- strip-ansi
[value]
(style/strip-ansi value))
(defn- contains-ansi?
[value]
(boolean (re-find style/ansi-pattern value)))
(defn- escape-regex
[value]
(let [pattern (js/RegExp. "[.*+?^${}()|[\\]\\\\]" "g")]
(string/replace value pattern "\\\\$&")))
(defn- contains-bold?
[value token]
(let [token (escape-regex token)
pattern (re-pattern (str "\\u001b\\[[0-9;]*m" token "\\u001b\\[[0-9;]*m"))]
(boolean (re-find pattern value))))
(defn- command-lines
[summary]
(let [lines (string/split-lines summary)
(let [lines (string/split-lines (strip-ansi summary))
section (if (some #{"Commands:"} lines) "Commands:" "Subcommands:")
start (inc (.indexOf lines section))
end (.indexOf lines "Global options:")
@@ -29,98 +49,157 @@
(deftest test-help-output
(testing "top-level help lists command groups"
(let [result (commands/parse-args ["--help"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["--help"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (not (string/includes? summary "--auth-token")))
(is (not (string/includes? summary "--retries")))
(is (string/includes? summary "Graph Inspect and Edit"))
(is (string/includes? summary "Graph Management"))
(is (string/includes? summary "list"))
(is (string/includes? summary "add"))
(is (string/includes? summary "remove"))
(is (string/includes? summary "move"))
(is (string/includes? summary "query"))
(is (string/includes? summary "show"))
(is (string/includes? summary "graph"))
(is (string/includes? summary "server"))))
(is (not (string/includes? plain-summary "--auth-token")))
(is (not (string/includes? plain-summary "--retries")))
(is (string/includes? plain-summary "Graph Inspect and Edit"))
(is (string/includes? plain-summary "Graph Management"))
(is (string/includes? plain-summary "list"))
(is (string/includes? plain-summary "add"))
(is (string/includes? plain-summary "remove"))
(is (string/includes? plain-summary "move"))
(is (string/includes? plain-summary "query"))
(is (string/includes? plain-summary "show"))
(is (string/includes? plain-summary "graph"))
(is (string/includes? plain-summary "server"))
(is (contains-bold? summary "list page"))
(is (contains-bold? summary "list tag"))
(is (contains-bold? summary "list property"))
(is (contains-bold? summary "add block"))
(is (contains-bold? summary "add page"))
(is (contains-bold? summary "remove"))
(is (contains-bold? summary "move"))
(is (contains-bold? summary "query"))
(is (contains-bold? summary "query list"))
(is (contains-bold? summary "show"))
(is (contains-bold? summary "graph list"))
(is (contains-bold? summary "graph create"))
(is (contains-bold? summary "server list"))
(is (contains-bold? summary "server start"))
(is (contains-bold? summary "--help"))
(is (contains-bold? summary "--repo"))
(is (re-find #"\u001b\[[0-9;]*mCommands\u001b\[[0-9;]*m:" summary))
(is (re-find #"\u001b\[[0-9;]*moptions\u001b\[[0-9;]*m:" summary))))
(testing "top-level help command list omits [options]"
(let [summary (:summary (commands/parse-args ["--help"]))
(let [summary (:summary (binding [style/*color-enabled?* true]
(commands/parse-args ["--help"])))
lines (command-lines summary)]
(is (seq lines))
(is (every? #(not (string/includes? % "[options]")) lines))))
(testing "top-level help separates global and command options"
(let [summary (:summary (commands/parse-args ["--help"]))]
(is (string/includes? summary "Global options:"))
(is (string/includes? summary "Command options:")))))
(let [summary (:summary (binding [style/*color-enabled?* true]
(commands/parse-args ["--help"])))
plain-summary (strip-ansi summary)]
(is (string/includes? plain-summary "Global options:"))
(is (string/includes? plain-summary "Command options:")))))
(deftest test-parse-args-help
(testing "graph group shows subcommands"
(let [result (commands/parse-args ["graph"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["graph"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? summary "graph list"))
(is (string/includes? summary "graph create"))
(is (string/includes? summary "graph export"))
(is (string/includes? summary "graph import"))))
(is (string/includes? plain-summary "graph list"))
(is (string/includes? plain-summary "graph create"))
(is (string/includes? plain-summary "graph export"))
(is (string/includes? plain-summary "graph import"))
(is (contains-bold? summary "graph list"))
(is (contains-bold? summary "graph create"))
(is (contains-bold? summary "graph export"))
(is (contains-bold? summary "graph import"))))
(testing "list group shows subcommands"
(let [result (commands/parse-args ["list"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["list"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? summary "list page"))
(is (string/includes? summary "list tag"))
(is (string/includes? summary "list property"))
(is (string/includes? summary "Global options:"))
(is (string/includes? summary "Command options:"))))
(is (string/includes? plain-summary "list page"))
(is (string/includes? plain-summary "list tag"))
(is (string/includes? plain-summary "list property"))
(is (contains-bold? summary "list page"))
(is (contains-bold? summary "list tag"))
(is (contains-bold? summary "list property"))
(is (string/includes? plain-summary "Global options:"))
(is (string/includes? plain-summary "Command options:"))))
(testing "add group shows subcommands"
(let [result (commands/parse-args ["add"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["add"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? summary "add block"))
(is (string/includes? summary "add page"))))
(is (string/includes? plain-summary "add block"))
(is (string/includes? plain-summary "add page"))
(is (contains-bold? summary "add block"))
(is (contains-bold? summary "add page"))))
(testing "remove command shows help"
(let [result (commands/parse-args ["remove" "--help"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["remove" "--help"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? summary "Usage: logseq remove"))
(is (string/includes? summary "Command options:"))))
(is (string/includes? plain-summary "Usage: logseq remove"))
(is (string/includes? plain-summary "Command options:"))
(is (contains-bold? summary "--id"))
(is (contains-bold? summary "--uuid"))
(is (contains-bold? summary "--page"))))
(testing "move command shows help"
(let [result (commands/parse-args ["move" "--help"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["move" "--help"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? summary "Usage: logseq move"))
(is (string/includes? summary "Command options:"))))
(is (string/includes? plain-summary "Usage: logseq move"))
(is (string/includes? plain-summary "Command options:"))
(is (contains-bold? summary "--id"))
(is (contains-bold? summary "--uuid"))
(is (contains-bold? summary "--target-id"))
(is (contains-bold? summary "--target-uuid"))))
(testing "server group shows subcommands"
(let [result (commands/parse-args ["server"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["server"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? summary "server list"))
(is (string/includes? summary "server start"))))
(is (string/includes? plain-summary "server list"))
(is (string/includes? plain-summary "server start"))
(is (contains-bold? summary "server list"))
(is (contains-bold? summary "server start"))))
(testing "query group shows subcommands"
(let [result (commands/parse-args ["query"])
summary (:summary result)]
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["query"]))
summary (:summary result)
plain-summary (strip-ansi summary)]
(is (true? (:help? result)))
(is (string/includes? summary "query list"))
(is (string/includes? summary "query"))))
(is (string/includes? plain-summary "query list"))
(is (string/includes? plain-summary "query"))
(is (contains-bold? summary "query list"))
(is (contains-bold? summary "query"))))
(testing "group help command list omits [options]"
(let [summary (:summary (commands/parse-args ["list"]))
(let [summary (:summary (binding [style/*color-enabled?* true]
(commands/parse-args ["list"])))
lines (command-lines summary)]
(is (seq lines))
(is (every? #(not (string/includes? % "[options]")) lines)))))
(deftest test-parse-args-help-alignment
(testing "graph group aligns subcommand columns"
(let [result (commands/parse-args ["graph"])
summary (:summary result)
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["graph"]))
summary (strip-ansi (:summary result))
subcommand-lines (let [lines (string/split-lines summary)
start (inc (.indexOf lines "Subcommands:"))]
(->> lines
@@ -134,8 +213,9 @@
(is (apply = desc-starts))))
(testing "list group aligns subcommand columns"
(let [result (commands/parse-args ["list"])
summary (:summary result)
(let [result (binding [style/*color-enabled?* true]
(commands/parse-args ["list"]))
summary (strip-ansi (:summary result))
subcommand-lines (let [lines (string/split-lines summary)
start (inc (.indexOf lines "Subcommands:"))]
(->> lines
@@ -208,12 +288,18 @@
:block/children [{:db/id 3
:block/title "Grandchild A1"}]}
{:db/id 4
:block/title "Child B"}]}}]
:block/title "Child B"}]}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (contains-ansi? output))
(is (string/includes? output (style/dim "├── ")))
(is (string/includes? output (style/dim "└── ")))
(is (string/includes? output (style/dim "│ ")))
(is (= (str "1 Root\n"
"2 ├── Child A\n"
"3 │ └── Grandchild A1\n"
"4 └── Child B")
(tree->text tree-data))))))
(strip-ansi output))))))
(deftest test-tree->text-aligns-mixed-id-widths
(testing "show tree text aligns glyph column with mixed-width ids"
@@ -225,12 +311,14 @@
:block/children [{:db/id 3
:block/title "Grand"}]}
{:db/id 1000
:block/title "Child B"}]}}]
:block/title "Child B"}]}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (= (str "7 Root\n"
"88 ├── Child A\n"
"3 │ └── Grand\n"
"1000 └── Child B")
(tree->text tree-data))))))
(strip-ansi output))))))
(deftest test-tree->text-multiline
(testing "show tree text renders multiline blocks under glyph column"
@@ -244,14 +332,16 @@
{:db/id 174
:block/title "block-line1\nblock-line2"}
{:db/id 175
:block/title "cccc"}]}}]
:block/title "cccc"}]}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (= (str "168 Jan 18th, 2026\n"
"169 ├── b1\n"
"173 ├── aaaxx\n"
"174 ├── block-line1\n"
" │ block-line2\n"
"175 └── cccc")
(tree->text tree-data))))))
(strip-ansi output))))))
(deftest test-tree->text-prefixes-status
(testing "show tree text prefixes status before block titles"
@@ -263,10 +353,14 @@
:block/children [{:db/id 2
:block/title "Child"
:logseq.property/status {:db/ident :logseq.property/status.canceled
:block/title "CANCELED"}}]}}]
:block/title "CANCELED"}}]}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (string/includes? output (style/bold "TODO")))
(is (string/includes? output (style/bold "CANCELED")))
(is (= (str "1 TODO Root\n"
"2 └── CANCELED Child")
(tree->text tree-data))))))
(strip-ansi output))))))
(deftest test-tree->text-status-multiline-alignment
(testing "show tree text keeps multiline alignment when status prefix is present"
@@ -276,11 +370,13 @@
:block/children [{:db/id 22
:block/title "line1\nline2"
:logseq.property/status {:db/ident :logseq.property/status.todo
:block/title "TODO"}}]}}]
:block/title "TODO"}}]}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (= (str "1 Root\n"
"22 └── TODO line1\n"
" line2")
(tree->text tree-data))))))
(strip-ansi output))))))
(deftest test-tree->text-linked-references-tree
(testing "show tree text renders linked references as trees with db/id in first column"
@@ -297,7 +393,10 @@
{:db/id 11
:block/title "Ref B"
:block/page {:db/id 101
:block/title "Page B"}}]}}]
:block/title "Page B"}}]}}
output (binding [style/*color-enabled?* true]
(tree->text-with-linked-refs tree-data))]
(is (re-find #"\u001b\[[0-9;]*mTODO" output))
(is (= (str "1 Root\n"
"\n"
"Linked References (2)\n"
@@ -306,7 +405,7 @@
"\n"
"101 Page B\n"
"11 └── Ref B")
(tree->text-with-linked-refs tree-data))))))
(strip-ansi output))))))
(deftest test-tree->text-appends-tags
(testing "show tree text appends block tags to content"
@@ -316,10 +415,30 @@
:block/children [{:db/id 2
:block/title "Child"
:block/tags [{:block/title "RTC"}
{:block/name "task"}]}]}}]
{:block/name "task"}]}]}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (string/includes? output (style/bold "#RTC")))
(is (string/includes? output (style/bold "#task")))
(is (= (str "1 Root\n"
"2 └── Child #RTC #task")
(tree->text tree-data))))))
(strip-ansi output))))))
(deftest test-tree->text-status-colors
(testing "show tree text uses green for DONE status"
(let [tree->text #'show-command/tree->text
tree-data {:root {:db/id 1
:block/title "Root"
:block/children [{:db/id 2
:block/title "Child"
:logseq.property/status {:db/ident :logseq.property/status.done
:block/title "DONE"}}]}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (string/includes? output (style/green "DONE")))
(is (= (str "1 Root\n"
"2 └── DONE Child")
(strip-ansi output))))))
(deftest test-tree->text-replaces-uuid-refs
(testing "show tree text replaces inline [[uuid]] with referenced block content recursively"
@@ -329,16 +448,22 @@
tree-data {:root {:db/id 1
:block/title (str "See [[" uuid "]]")}
:uuid->label {(string/lower-case uuid) (str "Target [[" nested "]]")
(string/lower-case nested) "Inner"}}]
(string/lower-case nested) "Inner"}}
output (binding [style/*color-enabled?* true]
(tree->text tree-data))]
(is (= (str "1 See [[Target [[Inner]]]]")
(tree->text tree-data))))))
(strip-ansi output))))))
(deftest test-help-tags-properties-identifiers
(testing "add help mentions tag and property identifiers"
(let [summary (:summary (commands/parse-args ["add" "block" "--help"]))]
(is (string/includes? summary "Identifiers can be id, :db/ident, or :block/title.")))
(let [summary (:summary (commands/parse-args ["add" "page" "--help"]))]
(is (string/includes? summary "Identifiers can be id, :db/ident, or :block/title.")))))
(let [summary (:summary (binding [style/*color-enabled?* true]
(commands/parse-args ["add" "block" "--help"])))]
(is (string/includes? (strip-ansi summary)
"Identifiers can be id, :db/ident, or :block/title.")))
(let [summary (:summary (binding [style/*color-enabled?* true]
(commands/parse-args ["add" "page" "--help"])))]
(is (string/includes? (strip-ansi summary)
"Identifiers can be id, :db/ident, or :block/title.")))))
(deftest test-show-json-edn-strips-block-uuid
(testing "show json/edn removes :block/uuid recursively while keeping :db/id"
@@ -623,7 +748,7 @@
(testing "show rejects invalid id edn"
(let [result (commands/parse-args ["show" "--id" "[1"])]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code]))))))
(is (= :invalid-options (get-in result [:error :code])))))
(testing "show rejects legacy page-name option"
(let [result (commands/parse-args ["show" "--page-name" "Home"])]
@@ -633,7 +758,7 @@
(testing "show rejects format option"
(let [result (commands/parse-args ["show" "--format" "json" "--page" "Home"])]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code])))))
(is (= :invalid-options (get-in result [:error :code]))))))
(deftest test-verb-subcommand-parse-query
(testing "query shows group help"

View File

@@ -1,6 +1,9 @@
(ns logseq.cli.format-test
(:require [cljs.test :refer [deftest is testing]]
[logseq.cli.format :as format]))
[clojure.string :as string]
[logseq.cli.command.show :as show-command]
[logseq.cli.format :as format]
[logseq.cli.style :as style]))
(deftest test-format-success
(testing "json output via output-format"
@@ -167,6 +170,61 @@
{:output-format nil})]
(is (= "Line 1\nLine 2" result)))))
(deftest test-human-output-show-styled-prefixes
(testing "show preserves styled status and tags in human output"
(let [tree->text #'show-command/tree->text
tree-data {:root {:db/id 1
:block/title "Root"
:block/children [{:db/id 2
:block/title "Child"
:logseq.property/status {:db/ident :logseq.property/status.todo
:block/title "TODO"}
:block/tags [{:block/title "TagA"}]}]}}
styled (binding [style/*color-enabled?* true]
(tree->text tree-data))
result (format/format-result {:status :ok
:command :show
:data {:message styled}}
{:output-format nil})]
(is (string/includes? result (style/bold "TODO")))
(is (string/includes? result (style/bold "#TagA")))
(is (= (str "1 Root\n"
"2 └── TODO Child #TagA")
(style/strip-ansi result))))))
(deftest test-human-output-show-preserves-styling
(testing "show returns styled text without stripping ANSI"
(let [tree->text #'show-command/tree->text
tree-data {:root {:db/id 1
:block/title "Root"
:block/children [{:db/id 2
:block/title "Child"}]}}
styled (binding [style/*color-enabled?* true]
(tree->text tree-data))
result (format/format-result {:status :ok
:command :show
:data {:message styled}}
{:output-format nil})]
(is (= styled result))
(is (re-find #"\u001b\[[0-9;]*m" result)))))
(deftest test-show-json-edn-output-ignores-styled-message
(testing "show json/edn outputs serialize data without ANSI styling"
(let [tree-data {:root {:db/id 1
:block/title "Root"}}
json-result (format/format-result {:status :ok
:command :show
:data tree-data}
{:output-format :json})
edn-result (format/format-result {:status :ok
:command :show
:data tree-data}
{:output-format :edn})]
(is (string/includes? json-result "\"root\""))
(is (string/includes? edn-result ":root"))
(is (not (re-find #"\u001b\[[0-9;]*m" json-result)))
(is (not (re-find #"\u001b\[[0-9;]*m" edn-result))))))
(deftest test-human-output-query
(testing "query renders raw result"
(let [result (format/format-result {:status :ok

View File

@@ -14,3 +14,14 @@
(p/catch (fn [e]
(is false (str "unexpected error: " e))
(done))))))
(deftest test-help-output-omits-command-list
(async done
(-> (p/let [result (cli-main/run! ["--help"] {:exit? false})
output (:output result)]
(is (= 0 (:exit-code result)))
(is (not (string/includes? output "Commands: list page"))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))
(done)))
(p/finally done))))

View File

@@ -0,0 +1,23 @@
(ns logseq.cli.style-test
(:require [cljs.test :refer [deftest is testing]]
[logseq.cli.style :as style]))
(deftest test-strip-ansi
(testing "strip-ansi removes ANSI sequences"
(is (= "Hello"
(style/strip-ansi "\u001b[1mHello\u001b[22m")))
(is (= "Hello"
(style/strip-ansi "\u001b[31mHello\u001b[39m")))))
(deftest test-style-disabled
(testing "style helpers return plain text when color is disabled"
(binding [style/*color-enabled?* false]
(is (= "Hi" (style/bold "Hi")))
(is (= "Hi" (style/dim "Hi")))
(is (= "Hi" (style/green "Hi"))))))
(deftest test-style-enabled
(testing "style helpers include ANSI when color is enabled"
(binding [style/*color-enabled?* true]
(is (not= "Hi" (style/bold "Hi")))
(is (re-find #"\u001b\[[0-9;]*m" (style/bold "Hi"))))))