Initial import of LogDarioSeq baseline

Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
(ns logseq.cli.e2e.cleanup-test
(:require [babashka.fs :as fs]
[clojure.test :refer [deftest is testing]]
[logseq.cli.e2e.cleanup :as cleanup]))
(deftest list-cli-e2e-db-worker-pids-filters-processes
(let [shell-fn (fn [& _]
{:exit 0
:out (str " 101 node /repo/dist/db-worker-node.js --repo graph-a --root-dir /tmp/logseq-cli-e2e-graph-a-123/graphs --owner-source cli\n"
" 202 node /repo/dist/db-worker-node.js --repo production --root-dir /tmp/production-graphs --owner-source cli\n"
" 303 node /repo/static/logseq-cli.js graph list\n"
" 404 /usr/bin/python3 background.py\n"
" 505 node /repo/static/db-worker-node.js --repo graph-b --root-dir /private/tmp/logseq-cli-e2e-graph-b-999/graphs --owner-source cli\n")
:err ""})]
(is (= [101 505]
(cleanup/list-cli-e2e-db-worker-pids {:shell-fn shell-fn}))))
(testing "throws when ps invocation fails"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Unable to scan processes"
(cleanup/list-cli-e2e-db-worker-pids {:shell-fn (fn [& _]
{:exit 1
:out ""
:err "permission denied"})})))))
(deftest cleanup-db-worker-processes-separates-killed-and-failed
(let [killed (atom [])
result (cleanup/cleanup-db-worker-processes! {:list-pids-fn (fn [] [11 22 33])
:kill-pid-fn (fn [pid]
(swap! killed conj pid)
(if (= pid 22)
:failed
:killed))})]
(is (= [11 22 33] (:found-pids result)))
(is (= [11 33] (:killed-pids result)))
(is (= [22] (:failed-pids result)))
(is (= [11 22 33] @killed))))
(deftest cleanup-db-worker-processes-dry-run-does-not-kill
(let [killed (atom [])
result (cleanup/cleanup-db-worker-processes! {:dry-run true
:list-pids-fn (fn [] [11 22])
:kill-pid-fn (fn [pid]
(swap! killed conj pid)
:killed)})]
(is (= [11 22] (:found-pids result)))
(is (true? (:dry-run? result)))
(is (= [11 22] (:would-kill-pids result)))
(is (= [] (:killed-pids result)))
(is (= [] (:failed-pids result)))
(is (= [] @killed))))
(deftest list-cli-e2e-db-sync-port-pids-filters-port-18080
(let [shell-fn (fn [& _]
{:exit 0
:out (str "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"
"node 111 me 13u IPv6 0x0 0t0 TCP *:18080 (LISTEN)\n"
"python 222 me 13u IPv4 0x0 0t0 TCP 127.0.0.1:18080 (LISTEN)\n"
"node 333 me 13u IPv6 0x0 0t0 TCP *:18081 (LISTEN)\n")
:err ""})]
(is (= [111 222]
(cleanup/list-cli-e2e-db-sync-port-pids {:shell-fn shell-fn}))))
(testing "returns empty when no listener exists"
(is (= []
(cleanup/list-cli-e2e-db-sync-port-pids {:shell-fn (fn [& _]
{:exit 1
:out ""
:err ""})}))))
(testing "throws when lsof invocation fails"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Unable to scan db-sync server port listeners"
(cleanup/list-cli-e2e-db-sync-port-pids {:shell-fn (fn [& _]
{:exit 1
:out ""
:err "permission denied"})})))))
(deftest cleanup-db-sync-port-processes-separates-killed-and-failed
(let [killed (atom [])
result (cleanup/cleanup-db-sync-port-processes! {:list-pids-fn (fn [] [44 55])
:kill-pid-fn (fn [pid]
(swap! killed conj pid)
(if (= pid 55)
:failed
:killed))})]
(is (= [44 55] (:found-pids result)))
(is (= [44] (:killed-pids result)))
(is (= [55] (:failed-pids result)))
(is (= [44 55] @killed))))
(deftest cleanup-db-sync-port-processes-dry-run-does-not-kill
(let [killed (atom [])
result (cleanup/cleanup-db-sync-port-processes! {:dry-run true
:list-pids-fn (fn [] [44])
:kill-pid-fn (fn [pid]
(swap! killed conj pid)
:killed)})]
(is (= [44] (:found-pids result)))
(is (true? (:dry-run? result)))
(is (= [44] (:would-kill-pids result)))
(is (= [] (:killed-pids result)))
(is (= [] (:failed-pids result)))
(is (= [] @killed))))
(deftest cleanup-temp-roots-removes-only-cli-e2e-temp-roots
(let [tmp-root (fs/create-temp-dir {:prefix "cleanup-e2e-test-"})
matching-root (fs/path tmp-root "logseq-cli-e2e-case-a-123")
another-matching-root (fs/path tmp-root "logseq-cli-e2e-case-b-456")
non-matching-root (fs/path tmp-root "other-case")]
(try
(fs/create-dirs (fs/path matching-root "graphs"))
(fs/create-dirs (fs/path matching-root "home" "logseq"))
(fs/create-dirs (fs/path another-matching-root "graphs"))
(fs/create-dirs (fs/path non-matching-root "graphs"))
(let [result (cleanup/cleanup-temp-roots! {:tmp-root (str tmp-root)})
expected-dirs (sort [(str matching-root)
(str another-matching-root)])]
(is (= expected-dirs
(sort (:found-dirs result))))
(is (= expected-dirs
(sort (:removed-dirs result))))
(is (empty? (:failed-dirs result)))
(is (false? (fs/exists? matching-root)))
(is (false? (fs/exists? another-matching-root)))
(is (true? (fs/exists? non-matching-root))))
(finally
(fs/delete-tree tmp-root)))))
(deftest cleanup-temp-roots-dry-run-does-not-delete
(let [deleted (atom [])
result (cleanup/cleanup-temp-roots! {:dry-run true
:list-dirs-fn (fn [] ["/tmp/logseq-cli-e2e-a"
"/tmp/logseq-cli-e2e-b"])
:delete-dir-fn (fn [dir]
(swap! deleted conj dir))})]
(is (= ["/tmp/logseq-cli-e2e-a"
"/tmp/logseq-cli-e2e-b"]
(:found-dirs result)))
(is (true? (:dry-run? result)))
(is (= ["/tmp/logseq-cli-e2e-a"
"/tmp/logseq-cli-e2e-b"]
(:would-remove-dirs result)))
(is (= [] (:removed-dirs result)))
(is (= [] (:failed-dirs result)))
(is (= [] @deleted))))

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "compare_graph_queries.py"
spec = importlib.util.spec_from_file_location("compare_graph_queries", MODULE_PATH)
compare_graph_queries = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(compare_graph_queries)
def test_parse_args_supports_repeated_queries(monkeypatch) -> None:
monkeypatch.setattr(
compare_graph_queries.sys,
"argv",
[
"compare_graph_queries.py",
"--cli",
"/tmp/logseq-cli.js",
"--graph",
"demo",
"--config-a",
"/tmp/a.edn",
"--root-dir-a",
"/tmp/a",
"--config-b",
"/tmp/b.edn",
"--root-dir-b",
"/tmp/b",
"--query",
"[:find ?x]",
"--query",
"[:find ?y]",
],
)
args = compare_graph_queries.parse_args()
assert args.root_dir_a == "/tmp/a"
assert args.root_dir_b == "/tmp/b"
assert args.query == ["[:find ?x]", "[:find ?y]"]
def test_main_batches_multiple_queries_in_one_process(tmp_path: Path) -> None:
left_queries = []
right_queries = []
printed = []
cli_path = tmp_path / "logseq-cli.js"
cli_path.write_text("// mock cli\n")
def fake_run_query(cli_path, config_path, root_dir, graph, query):
record = {
"cli_path": str(cli_path),
"config_path": str(config_path),
"root_dir": str(root_dir),
"graph": graph,
"query": query,
}
if str(config_path).endswith("a.edn"):
left_queries.append(record)
else:
right_queries.append(record)
return {
"payload": {"status": "ok"},
"result": [{"title": query}],
}
compare_graph_queries.run_query = fake_run_query
compare_graph_queries.parse_args = lambda: SimpleNamespace(
cli=str(cli_path),
graph="demo",
query=["[:find ?x]", "[:find ?y]"],
config_a="/tmp/a.edn",
root_dir_a="/tmp/a",
config_b="/tmp/b.edn",
root_dir_b="/tmp/b",
require_result=False,
)
compare_graph_queries.print = lambda value, **kwargs: printed.append(json.loads(value))
compare_graph_queries.main()
assert [item["query"] for item in left_queries] == ["[:find ?x]", "[:find ?y]"]
assert [item["query"] for item in right_queries] == ["[:find ?x]", "[:find ?y]"]
assert printed == [
{
"status": "ok",
"results": {
"[:find ?x]": [{"title": "[:find ?x]"}],
"[:find ?y]": [{"title": "[:find ?y]"}],
},
}
]

View File

@@ -0,0 +1,58 @@
(ns logseq.cli.e2e.coverage-test
(:require [clojure.test :refer [deftest is testing]]
[logseq.cli.e2e.coverage :as coverage]
[logseq.cli.e2e.manifests :as manifests]))
(def sample-inventory
{:excluded-command-prefixes ["sync" "login" "logout"]
:scopes {:global {:options ["--help" "--version"]}
:graph {:commands ["graph list" "graph create"]
:options ["--file" "--type"]}}})
(deftest reports-missing-commands-and-options
(let [cases [{:id "global-help"
:covers {:options {:global ["--help"]}}}
{:id "graph-list"
:covers {:commands ["graph list"]
:options {:graph ["--file"]}}}]
report (coverage/coverage-report sample-inventory cases)]
(is (= ["graph create"] (:missing-commands report)))
(is (= ["--version"] (get-in report [:missing-options :global])))
(is (= ["--type"] (get-in report [:missing-options :graph])))))
(deftest rejects-excluded-commands-in-inventory
(let [inventory (assoc-in sample-inventory [:scopes :bad :commands] ["login"])]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Excluded commands"
(coverage/validate-inventory! inventory)))))
(deftest rejects-excluded-commands-in-case-covers
(let [cases [{:id "bad-login"
:covers {:commands ["logout"]}}]]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Excluded commands"
(coverage/validate-cases! sample-inventory cases)))))
(deftest complete-coverage-is-recognized
(let [cases [{:id "global"
:covers {:options {:global ["--help" "--version"]}}}
{:id "graph-create"
:covers {:commands ["graph create"]
:options {:graph ["--type"]}}}
{:id "graph-list"
:covers {:commands ["graph list"]
:options {:graph ["--file"]}}}]
report (coverage/coverage-report sample-inventory cases)]
(is (coverage/complete? report))))
(deftest sync-suite-manifests-cover-mvp-commands
(let [inventory (manifests/load-inventory :sync)
cases (manifests/load-cases :sync)
covered-commands (set (mapcat #(get-in % [:covers :commands]) cases))
report (coverage/coverage-report inventory cases)]
(is (contains? covered-commands "sync upload"))
(is (contains? covered-commands "sync download"))
(is (contains? covered-commands "sync status"))
(is (coverage/complete? report))))

View File

@@ -0,0 +1,798 @@
(ns logseq.cli.e2e.main-test
(:require [babashka.cli :as cli]
[clojure.string :as string]
[clojure.test :refer [deftest is]]
[logseq.cli.e2e.cleanup :as cleanup]
[logseq.cli.e2e.main :as main]
[logseq.cli.e2e.manifests :as manifests]
[logseq.cli.e2e.sync-fixture :as sync-fixture]))
(def sample-cases
[{:id "global-help"
:cmds ["node static/logseq-cli.js --help"]
:covers {:options {:global ["--help"]}}
:tags [:global :smoke]}
{:id "graph-create"
:cmds ["node static/logseq-cli.js graph create --graph demo"]
:covers {:commands ["graph create"]
:options {:graph ["--type"]}}
:tags [:graph]}
{:id "graph-list"
:cmds ["node static/logseq-cli.js graph list"]
:covers {:commands ["graph list"]
:options {:graph ["--file"]}}
:tags [:graph :smoke]}])
(def complete-inventory
{:excluded-command-prefixes ["sync" "login" "logout"]
:scopes {:global {:options ["--help"]}
:graph {:commands ["graph create" "graph list"]
:options ["--type" "--file"]}}})
(def cli-parse-config
{:alias {:i :include
:h :help}
:spec {:jobs {:default 4}}
:coerce {:include []
:help :boolean
:dry-run :boolean
:skip-build :boolean
:verbose :boolean
:timings :boolean
:jobs :long}})
(deftest cli-opts-parses-jobs-as-integer
(is (= 3
(:jobs (cli/parse-opts ["--jobs" "3"] cli-parse-config)))))
(deftest cli-opts-defaults-jobs-to-four
(is (= 4
(:jobs (cli/parse-opts [] cli-parse-config)))))
(deftest cli-opts-rejects-non-integer-jobs
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Coerce failure"
(cli/parse-opts ["--jobs" "nope"] cli-parse-config))))
(deftest run-rejects-jobs-less-than-one
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"--jobs must be a positive integer"
(main/run! {:inventory complete-inventory
:cases sample-cases
:skip-build true
:jobs 0
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})}))))
(deftest run-non-sync-uses-parallel-runner-when-jobs-greater-than-one
(let [parallel-call (atom nil)
serial-called? (atom false)]
(with-redefs [main/run-selected-cases-in-parallel! (fn [selected-cases run-case run-command opts]
(reset! parallel-call {:case-ids (mapv :id selected-cases)
:run-case run-case
:run-command run-command
:jobs (:jobs opts)})
[{:id "global-help" :status :ok}
{:id "graph-list" :status :ok}])
main/run-selected-cases! (fn [& _]
(reset! serial-called? true)
(throw (ex-info "serial runner should not be used" {})))]
(let [result (main/run! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build true
:jobs 2
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result)))
(is (= ["global-help" "graph-list"] (:case-ids @parallel-call)))
(is (= 2 (:jobs @parallel-call)))
(is (false? @serial-called?))))))
(deftest run-sync-suite-uses-parallel-runner-when-jobs-greater-than-one
(let [parallel-call (atom nil)
serial-called? (atom false)
sync-inventory {:excluded-command-prefixes ["login" "logout"]
:scopes {:sync {:commands ["sync upload" "sync status"]
:options []}}}
sync-cases [{:id "sync-upload"
:cmds ["node static/logseq-cli.js sync upload"]
:covers {:commands ["sync upload"]}}
{:id "sync-status"
:cmds ["node static/logseq-cli.js sync status"]
:covers {:commands ["sync status"]}}]]
(with-redefs [main/run-selected-cases-in-parallel! (fn [selected-cases run-case run-command opts]
(reset! parallel-call {:case-ids (mapv :id selected-cases)
:run-case run-case
:run-command run-command
:jobs (:jobs opts)})
(mapv (fn [case]
{:id (:id case)
:status :ok})
selected-cases))
main/run-selected-cases! (fn [& _]
(reset! serial-called? true)
(throw (ex-info "serial runner should not be used" {})))]
(let [result (main/run! {:suite :sync
:inventory sync-inventory
:cases sync-cases
:skip-build true
:jobs 4
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result)))
(is (= ["sync-upload" "sync-status"] (:case-ids @parallel-call)))
(is (= 4 (:jobs @parallel-call)))
(is (false? @serial-called?))))))
(deftest run-jobs-one-keeps-serial-runner
(let [serial-call (atom nil)
parallel-called? (atom false)]
(with-redefs [main/run-selected-cases! (fn [selected-cases run-case run-command opts]
(reset! serial-call {:case-ids (mapv :id selected-cases)
:run-case run-case
:run-command run-command
:jobs (:jobs opts)})
(mapv (fn [case]
{:id (:id case)
:status :ok})
selected-cases))
main/run-selected-cases-in-parallel! (fn [& _]
(reset! parallel-called? true)
(throw (ex-info "parallel runner should not be used" {})))]
(let [result (main/run! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build true
:jobs 1
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result)))
(is (= ["global-help" "graph-list"] (:case-ids @serial-call)))
(is (= 1 (:jobs @serial-call)))
(is (false? @parallel-called?))))))
(deftest parallel-runner-collects-completions-before-rethrowing-failure
(let [started (atom [])
finished (atom [])
started-latch (java.util.concurrent.CountDownLatch. 2)
release-success (promise)
cases [{:id "global-help"}
{:id "graph-list"}]
error (try
(main/run-selected-cases-in-parallel!
cases
(fn [case _opts]
(swap! started conj (:id case))
(.countDown started-latch)
(.await started-latch)
(if (= "graph-list" (:id case))
(do
(swap! finished conj [:failed (:id case)])
(deliver release-success true)
(throw (ex-info "boom" {:id (:id case)})))
(do
@release-success
(swap! finished conj [:ok (:id case)])
{:id (:id case)
:status :ok})))
(fn [_]
{:exit 0
:out ""
:err ""})
{:jobs 2})
nil
(catch Exception ex
ex))]
(is (instance? Exception error))
(is (= #{"global-help" "graph-list"}
(set @started)))
(is (= #{[:ok "global-help"]
[:failed "graph-list"]}
(set @finished)))))
(deftest parallel-runner-elapsed-ms-starts-when-case-begins-running
(let [events (atom [])]
(main/run-selected-cases-in-parallel!
[{:id "slow-1"}
{:id "slow-2"}
{:id "fast-after-queue"}]
(fn [test-case _opts]
(clojure.core/case (:id test-case)
"slow-1" (Thread/sleep 180)
"slow-2" (Thread/sleep 180)
"fast-after-queue" (Thread/sleep 10))
{:id (:id test-case)
:status :ok})
(fn [_]
{:exit 0
:out ""
:err ""})
{:jobs 2
:on-case-success (fn [payload]
(swap! events conj [(:id (:result payload)) (:elapsed-ms payload)]))})
(let [elapsed-map (into {} @events)]
(is (< (get elapsed-map "fast-after-queue" 1000) 120)
"queued case should measure only its own execution time, not time spent waiting in the pool")
(is (>= (get elapsed-map "slow-1" 0) 150))
(is (>= (get elapsed-map "slow-2" 0) 150)))))
(deftest select-cases-supports-case-id
(is (= ["graph-create"]
(mapv :id (main/select-cases sample-cases {:case "graph-create"})))))
(deftest select-cases-supports-include-tags
(is (= ["global-help" "graph-list"]
(mapv :id (main/select-cases sample-cases {:include ["smoke"]})))))
(deftest select-cases-omits-skipped-cases
(let [cases (conj sample-cases
{:id "stale-fallback"
:cmds ["node static/logseq-cli.js debug pull --id 1"]
:skip "Requires a removed current graph fallback"
:covers {:commands ["debug pull"]}
:tags [:debug :smoke]})]
(is (= ["global-help" "graph-create" "graph-list"]
(mapv :id (main/select-cases cases {}))))
(is (= ["global-help" "graph-list"]
(mapv :id (main/select-cases cases {:include ["smoke"]}))))
(is (= []
(mapv :id (main/select-cases cases {:case "stale-fallback"}))))))
(deftest run-fails-on-missing-coverage-before-case-execution
(let [ran? (atom false)]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Missing coverage"
(main/run! {:inventory complete-inventory
:cases [{:id "global-help"
:cmds ["node static/logseq-cli.js --help"]
:covers {:options {:global ["--help"]}}}]
:skip-build true
:run-command (fn [_]
(reset! ran? true)
{:exit 0
:out ""
:err ""})})))
(is (false? @ran?))))
(deftest run-succeeds-when-coverage-is-complete
(let [result (main/run! {:inventory complete-inventory
:cases [{:id "global-help"
:cmds ["node static/logseq-cli.js --help"]
:covers {:options {:global ["--help"]}}}
{:id "graph-create"
:cmds ["node static/logseq-cli.js graph create --type markdown"]
:covers {:commands ["graph create"]
:options {:graph ["--type"]}}}
{:id "graph-list"
:cmds ["node static/logseq-cli.js graph list --file demo.edn"]
:covers {:commands ["graph list"]
:options {:graph ["--file"]}}}]
:skip-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})})]
(is (= :ok (:status result)))
(is (= 3 (count (:cases result))))))
(deftest targeted-run-skips-full-coverage-check
(let [executed (atom [])]
(let [result (main/run! {:inventory complete-inventory
:cases [{:id "global-help"
:cmds ["node static/logseq-cli.js --help"]
:covers {:options {:global ["--help"]}}}
{:id "graph-create"
:cmds ["node static/logseq-cli.js graph create --graph demo"]
:covers {:commands ["graph create"]
:options {:graph ["--type"]}}}]
:case "global-help"
:skip-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
(swap! executed conj (:id case))
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result)))
(is (= ["global-help"] @executed)))))
(deftest run-invokes-case-progress-hooks
(let [events (atom [])]
(main/run! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})
:on-case-start (fn [{:keys [index total case]}]
(swap! events conj [:start index total (:id case)]))
:on-case-success (fn [{:keys [index total result]}]
(swap! events conj [:ok index total (:id result)]))})
(is (= [[:start 1 2 "global-help"]
[:ok 1 2 "global-help"]
[:start 2 2 "graph-list"]
[:ok 2 2 "graph-list"]]
@events))))
(deftest run-loads-non-sync-suite-by-default
(let [suite-calls (atom [])]
(with-redefs [manifests/load-inventory (fn [suite]
(swap! suite-calls conj [:inventory suite])
complete-inventory)
manifests/load-cases (fn [suite]
(swap! suite-calls conj [:cases suite])
sample-cases)]
(let [result (main/run! {:skip-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result))))
(is (= [[:inventory :non-sync]
[:cases :non-sync]]
@suite-calls)))))
(deftest run-loads-sync-suite-when-explicit
(let [suite-calls (atom [])
sync-inventory {:excluded-command-prefixes ["login" "logout"]
:scopes {:sync {:commands ["sync upload"]
:options []}}}
sync-cases [{:id "sync-upload"
:cmds ["node static/logseq-cli.js sync upload"]
:covers {:commands ["sync upload"]}}]]
(with-redefs [manifests/load-inventory (fn [suite]
(swap! suite-calls conj [:inventory suite])
sync-inventory)
manifests/load-cases (fn [suite]
(swap! suite-calls conj [:cases suite])
sync-cases)]
(let [result (main/run! {:suite :sync
:skip-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result))))
(is (= [[:inventory :sync]
[:cases :sync]]
@suite-calls)))))
(deftest run-sync-suite-uses-suite-fixture-once-with-parallel-runner
(let [before-called (atom 0)
after-called (atom 0)
prepared-case-ids (atom [])
run-case-seen (atom [])
parallel-call (atom nil)
sync-inventory {:excluded-command-prefixes ["login" "logout"]
:scopes {:sync {:commands ["sync upload" "sync status"]
:options []}}}
sync-cases [{:id "sync-upload"
:cmds ["node static/logseq-cli.js sync upload"]
:covers {:commands ["sync upload"]}}
{:id "sync-status"
:cmds ["node static/logseq-cli.js sync status"]
:covers {:commands ["sync status"]}}]]
(with-redefs [sync-fixture/before-suite! (fn [_]
(swap! before-called inc)
{:suite :sync})
sync-fixture/prepare-case (fn [case _suite-context]
(swap! prepared-case-ids conj (:id case))
(assoc case :prepared? true))
sync-fixture/after-suite! (fn [_ _]
(swap! after-called inc))
main/run-selected-cases-in-parallel! (fn [selected-cases run-case run-command opts]
(reset! parallel-call {:case-ids (mapv :id selected-cases)
:jobs (:jobs opts)})
(mapv (fn [case]
(run-case case {:run-command run-command}))
selected-cases))]
(let [result (main/run! {:suite :sync
:inventory sync-inventory
:cases sync-cases
:skip-build true
:jobs 2
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
(swap! run-case-seen conj [(:id case) (:prepared? case)])
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result)))))
(is (= 1 @before-called))
(is (= 1 @after-called))
(is (= ["sync-upload" "sync-status"] (:case-ids @parallel-call)))
(is (= 2 (:jobs @parallel-call)))
(is (= ["sync-upload" "sync-status"] @prepared-case-ids))
(is (= [["sync-upload" true]
["sync-status" true]]
@run-case-seen))))
(deftest list-cases-defaults-to-non-sync
(let [selected-suite (atom nil)
output (with-out-str
(with-redefs [manifests/load-cases (fn [suite]
(reset! selected-suite suite)
[{:id "non-sync-case"}])]
(main/list-cases! {})))]
(is (= :non-sync @selected-suite))
(is (string/includes? output "non-sync-case"))))
(deftest list-sync-cases-uses-sync-suite
(let [selected-suite (atom nil)
output (with-out-str
(with-redefs [manifests/load-cases (fn [suite]
(reset! selected-suite suite)
[{:id "sync-case"}])]
(main/list-sync-cases! {})))]
(is (= :sync @selected-suite))
(is (string/includes? output "sync-case"))))
(deftest test-prints-progress-and-summary
(let [output (with-out-str
(main/test! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok
:cmd (last (:cmds case))})}))]
(is (string/includes? output "==> Running cli-e2e cases"))
(is (string/includes? output "==> Build preflight: running..."))
(is (string/includes? output "==> Build preflight: skipped (--skip-build)"))
(is (string/includes? output "==> Prepared 2 case(s), starting execution"))
(is (string/includes? output "[1/2] ▶ global-help"))
(is (string/includes? output "[1/2] ✓ global-help"))
(is (string/includes? output "[2/2] ▶ graph-list"))
(is (string/includes? output "[2/2] ✓ graph-list"))
(is (string/includes? output "Summary: 2 passed, 0 failed"))))
(deftest test-parallel-output-omits-meaningless-index-prefixes
(let [output (with-out-str
(main/test! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build true
:jobs 2
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
(Thread/sleep (if (= "global-help" (:id case)) 25 5))
{:id (:id case)
:status :ok})}))]
(is (string/includes? output "▶ global-help"))
(is (string/includes? output "✓ global-help"))
(is (string/includes? output "✓ graph-list"))
(is (not (string/includes? output "[1/2]")))
(is (not (string/includes? output "[2/2]")))))
(deftest test-timings-prints-step-details-and-slow-summary
(let [output (with-out-str
(main/test! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build true
:timings true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
(if (= "global-help" (:id case))
{:id "global-help"
:status :ok
:timings [{:phase :setup
:step-index 1
:step-total 1
:elapsed-ms 12
:status :ok
:cmd "setup-global"}
{:phase :main
:step-index 1
:step-total 1
:elapsed-ms 55
:status :ok
:cmd "main-global"}]}
{:id "graph-list"
:status :ok
:timings [{:phase :main
:step-index 1
:step-total 1
:elapsed-ms 210
:status :ok
:cmd "main-graph-list"}]}))}))]
(is (string/includes? output "==> Step timing enabled (--timings)"))
(is (string/includes? output "step timings:"))
(is (string/includes? output "Slow steps (top 10):"))
(is (string/includes? output "main-graph-list"))))
(deftest test-without-timings-keeps-output-concise
(let [output (with-out-str
(main/test! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok
:timings [{:phase :main
:step-index 1
:step-total 1
:elapsed-ms 88
:status :ok
:cmd "hidden-step"}]})}))]
(is (not (string/includes? output "==> Step timing enabled (--timings)")))
(is (not (string/includes? output "step timings:")))
(is (not (string/includes? output "Slow steps (top 10):")))))
(deftest test-sync-timings-prints-step-details-and-slow-summary
(let [sync-inventory {:excluded-command-prefixes ["login" "logout"]
:scopes {:sync {:commands ["sync status"]
:options []}}}
sync-cases [{:id "sync-status-case"
:cmds ["node static/logseq-cli.js sync status"]
:covers {:commands ["sync status"]}}]
output (with-out-str
(main/test-sync! {:inventory sync-inventory
:cases sync-cases
:skip-build true
:timings true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok
:timings [{:phase :setup
:step-index 1
:step-total 1
:elapsed-ms 10
:status :ok
:cmd "sync-setup"}
{:phase :main
:step-index 1
:step-total 1
:elapsed-ms 150
:status :ok
:cmd "sync-main"}
{:phase :cleanup
:step-index 1
:step-total 1
:elapsed-ms 20
:status :ok
:cmd "sync-cleanup"}]})}))]
(is (string/includes? output "==> Running cli-e2e cases"))
(is (string/includes? output "==> Step timing enabled (--timings)"))
(is (string/includes? output "step timings:"))
(is (string/includes? output "Slow steps (top 10):"))
(is (string/includes? output "sync-main"))))
(deftest test-help-prints-usage-and-skips-execution
(let [ran? (atom false)
result (atom nil)
output (with-out-str
(reset! result
(main/test! {:help true
:run-command (fn [_]
(reset! ran? true)
{:exit 0
:out ""
:err ""})
:run-case (fn [_ _]
(reset! ran? true)
{:id "unexpected"
:status :ok})}))) ]
(is (= :help (:status @result)))
(is (false? @ran?))
(is (string/includes? output "Usage: bb -f cli-e2e/bb.edn test [options]"))
(is (string/includes? output "--skip-build"))
(is (not (string/includes? output "--force-build")))
(is (string/includes? output "--include TAG"))
(is (string/includes? output "--case ID"))
(is (string/includes? output "--jobs N"))
(is (string/includes? output "Run up to N non-sync cases in parallel"))
(is (string/includes? output "--skip-build --jobs 4"))
(is (string/includes? output "Default: 4"))
(is (string/includes? output "--timings"))
(is (not (string/includes? output "--e2ee-password")))))
(deftest test-sync-help-prints-usage-and-skips-execution
(let [ran? (atom false)
result (atom nil)
output (with-out-str
(reset! result
(main/test-sync! {:help true
:run-command (fn [_]
(reset! ran? true)
{:exit 0
:out ""
:err ""})
:run-case (fn [_ _]
(reset! ran? true)
{:id "unexpected"
:status :ok})})))]
(is (= :help (:status @result)))
(is (false? @ran?))
(is (string/includes? output "Usage: bb -f cli-e2e/bb.edn test-sync [options]"))
(is (string/includes? output "--skip-build"))
(is (not (string/includes? output "--force-build")))
(is (string/includes? output "--include TAG"))
(is (string/includes? output "--case ID"))
(is (string/includes? output "--jobs N"))
(is (string/includes? output "Run up to N sync cases in parallel"))
(is (string/includes? output "--jobs 4"))
(is (string/includes? output "bb -f cli-e2e/bb.edn test-sync"))
(is (string/includes? output "--case sync-upload-download-mvp"))
(is (not (string/includes? output "--skip-build --case sync-upload-download-mvp")))
(is (string/includes? output "Default: 4"))
(is (string/includes? output "--timings"))
(is (string/includes? output "--e2ee-password VALUE"))
(is (string/includes? output "Default: 11111"))))
(deftest run-does-not-pass-force-build-to-preflight
(let [preflight-call (atom nil)]
(with-redefs [logseq.cli.e2e.preflight/run! (fn [opts]
(reset! preflight-call opts)
{:status :ok
:commands []
:missing-artifacts []})]
(let [result (main/run! {:inventory complete-inventory
:cases sample-cases
:include ["smoke"]
:skip-build false
:force-build true
:run-command (fn [_]
{:exit 0
:out ""
:err ""})
:run-case (fn [case _opts]
{:id (:id case)
:status :ok})})]
(is (= :ok (:status result)))
(is (not (contains? @preflight-call :force-build)))))))
(deftest test-single-case-enables-detailed-command-logging
(let [command-opts (atom nil)
output (with-out-str
(main/test! {:inventory complete-inventory
:cases [{:id "global-help"
:cmds ["node static/logseq-cli.js --help"]
:covers {:options {:global ["--help"]}}}]
:case "global-help"
:skip-build true
:run-command (fn [{:keys [cmd] :as opts}]
(reset! command-opts opts)
{:cmd cmd
:exit 0
:out "ok"
:err ""})
:run-case (fn [case {:keys [run-command]}]
(let [result (run-command {:cmd (first (:cmds case))
:phase :main
:step-index 1
:step-total 1})]
{:id (:id case)
:status :ok
:cmd (:cmd result)}) )}))]
(is (string/includes? output "==> Detailed case logging enabled (--case global-help)"))
(is (string/includes? output " [main 1/1] $ node static/logseq-cli.js --help"))
(is (true? (:stream-output? @command-opts)))))
(deftest cleanup-help-prints-usage
(let [output (with-out-str (main/cleanup! {:help true}))]
(is (string/includes? output "Usage: bb -f cli-e2e/bb.edn cleanup"))
(is (string/includes? output "Terminate cli-e2e db-worker-node processes"))
(is (string/includes? output "Terminate db-sync server listeners on port 18080"))
(is (string/includes? output "Remove cli-e2e temp roots"))
(is (string/includes? output "--dry-run"))))
(deftest cleanup-prints-summary-and-returns-status
(with-redefs [cleanup/cleanup-db-worker-processes! (fn [_] {:found-pids [101 202]
:killed-pids [101]
:failed-pids [202]})
cleanup/cleanup-db-sync-port-processes! (fn [_] {:found-pids [303]
:killed-pids [303]
:failed-pids []})
cleanup/cleanup-temp-roots! (fn [_] {:found-dirs ["/tmp/logseq-cli-e2e-a"
"/tmp/logseq-cli-e2e-b"]
:removed-dirs ["/tmp/logseq-cli-e2e-a"]
:failed-dirs ["/tmp/logseq-cli-e2e-b"]})]
(let [result (atom nil)
output (with-out-str
(reset! result (main/cleanup! {})))]
(is (= :ok (:status @result)))
(is (= [101] (get-in @result [:processes :killed-pids])))
(is (= [303] (get-in @result [:db-sync-port-processes :killed-pids])))
(is (= ["/tmp/logseq-cli-e2e-a"] (get-in @result [:temp-roots :removed-dirs])))
(is (string/includes? output "db-worker-node processes: found 2, killed 1, failed 1"))
(is (string/includes? output "db-sync server processes (port 18080): found 1, killed 1, failed 0"))
(is (string/includes? output "temp roots: found 2, removed 1, failed 1")))))
(deftest cleanup-dry-run-prints-summary-and-passes-option
(let [process-opts (atom nil)
db-sync-opts (atom nil)
dir-opts (atom nil)]
(with-redefs [cleanup/cleanup-db-worker-processes! (fn [opts]
(reset! process-opts opts)
{:dry-run? true
:found-pids [101 202]
:would-kill-pids [101 202]
:killed-pids []
:failed-pids []})
cleanup/cleanup-db-sync-port-processes! (fn [opts]
(reset! db-sync-opts opts)
{:dry-run? true
:found-pids [303]
:would-kill-pids [303]
:killed-pids []
:failed-pids []})
cleanup/cleanup-temp-roots! (fn [opts]
(reset! dir-opts opts)
{:dry-run? true
:found-dirs ["/tmp/logseq-cli-e2e-a"]
:would-remove-dirs ["/tmp/logseq-cli-e2e-a"]
:removed-dirs []
:failed-dirs []})]
(let [result (atom nil)
output (with-out-str
(reset! result (main/cleanup! {:dry-run true})))]
(is (= {:dry-run true} @process-opts))
(is (= {:dry-run true} @db-sync-opts))
(is (= {:dry-run true} @dir-opts))
(is (= :ok (:status @result)))
(is (true? (get-in @result [:processes :dry-run?])))
(is (true? (get-in @result [:db-sync-port-processes :dry-run?])))
(is (true? (get-in @result [:temp-roots :dry-run?])))
(is (string/includes? output "[dry-run] db-worker-node processes: found 2, would kill 2"))
(is (string/includes? output "[dry-run] db-sync server processes (port 18080): found 1, would kill 1"))
(is (string/includes? output "[dry-run] temp roots: found 1, would remove 1"))))))

View File

@@ -0,0 +1,225 @@
(ns logseq.cli.e2e.manifests-test
(:require [clojure.test :refer [deftest is testing]]
[logseq.cli.e2e.manifests :as manifests]))
(deftest load-cases-requires-new-map-format
(with-redefs [manifests/read-edn-file (fn [_]
[{:id "legacy-a"}
{:id "legacy-b"}])]
(let [error (try
(manifests/load-cases :non-sync)
nil
(catch clojure.lang.ExceptionInfo error
error))]
(is (some? error))
(is (re-find #"Invalid cli-e2e manifest format" (.getMessage error)))
(is (= "{:templates {...} :cases [...]}"
(:expected (ex-data error)))))))
(deftest load-cases-merges-templates-and-cases-by-rules
(with-redefs [manifests/read-edn-file
(fn [_]
{:templates
{:base {:setup ["setup-a"]
:cmds ["cmd-a"]
:cleanup ["cleanup-a"]
:tags [:base]
:vars {:nested {:left 1}
:only-base true}
:covers {:commands ["base-command"]
:options {:global ["--base"]}}
:expect {:stdout-json-paths {[:status] "ok"
[:data :base] 1}}
:graph "base-graph"}
:addon {:setup ["setup-b"]
:cmds ["cmd-b"]
:cleanup ["cleanup-b"]
:tags [:addon]
:vars {:nested {:right 2}}
:covers {:options {:graph ["--addon"]}}
:expect {:stdout-json-paths {[:data :addon] 2}}
:graph "addon-graph"}
:shared {:setup ["setup-shared"]
:cmds ["cmd-shared"]
:cleanup ["cleanup-shared"]
:tags [:shared]
:vars {:nested {:shared 9}}
:graph "shared-graph"}}
:cases
[{:id "single-parent"
:extends :base
:cmds ["cmd-case"]
:expect {:stdout-json-paths {[:data :case] 3}}
:graph "single-parent-graph"}
{:id "multi-parent"
:extends [:base :addon]
:setup ["setup-case"]
:cmds ["cmd-case"]
:cleanup ["cleanup-case"]
:tags [:case]
:vars {:nested {:leaf 3}
:only-case true}
:covers {:commands ["case-command"]
:options {:graph ["--case"]}}
:expect {:stdout-json-paths {[:data :case] 3}}
:graph "case-graph"}
{:id "negative-case"
:extends :shared
:expect {:exit 1
:stdout-json-paths {[:error :code] "boom"}}
:graph "negative-graph"}]})]
(let [[single-parent multi-parent negative-case] (manifests/load-cases :sync)]
(testing "supports :extends keyword"
(is (= "single-parent" (:id single-parent)))
(is (= ["cmd-a" "cmd-case"] (:cmds single-parent)))
(is (= "single-parent-graph" (:graph single-parent))))
(testing "append merge keys"
(is (= ["setup-a" "setup-b" "setup-case"] (:setup multi-parent)))
(is (= ["cmd-a" "cmd-b" "cmd-case"] (:cmds multi-parent)))
(is (= ["cleanup-a" "cleanup-b" "cleanup-case"] (:cleanup multi-parent)))
(is (= [:base :addon :case] (:tags multi-parent))))
(testing "deep merge keys"
(is (= {:nested {:left 1 :right 2 :leaf 3}
:only-base true
:only-case true}
(:vars multi-parent)))
(is (= {:commands ["case-command"]
:options {:global ["--base"]
:graph ["--case"]}}
(:covers multi-parent)))
(is (= {[:status] "ok"
[:data :base] 1
[:data :addon] 2
[:data :case] 3}
(get-in multi-parent [:expect :stdout-json-paths]))))
(testing "child expect overrides inherited maps when parent omits them"
(is (= {:exit 1
:stdout-json-paths {[:error :code] "boom"}}
(:expect negative-case))))
(testing "base templates can omit happy-path defaults"
(is (= ["setup-shared"] (:setup negative-case)))
(is (= ["cmd-shared"] (:cmds negative-case)))
(is (= ["cleanup-shared"] (:cleanup negative-case))))
(testing "scalar keys are overridden by child"
(is (= "case-graph" (:graph multi-parent)))))))
(deftest load-cases-detects-circular-template-inheritance-with-cycle-path
(with-redefs [manifests/read-edn-file (fn [_]
{:templates
{:a {:extends :b
:setup ["a"]}
:b {:extends :a
:setup ["b"]}}
:cases [{:id "cycle" :extends :a}]})]
(let [error (try
(manifests/load-cases :sync)
nil
(catch clojure.lang.ExceptionInfo error
error))]
(is (some? error))
(is (re-find #"Circular template inheritance" (.getMessage error)))
(is (= [:a :b :a] (:cycle (ex-data error)))))))
(deftest load-cases-validates-extends-entries
(with-redefs [manifests/read-edn-file (fn [_]
{:templates {:base {:cmds ["base"]}}
:cases [{:id "invalid-extends"
:extends [:base "bad"]
:cmds ["case"]}]})]
(let [error (try
(manifests/load-cases :non-sync)
nil
(catch clojure.lang.ExceptionInfo error
error))]
(is (some? error))
(is (re-find #"Invalid :extends entries" (.getMessage error)))
(is (= ["bad"] (:invalid-entries (ex-data error)))))))
(deftest load-cases-lint-detects-invalid-extends-references
(with-redefs [manifests/read-edn-file (fn [_]
{:templates {:base {:cmds ["base"]}
:unused {:extends :missing-template
:cmds ["unused"]}}
:cases [{:id "valid" :extends :base :cmds ["case"]}
{:id "invalid" :extends :also-missing :cmds ["case"]}]})]
(let [error (try
(manifests/load-cases :sync)
nil
(catch clojure.lang.ExceptionInfo error
error))
issues (:issues (ex-data error))]
(is (some? error))
(is (re-find #"manifest lint failed" (.getMessage error)))
(is (= #{:also-missing :missing-template}
(set (map :target (filter #(= :invalid-extends (:type %)) issues))))))))
(deftest load-cases-lint-detects-duplicate-case-ids
(with-redefs [manifests/read-edn-file (fn [_]
{:templates {:base {:cmds ["base"]}}
:cases [{:id "dup" :extends :base :cmds ["case-a"]}
{:id "dup" :extends :base :cmds ["case-b"]}]})]
(let [error (try
(manifests/load-cases :non-sync)
nil
(catch clojure.lang.ExceptionInfo error
error))
duplicate-issues (filter #(= :duplicate-case-id (:type %))
(:issues (ex-data error)))]
(is (some? error))
(is (= [{:type :duplicate-case-id :id "dup" :count 2}]
(vec duplicate-issues))))))
(deftest load-cases-lint-detects-unused-templates
(with-redefs [manifests/read-edn-file (fn [_]
{:templates {:base {:cmds ["base"]}
:unused {:cmds ["unused"]}}
:cases [{:id "only" :extends :base :cmds ["case"]}]})]
(let [error (try
(manifests/load-cases :sync)
nil
(catch clojure.lang.ExceptionInfo error
error))
unused-issues (filter #(= :unused-template (:type %))
(:issues (ex-data error)))]
(is (some? error))
(is (= [{:type :unused-template :template :unused}]
(vec unused-issues))))))
(deftest sync-multi-batch-operations-uses-state-driven-waits-instead-of-fixed-sleeps
(let [cases (manifests/load-cases :sync)
multi-batch (some #(when (= "sync-multi-batch-operations" (:id %)) %) cases)
commands (:cmds multi-batch)
upload-commands (filter #(re-find #"sync upload --graph" %) commands)
wait-commands (filter #(re-find #"wait_sync_status\.py" %) commands)]
(is (some? multi-batch))
(is (not-any? #(= "sleep 1" %) commands))
(is (= 1 (count upload-commands)))
(is (= 5 (count wait-commands)))
(is (= 3 (count (filter #(re-find #"--root-dir '\{\{tmp-dir\}\}/graphs-b'.+--timeout-s 30 --interval-s 1" %) wait-commands))))))
(deftest sync-manifest-includes-duplicate-upload-negative-case
(let [cases (manifests/load-cases :sync)
duplicate-upload (some #(when (= "sync-upload-rejects-duplicate-remote-graph" (:id %)) %) cases)
json-paths (get-in duplicate-upload [:expect :stdout-json-paths])
setup-commands (:setup duplicate-upload)
main-commands (:cmds duplicate-upload)]
(is (some? duplicate-upload))
(is (= 1 (get-in duplicate-upload [:expect :exit])))
(is (= "graph-already-exists"
(get-in duplicate-upload [:expect :stdout-json-paths [:error :code]])))
(is (= ["delete it before uploading again"]
(get-in duplicate-upload [:expect :stdout-contains])))
(is (= 1 (count (filter #(re-find #"sync upload --graph" %) setup-commands))))
(is (= 1 (count (filter #(re-find #"sync upload --graph" %) main-commands))))
(is (false? (contains? json-paths [:data :pending-local])))
(is (false? (contains? json-paths [:data :pending-asset])))
(is (false? (contains? json-paths [:data :pending-server])))
(is (false? (contains? json-paths [:data :last-error])))))
(deftest sync-status-steady-state-does-not-repeat-identical-b-side-steady-state-waits
(let [cases (manifests/load-cases :sync)
steady-state (some #(when (= "sync-status-steady-state" (:id %)) %) cases)
steady-waits (filter #(re-find #"wait_sync_status\.py.+--root-dir '\{\{tmp-dir\}\}/graphs-b'.+--timeout-s 30 --interval-s 1" %) (:cmds steady-state))]
(is (some? steady-state))
(is (= 1 (count steady-waits)))
(is (= 1 (count (filter #(re-find #"sync status --graph" %) (:cmds steady-state)))))))

View File

@@ -0,0 +1,18 @@
(ns logseq.cli.e2e.paths-test
(:require [clojure.test :refer [deftest is]]
[logseq.cli.e2e.paths :as paths]))
(deftest repo-root-does-not-depend-on-runtime-file-binding
(let [expected (paths/repo-root)]
(binding [*file* nil]
(is (= expected
(paths/repo-root))))))
(deftest required-artifacts-include-cli-runtime-contract
(is (= [(paths/repo-path "cli" "_build" "default" "dist" "logseq-cli.js")
(paths/repo-path "static" "logseq-cli.js")
(paths/repo-path "static" "db-worker-node.js")
(paths/repo-path "dist" "db-worker-node.js")
(paths/repo-path "dist" "db-worker-node-assets.json")
(paths/repo-path "deps" "db-sync" "worker" "dist" "node-adapter.js")]
(paths/required-artifacts))))

View File

@@ -0,0 +1,98 @@
(ns logseq.cli.e2e.preflight-test
(:require [clojure.test :refer [deftest is testing]]
[logseq.cli.e2e.preflight :as preflight]))
(def required-artifacts
["/repo/cli/_build/default/dist/logseq-cli.js"
"/repo/static/logseq-cli.js"
"/repo/static/db-worker-node.js"
"/repo/dist/db-worker-node.js"
"/repo/dist/db-worker-node-assets.json"
"/repo/deps/db-sync/worker/dist/node-adapter.js"])
(defn- with-required-artifacts
[f]
(with-redefs [logseq.cli.e2e.paths/repo-root (constantly "/repo")
logseq.cli.e2e.paths/required-artifacts (fn [] required-artifacts)]
(f)))
(deftest build-plan-matches-required-commands
(is (= ["pnpm cli:release"
"pnpm db-worker-node:compile:bundle"
"pnpm --dir deps/db-sync build:node-adapter"]
(mapv :cmd preflight/build-plan)))
(is (not-any? #(re-find #"clojure -M:cljs compile logseq-cli" (:cmd %))
preflight/build-plan)))
(deftest missing-artifacts-returns-unreadable-paths
(let [artifacts ["/repo/static/logseq-cli.js"
"/repo/static/db-worker-node.js"
"/repo/dist/db-worker-node.js"]
present? #{"/repo/static/logseq-cli.js"}]
(is (= ["/repo/static/db-worker-node.js"
"/repo/dist/db-worker-node.js"]
(preflight/missing-artifacts artifacts present?)))))
(deftest skip-build-avoids-running-shell-commands
(let [called? (atom false)
result (preflight/run! {:skip-build true
:run-command (fn [_]
(reset! called? true))
:file-exists? (constantly false)})]
(is (= :skipped (:status result)))
(is (false? @called?))))
(deftest build-runs-commands-even-when-artifacts-are-ready
(let [calls (atom [])]
(with-required-artifacts
#(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}]
(swap! calls conj cmd)
{:cmd cmd
:exit 0
:out ""
:err ""})
:file-exists? (set required-artifacts)})]
(is (= :ok (:status result)))
(is (= ["pnpm cli:release"
"pnpm db-worker-node:compile:bundle"
"pnpm --dir deps/db-sync build:node-adapter"]
@calls))))))
(deftest build-runs-commands-when-artifacts-are-partially-present
(let [calls (atom [])
existing (atom (disj (set required-artifacts)
"/repo/dist/db-worker-node-assets.json"))]
(with-required-artifacts
#(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}]
(swap! calls conj cmd)
(reset! existing (set required-artifacts))
{:cmd cmd
:exit 0
:out ""
:err ""})
:file-exists? (fn [path]
(contains? @existing path))})]
(is (= :ok (:status result)))
(is (= ["pnpm cli:release"
"pnpm db-worker-node:compile:bundle"
"pnpm --dir deps/db-sync build:node-adapter"]
@calls))))))
(deftest build-runs-commands-when-artifacts-are-absent
(let [calls (atom [])
existing (atom #{})]
(with-required-artifacts
#(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}]
(swap! calls conj cmd)
(reset! existing (set required-artifacts))
{:cmd cmd
:exit 0
:out ""
:err ""})
:file-exists? (fn [path]
(contains? @existing path))})]
(is (= :ok (:status result)))
(is (= ["pnpm cli:release"
"pnpm db-worker-node:compile:bundle"
"pnpm --dir deps/db-sync build:node-adapter"]
@calls))))))

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "random_bidirectional_block_ops.py"
spec = importlib.util.spec_from_file_location("random_bidirectional_block_ops", MODULE_PATH)
random_bidirectional_block_ops = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = random_bidirectional_block_ops
spec.loader.exec_module(random_bidirectional_block_ops)
def test_default_profile_is_faster_than_high_stress(monkeypatch) -> None:
monkeypatch.setattr(
random_bidirectional_block_ops.sys,
"argv",
[
"random_bidirectional_block_ops.py",
"--cli",
"/tmp/logseq-cli.js",
"--graph",
"demo",
"--config-a",
"/tmp/a.edn",
"--root-dir-a",
"/tmp/a",
"--config-b",
"/tmp/b.edn",
"--root-dir-b",
"/tmp/b",
"--page",
"Home",
],
)
default_args = random_bidirectional_block_ops.parse_args()
monkeypatch.setattr(
random_bidirectional_block_ops.sys,
"argv",
[
"random_bidirectional_block_ops.py",
"--cli",
"/tmp/logseq-cli.js",
"--graph",
"demo",
"--config-a",
"/tmp/a.edn",
"--root-dir-a",
"/tmp/a",
"--config-b",
"/tmp/b.edn",
"--root-dir-b",
"/tmp/b",
"--page",
"Home",
"--profile",
"high-stress",
],
)
high_stress_args = random_bidirectional_block_ops.parse_args()
assert default_args.root_dir_a == "/tmp/a"
assert default_args.root_dir_b == "/tmp/b"
assert default_args.profile == "default"
assert high_stress_args.profile == "high-stress"
assert default_args.rounds_per_client < high_stress_args.rounds_per_client
def test_explicit_rounds_override_profile_default(monkeypatch) -> None:
monkeypatch.setattr(
random_bidirectional_block_ops.sys,
"argv",
[
"random_bidirectional_block_ops.py",
"--cli",
"/tmp/logseq-cli.js",
"--graph",
"demo",
"--config-a",
"/tmp/a.edn",
"--root-dir-a",
"/tmp/a",
"--config-b",
"/tmp/b.edn",
"--root-dir-b",
"/tmp/b",
"--page",
"Home",
"--profile",
"high-stress",
"--rounds-per-client",
"12",
],
)
args = random_bidirectional_block_ops.parse_args()
assert args.profile == "high-stress"
assert args.rounds_per_client == 12

View File

@@ -0,0 +1,152 @@
(ns logseq.cli.e2e.runner-test
(:require [clojure.test :refer [deftest is]]
[logseq.cli.e2e.runner :as runner]))
(deftest render-case-expands-template-values-recursively
(let [rendered (runner/render-case
{:id "graph-create"
:setup ["{{cli}} --graph {{graph-arg}}"]
:cmds ["{{cli}} graph info --graph {{graph-arg}}"]
:expect {:stdout-json-paths {[:data :graph] "{{graph}}"
[:status] "ok"}}}
{:cli "node /tmp/logseq-cli.js"
:graph "demo"
:graph-arg "'demo'"})]
(is (= ["node /tmp/logseq-cli.js --graph 'demo'"] (:setup rendered)))
(is (= ["node /tmp/logseq-cli.js graph info --graph 'demo'"] (:cmds rendered)))
(is (= "demo" (get-in rendered [:expect :stdout-json-paths [:data :graph]])))))
(deftest run-case-executes-setup-before-main-command
(let [calls (atom [])
result (runner/run-case!
{:id "graph-info"
:setup ["setup one" "setup two"]
:cmds ["main command one" "main command two"]
:expect {:exit 0}}
{:context {}
:run-command (fn [{:keys [cmd]}]
(swap! calls conj cmd)
{:cmd cmd
:exit 0
:out ""
:err ""})})]
(is (= ["setup one" "setup two" "main command one" "main command two"] @calls))
(is (= "graph-info" (:id result)))
(is (= "main command two" (get-in result [:result :cmd])))))
(deftest run-case-includes-command-phase-metadata-when-detailed
(let [calls (atom [])]
(runner/run-case!
{:id "graph-info"
:setup ["setup one" "setup two"]
:cmds ["main command one" "main command two"]
:cleanup ["cleanup one"]
:expect {:exit 0}}
{:context {}
:detailed-log? true
:run-command (fn [{:keys [cmd phase step-index step-total case-id throw?] :as opts}]
(swap! calls conj (select-keys opts [:cmd :phase :step-index :step-total :case-id :throw?]))
{:cmd cmd
:exit 0
:out ""
:err ""})})
(is (= [{:cmd "setup one" :phase :setup :step-index 1 :step-total 2 :case-id "graph-info" :throw? true}
{:cmd "setup two" :phase :setup :step-index 2 :step-total 2 :case-id "graph-info" :throw? true}
{:cmd "main command one" :phase :main :step-index 1 :step-total 2 :case-id "graph-info" :throw? true}
{:cmd "main command two" :phase :main :step-index 2 :step-total 2 :case-id "graph-info" :throw? false}
{:cmd "cleanup one" :phase :cleanup :step-index 1 :step-total 1 :case-id "graph-info" :throw? false}]
@calls))))
(deftest run-case-collects-step-timings-when-enabled
(let [result (runner/run-case!
{:id "graph-info"
:setup ["setup one"]
:cmds ["main command"]
:cleanup ["cleanup one"]
:expect {:exit 0}}
{:context {}
:timings? true
:run-command (fn [{:keys [cmd]}]
{:cmd cmd
:exit 0
:out ""
:err ""})})]
(is (= 3 (count (:timings result))))
(is (= [:setup :main :cleanup]
(mapv :phase (:timings result))))))
(deftest run-case-attaches-timings-to-error-when-enabled
(let [error (try
(runner/run-case!
{:id "graph-info"
:setup ["setup one"]
:cmds ["main command"]
:cleanup ["cleanup one"]
:expect {:exit 0}}
{:context {}
:timings? true
:run-command (fn [{:keys [cmd]}]
(when (= cmd "main command")
(throw (ex-info "boom" {:cmd cmd})))
{:cmd cmd
:exit 0
:out ""
:err ""})})
nil
(catch clojure.lang.ExceptionInfo error
error))
timings (:timings (ex-data error))]
(is (= "graph-info" (:case-id (ex-data error))))
(is (= [:setup :main :cleanup]
(mapv :phase timings)))
(is (= :failed (:status (second timings))))))
(deftest run-case-validates-json-paths-and-nonzero-exit
(let [result (runner/run-case!
{:id "invalid-shell"
:cmds ["node static/logseq-cli.js completion fish"]
:expect {:exit 1
:stdout-json-paths {[:status] "error"
[:error :code] "invalid-options"}}}
{:context {}
:run-command (fn [{:keys [cmd]}]
{:cmd cmd
:exit 1
:out "{\"status\":\"error\",\"error\":{\"code\":\"invalid-options\"}}"
:err ""})})]
(is (= 1 (get-in result [:result :exit])))))
(deftest assert-result-validates-edn-paths
(is (nil? (runner/assert-result!
{:id "graph-list-edn"
:expect {:exit 0
:stdout-edn-paths {[:status] :ok
[:data :graphs] ["demo"]}}}
{:cmd "node cli.js graph list"
:exit 0
:out "{:status :ok, :data {:graphs [\"demo\"]}}"
:err ""}))))
(deftest assert-result-validates-stdout-not-contains
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"stdout contained forbidden text"
(runner/assert-result!
{:id "remove-block"
:expect {:exit 0
:stdout-not-contains ["Alpha block"]}}
{:cmd "node cli.js show --page Home"
:exit 0
:out "Home\n- Alpha block"
:err ""}))))
(deftest assert-result-normalizes-json-escaped-windows-paths-for-contains
(is (nil? (runner/assert-result!
{:id "doctor-dev-script-json"
:expect {:exit 0
:stdout-contains ["static/db-worker-node.js"
"C:\\Users\\demo\\tmp\\graph-export.edn"]}}
{:cmd "node cli.js doctor --dev-script"
:exit 0
:out "{\"status\":\"ok\",\"data\":{\"path\":\"C:\\\\Users\\\\demo\\\\tmp\\\\graph-export.edn\",\"message\":\"Found readable file: C:\\\\Users\\\\demo\\\\project\\\\static\\\\db-worker-node.js\"}}"
:err ""}))))

View File

@@ -0,0 +1,54 @@
(ns logseq.cli.e2e.shell-test
(:require [babashka.process :as process]
[clojure.test :refer [deftest is testing]]
[logseq.cli.e2e.shell :as shell]))
(deftest preserves-the-exact-command-string
(let [captured (atom nil)
command "node static/logseq-cli.js --graph 'Graph With Space' --help"
result (shell/run! {:cmd command
:dir "/repo"
:executor (fn [cmd opts]
(reset! captured {:cmd cmd
:opts opts})
{:exit 0
:out "ok"
:err ""})})]
(is (= command (:cmd result)))
(is (= command (:cmd @captured)))
(is (= "/repo" (get-in @captured [:opts :dir])))))
(deftest shell-failures-include-command-context
(let [command "node static/logseq-cli.js completion fish"]
(try
(shell/run! {:cmd command
:executor (fn [_ _]
{:exit 2
:out ""
:err "unsupported shell"})})
(is false "Expected shell/run! to throw")
(catch clojure.lang.ExceptionInfo ex
(is (= command (:cmd (ex-data ex))))
(is (= 2 (:exit (ex-data ex))))
(is (= "unsupported shell" (:err (ex-data ex))))))))
(deftest allow-failure-returns-result-without-throwing
(let [command "node static/logseq-cli.js graph info --graph missing"
result (shell/run! {:cmd command
:throw? false
:executor (fn [_ _]
{:exit 1
:out "failed"
:err "missing"})})]
(is (= command (:cmd result)))
(is (= 1 (:exit result)))
(is (= "failed" (:out result)))
(is (= "missing" (:err result)))))
(deftest default-executor-uses-resolvable-shell-command
(let [captured (atom nil)]
(with-redefs [process/process (fn [_opts & args]
(reset! captured args)
(future {:exit 0}))]
(shell/run! {:cmd "echo ok"}))
(is (= ["bash" "-c" "echo ok"] @captured))))

View File

@@ -0,0 +1,184 @@
(ns logseq.cli.e2e.sync-fixture-test
(:require [babashka.fs :as fs]
[clojure.java.shell :as shell]
[clojure.string :as string]
[clojure.test :refer [deftest is testing]]
[logseq.cli.e2e.sync-fixture :as sync-fixture]))
(defn- free-port []
(with-open [socket (java.net.ServerSocket. 0)]
(.getLocalPort socket)))
(deftest prepare-sync-config-writes-oauth-token-endpoint
(let [tmp-dir (fs/create-temp-dir {:prefix "logseq-cli-sync-config-test-"})
auth-path (fs/path tmp-dir "auth.json")
config-path (fs/path tmp-dir "cli.edn")
script (fs/path "cli-e2e" "scripts" "prepare_sync_config.py")
result (do
(spit (str auth-path) "{\"refresh-token\":\"refresh-token\"}\n")
(shell/sh "python3" (str script)
"--output" (str config-path)
"--auth-path" (str auth-path)
"--http-base" "http://127.0.0.1:18080"
"--ws-url" "ws://127.0.0.1:18080/sync/%s"))]
(try
(is (= 0 (:exit result)) (:err result))
(let [config (slurp (str config-path))]
(is (string/includes? config ":http-base \"http://127.0.0.1:18080\""))
(is (string/includes? config ":ws-url \"ws://127.0.0.1:18080/sync/%s\""))
(is (string/includes? config ":oauth-token-endpoint "))
(is (string/includes? config "https://logseq-prod.auth.us-east-1.amazoncognito.com/oauth2/token")))
(finally
(fs/delete-tree tmp-dir)))))
(deftest prepare-case-injects-case-local-sync-resources
(let [suite-context {:sync-port "18080"
:sync-http-base "http://127.0.0.1:18080"
:sync-ws-url "ws://127.0.0.1:18080/sync/%s"}
input-case {:id "sync-case"
:vars {:existing true}
:setup ["mkdir -p '{{tmp-dir}}/graphs-b'"
"mkdir -p '{{tmp-dir}}/home/logseq'"
"cp ~/logseq/auth.json '{{tmp-dir}}/home/logseq/auth.json'"
"python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{config-path}}' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'"
"python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{tmp-dir}}/cli-b.edn' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'"
"python3 '{{repo-root}}/cli-e2e/scripts/db_sync_server.py' start --port 18080"
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"]
:cleanup ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"
"python3 '{{repo-root}}/cli-e2e/scripts/db_sync_server.py' stop --pid-file '{{tmp-dir}}/db-sync-server.pid'"]}
prepared (sync-fixture/prepare-case input-case suite-context)]
(is (= "sync-case" (:id prepared)))
(is (= ["mkdir -p '{{tmp-dir}}/graphs-b'"
"mkdir -p '{{tmp-dir}}/home/logseq'"
"cp ~/logseq/auth.json '{{tmp-dir}}/home/logseq/auth.json'"
"python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{config-path}}' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'"
"python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{tmp-dir}}/cli-b.edn' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'"
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"]
(:setup prepared)))
(is (= ["{{cli}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}"]
(:cleanup prepared)))
(is (= "http://127.0.0.1:18080" (get-in prepared [:vars :sync-http-base])))
(is (= "ws://127.0.0.1:18080/sync/%s" (get-in prepared [:vars :sync-ws-url])))
(is (= "18080" (get-in prepared [:vars :sync-port])))
(is (= "11111" (get-in prepared [:vars :e2ee-password])))
(is (= "11111" (get-in prepared [:vars :e2ee-password-arg])))
(is (not (contains? (:vars prepared) :suite-auth-path)))
(is (not (contains? (:vars prepared) :suite-config-path)))
(is (not-any? #(string/includes? % "suite-auth-path") (:setup prepared)))
(is (not-any? #(string/includes? % "suite-config-path") (:setup prepared)))
(is (not-any? #(string/includes? % "db_sync_server.py' start") (:setup prepared)))
(is (not-any? #(string/includes? % "db_sync_server.py' stop") (:cleanup prepared)))))
(deftest prepare-case-places-case-local-auth-before-cli-sync-commands
(let [suite-context {:suite-tmp-dir "/tmp/sync-suite"
:sync-port "18080"
:sync-http-base "http://127.0.0.1:18080"
:sync-ws-url "ws://127.0.0.1:18080/sync/%s"}
prepared (sync-fixture/prepare-case
{:id "sync-case"
:setup ["mkdir -p '{{tmp-dir}}/graphs-b'"
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}"]
:cleanup []}
suite-context)
setup (:setup prepared)
bootstrap-cmd (nth setup 5)]
(is (= "mkdir -p '{{tmp-dir}}/graphs-b'" (first setup)))
(is (= "mkdir -p '{{tmp-dir}}/home/logseq'" (second setup)))
(is (= "cp ~/logseq/auth.json '{{tmp-dir}}/home/logseq/auth.json'" (nth setup 2)))
(is (= "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{config-path}}' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'"
(nth setup 3)))
(is (= "python3 '{{repo-root}}/cli-e2e/scripts/prepare_sync_config.py' --output '{{tmp-dir}}/cli-b.edn' --auth-path '{{tmp-dir}}/home/logseq/auth.json' --http-base '{{sync-http-base}}' --ws-url '{{sync-ws-url}}'"
(nth setup 4)))
(is (string/includes? bootstrap-cmd "sync ensure-keys"))
(is (string/includes? bootstrap-cmd "--upload-keys"))
(is (string/includes? bootstrap-cmd "/tmp/sync-suite/user-rsa-keys.lock"))
(is (string/includes? bootstrap-cmd "/tmp/sync-suite/user-rsa-keys.ready"))
(is (= "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{graph-arg}} >/dev/null"
(nth setup 6)))
(is (= "{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}"
(nth setup 7)))
(is (not-any? #(and (string/includes? % "sync ensure-keys")
(not= % bootstrap-cmd)
(string/includes? % "--upload-keys"))
setup))))
(deftest prepare-case-accepts-custom-e2ee-password
(let [suite-context {:sync-port "18080"
:sync-http-base "http://127.0.0.1:18080"
:sync-ws-url "ws://127.0.0.1:18080/sync/%s"
:e2ee-password "pass word"}
prepared (sync-fixture/prepare-case {:id "sync-case"
:setup []
:cleanup []}
suite-context)]
(is (= "pass word" (get-in prepared [:vars :e2ee-password])))
(is (= "'pass word'" (get-in prepared [:vars :e2ee-password-arg])))))
(deftest before-and-after-suite-only-manage-shared-sync-server-lifecycle
(let [calls (atom [])
run-command (fn [opts]
(swap! calls conj opts)
{:exit 0
:out ""
:err ""})
suite-context (sync-fixture/before-suite! {:run-command run-command})]
(testing "before-suite only starts the shared server"
(is (= 1 (count @calls)))
(is (string/includes? (:cmd (first @calls)) "db_sync_server.py"))
(is (string/includes? (:cmd (first @calls)) " start "))
(is (string/includes? (:cmd (first @calls)) "--data-dir"))
(is (not (string/includes? (:cmd (first @calls)) "--root-dir")))
(is (string/includes? (:cmd (first @calls)) "--port 18080"))
(is (string/includes? (:cmd (first @calls)) "--startup-timeout-s 60"))
(is (not (string/includes? (:cmd (first @calls)) "prepare_sync_config.py")))
(is (not (contains? suite-context :suite-auth-path)))
(is (not (contains? suite-context :suite-config-path))))
(sync-fixture/after-suite! suite-context {:run-command run-command})
(testing "after-suite only stops the shared server once"
(is (= 2 (count @calls)))
(is (string/includes? (:cmd (last @calls)) "db_sync_server.py"))
(is (string/includes? (:cmd (last @calls)) " stop "))
(is (false? (:throw? (last @calls)))))))
(deftest db-sync-server-start-adds-db-sync-node-modules-to-node-path
(let [tmp-dir (fs/create-temp-dir {:prefix "logseq-cli-db-sync-server-test-"})
repo-root (fs/path tmp-dir "repo")
entry (fs/path repo-root "deps" "db-sync" "worker" "dist" "node-adapter.js")
node-modules (fs/path repo-root "deps" "db-sync" "node_modules")
pid-file (fs/path tmp-dir "server.pid")
log-file (fs/path tmp-dir "server.log")
data-dir (fs/path tmp-dir "server-data")
node-path-file (fs/path tmp-dir "node-path.txt")
script (fs/path "cli-e2e" "scripts" "db_sync_server.py")
port (free-port)]
(try
(fs/create-dirs (fs/parent entry))
(fs/create-dirs node-modules)
(spit (str entry)
(str "const fs = require('node:fs');\n"
"const http = require('node:http');\n"
"fs.writeFileSync(" (pr-str (str node-path-file)) ", process.env.NODE_PATH || '', 'utf8');\n"
"http.createServer((req, res) => {\n"
" if (req.url === '/health') { res.writeHead(200); res.end('ok'); return; }\n"
" res.writeHead(404); res.end('not found');\n"
"}).listen(process.env.DB_SYNC_PORT, '127.0.0.1');\n"))
(let [start (shell/sh "python3" (str script)
"start"
"--repo-root" (str repo-root)
"--pid-file" (str pid-file)
"--log-file" (str log-file)
"--data-dir" (str data-dir)
"--port" (str port)
"--startup-timeout-s" "5"
"--auth-path" "")]
(try
(is (= 0 (:exit start)) (:err start))
(is (string/includes? (slurp (str node-path-file)) (str node-modules)))
(finally
(shell/sh "python3" (str script)
"stop"
"--pid-file" (str pid-file)
"--shutdown-timeout-s" "2"))))
(finally
(fs/delete-tree tmp-dir)))))

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "wait_sync_status.py"
spec = importlib.util.spec_from_file_location("wait_sync_status", MODULE_PATH)
wait_sync_status = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(wait_sync_status)
def test_resolved_status_command_is_built_once_and_reused() -> None:
args = SimpleNamespace(
cli="~/repo/static/logseq-cli.js",
root_dir="~/tmp/root",
config="~/tmp/cli.edn",
graph="demo",
)
command = wait_sync_status.status_command(args)
assert command[0] == "node"
assert command[2] == "--root-dir"
assert command[-2:] == ["--graph", "demo"]
assert Path(command[1]).is_absolute()
assert Path(command[3]).is_absolute()
assert Path(command[5]).is_absolute()
assert wait_sync_status.status_command(args) is command
def test_run_status_uses_precomputed_command() -> None:
command = ["node", "/abs/cli.js", "--graph", "demo"]
args = SimpleNamespace(status_command=command)
calls = []
def fake_run(cmd, capture_output, text):
calls.append(cmd)
return SimpleNamespace(
returncode=0,
stdout='{"status":"ok","data":{"pending-local":0,"pending-asset":0,"pending-server":0}}',
stderr="",
)
original_run = wait_sync_status.subprocess.run
wait_sync_status.subprocess.run = fake_run
try:
payload = wait_sync_status.run_status(args)
finally:
wait_sync_status.subprocess.run = original_run
assert payload["status"] == "ok"
assert calls == [command]