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:
184
cli-e2e/src/logseq/cli/e2e/cleanup.clj
Normal file
184
cli-e2e/src/logseq/cli/e2e/cleanup.clj
Normal file
@@ -0,0 +1,184 @@
|
||||
(ns logseq.cli.e2e.cleanup
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.java.shell :as java-shell]
|
||||
[clojure.string :as string]))
|
||||
|
||||
(def ^:private cli-e2e-temp-prefix "logseq-cli-e2e-")
|
||||
(def ^:private db-sync-default-port 18080)
|
||||
|
||||
(defn- parse-ps-line
|
||||
[line]
|
||||
(when-let [[_ pid-str command] (re-matches #"\s*(\d+)\s+(.*)" (or line ""))]
|
||||
{:pid (Long/parseLong pid-str)
|
||||
:command command}))
|
||||
|
||||
(defn- cli-e2e-db-worker-command?
|
||||
[command]
|
||||
(and (re-find #"db-worker-node(?:\.js)?\b" command)
|
||||
(re-find #"logseq-cli-e2e-" command)))
|
||||
|
||||
(defn list-cli-e2e-db-worker-pids
|
||||
([]
|
||||
(list-cli-e2e-db-worker-pids {}))
|
||||
([{:keys [shell-fn]
|
||||
:or {shell-fn java-shell/sh}}]
|
||||
(let [{:keys [exit out err]} (shell-fn "ps" "-ax" "-o" "pid=" "-o" "command=")]
|
||||
(when-not (zero? exit)
|
||||
(throw (ex-info "Unable to scan processes"
|
||||
{:exit exit
|
||||
:err err})))
|
||||
(->> (string/split-lines (or out ""))
|
||||
(keep parse-ps-line)
|
||||
(filter (fn [{:keys [command]}]
|
||||
(cli-e2e-db-worker-command? command)))
|
||||
(mapv :pid)))))
|
||||
|
||||
(defn- pid-alive?
|
||||
[pid]
|
||||
(zero? (:exit (java-shell/sh "kill" "-0" (str pid)))))
|
||||
|
||||
(defn- kill-pid!
|
||||
[pid]
|
||||
(try
|
||||
(if-not (pid-alive? pid)
|
||||
:not-found
|
||||
(do
|
||||
(java-shell/sh "kill" "-TERM" (str pid))
|
||||
(Thread/sleep 100)
|
||||
(if-not (pid-alive? pid)
|
||||
:killed
|
||||
(do
|
||||
(java-shell/sh "kill" "-KILL" (str pid))
|
||||
(Thread/sleep 50)
|
||||
(if (pid-alive? pid)
|
||||
:failed
|
||||
:killed)))))
|
||||
(catch Exception _
|
||||
:failed)))
|
||||
|
||||
(defn cleanup-db-worker-processes!
|
||||
([]
|
||||
(cleanup-db-worker-processes! {}))
|
||||
([{:keys [dry-run list-pids-fn kill-pid-fn]}]
|
||||
(let [list-pids-fn (or list-pids-fn list-cli-e2e-db-worker-pids)
|
||||
kill-pid-fn (or kill-pid-fn kill-pid!)
|
||||
found-pids (vec (list-pids-fn))]
|
||||
(if dry-run
|
||||
{:dry-run? true
|
||||
:found-pids found-pids
|
||||
:would-kill-pids found-pids
|
||||
:killed-pids []
|
||||
:failed-pids []}
|
||||
(let [{:keys [killed-pids failed-pids]}
|
||||
(reduce (fn [acc pid]
|
||||
(if (= :failed (kill-pid-fn pid))
|
||||
(update acc :failed-pids conj pid)
|
||||
(update acc :killed-pids conj pid)))
|
||||
{:killed-pids []
|
||||
:failed-pids []}
|
||||
found-pids)]
|
||||
{:dry-run? false
|
||||
:found-pids found-pids
|
||||
:would-kill-pids []
|
||||
:killed-pids killed-pids
|
||||
:failed-pids failed-pids})))))
|
||||
|
||||
(defn- parse-long-safe
|
||||
[value]
|
||||
(try
|
||||
(Long/parseLong value)
|
||||
(catch Exception _
|
||||
nil)))
|
||||
|
||||
(defn list-cli-e2e-db-sync-port-pids
|
||||
([]
|
||||
(list-cli-e2e-db-sync-port-pids {}))
|
||||
([{:keys [shell-fn port]
|
||||
:or {shell-fn java-shell/sh
|
||||
port db-sync-default-port}}]
|
||||
(let [{:keys [exit out err]} (shell-fn "lsof" "-nP" (str "-iTCP:" port) "-sTCP:LISTEN")
|
||||
out-lines (->> (string/split-lines (or out ""))
|
||||
(map string/trim)
|
||||
(remove string/blank?)
|
||||
vec)]
|
||||
(when (and (not (zero? exit))
|
||||
(or (not (string/blank? err))
|
||||
(seq out-lines)))
|
||||
(throw (ex-info "Unable to scan db-sync server port listeners"
|
||||
{:exit exit
|
||||
:err err
|
||||
:port port})))
|
||||
(->> out-lines
|
||||
(filter #(re-find (re-pattern (str ":" port "\\b")) %))
|
||||
(keep (fn [line]
|
||||
(some-> (string/split line #"\s+")
|
||||
(nth 1 nil)
|
||||
parse-long-safe)))
|
||||
distinct
|
||||
vec))))
|
||||
|
||||
(defn cleanup-db-sync-port-processes!
|
||||
([]
|
||||
(cleanup-db-sync-port-processes! {}))
|
||||
([{:keys [dry-run list-pids-fn kill-pid-fn]
|
||||
:as _opts}]
|
||||
(let [list-pids-fn (or list-pids-fn list-cli-e2e-db-sync-port-pids)
|
||||
kill-pid-fn (or kill-pid-fn kill-pid!)
|
||||
found-pids (vec (list-pids-fn))]
|
||||
(if dry-run
|
||||
{:dry-run? true
|
||||
:found-pids found-pids
|
||||
:would-kill-pids found-pids
|
||||
:killed-pids []
|
||||
:failed-pids []}
|
||||
(let [{:keys [killed-pids failed-pids]}
|
||||
(reduce (fn [acc pid]
|
||||
(if (= :failed (kill-pid-fn pid))
|
||||
(update acc :failed-pids conj pid)
|
||||
(update acc :killed-pids conj pid)))
|
||||
{:killed-pids []
|
||||
:failed-pids []}
|
||||
found-pids)]
|
||||
{:dry-run? false
|
||||
:found-pids found-pids
|
||||
:would-kill-pids []
|
||||
:killed-pids killed-pids
|
||||
:failed-pids failed-pids})))))
|
||||
|
||||
(defn- list-cli-e2e-temp-roots
|
||||
[tmp-root]
|
||||
(->> (fs/list-dir tmp-root)
|
||||
(filter fs/directory?)
|
||||
(filter #(string/starts-with? (fs/file-name %) cli-e2e-temp-prefix))
|
||||
(mapv str)))
|
||||
|
||||
(defn cleanup-temp-roots!
|
||||
([]
|
||||
(cleanup-temp-roots! {}))
|
||||
([{:keys [dry-run tmp-root list-dirs-fn delete-dir-fn]
|
||||
:or {tmp-root (System/getProperty "java.io.tmpdir")
|
||||
delete-dir-fn fs/delete-tree}}]
|
||||
(let [list-dirs-fn (or list-dirs-fn
|
||||
#(list-cli-e2e-temp-roots tmp-root))
|
||||
found-dirs (vec (list-dirs-fn))]
|
||||
(if dry-run
|
||||
{:dry-run? true
|
||||
:found-dirs found-dirs
|
||||
:would-remove-dirs found-dirs
|
||||
:removed-dirs []
|
||||
:failed-dirs []}
|
||||
(let [{:keys [removed-dirs failed-dirs]}
|
||||
(reduce (fn [acc dir]
|
||||
(try
|
||||
(delete-dir-fn dir)
|
||||
(update acc :removed-dirs conj dir)
|
||||
(catch Exception _
|
||||
(update acc :failed-dirs conj dir))))
|
||||
{:removed-dirs []
|
||||
:failed-dirs []}
|
||||
found-dirs)]
|
||||
{:dry-run? false
|
||||
:found-dirs found-dirs
|
||||
:would-remove-dirs []
|
||||
:removed-dirs removed-dirs
|
||||
:failed-dirs failed-dirs})))))
|
||||
120
cli-e2e/src/logseq/cli/e2e/coverage.clj
Normal file
120
cli-e2e/src/logseq/cli/e2e/coverage.clj
Normal file
@@ -0,0 +1,120 @@
|
||||
(ns logseq.cli.e2e.coverage
|
||||
(:require [clojure.set :as set]
|
||||
[clojure.string :as string]))
|
||||
|
||||
(defn- normalize-commands
|
||||
[commands]
|
||||
(->> commands
|
||||
(map str)
|
||||
(remove string/blank?)
|
||||
distinct
|
||||
sort
|
||||
vec))
|
||||
|
||||
(defn- normalize-options
|
||||
[options]
|
||||
(->> options
|
||||
(map str)
|
||||
(remove string/blank?)
|
||||
distinct
|
||||
sort
|
||||
vec))
|
||||
|
||||
(defn- excluded-command?
|
||||
[excluded-prefixes command]
|
||||
(let [head (first (string/split (str command) #" "))]
|
||||
(contains? (set excluded-prefixes) head)))
|
||||
|
||||
(defn validate-inventory!
|
||||
[{:keys [excluded-command-prefixes scopes] :as inventory}]
|
||||
(let [invalid-commands (->> scopes
|
||||
vals
|
||||
(mapcat :commands)
|
||||
(filter #(excluded-command? excluded-command-prefixes %))
|
||||
normalize-commands)
|
||||
invalid-scopes (->> scopes
|
||||
(keep (fn [[scope {:keys [commands]}]]
|
||||
(when (some #(excluded-command? excluded-command-prefixes %) commands)
|
||||
scope)))
|
||||
sort
|
||||
vec)]
|
||||
(when (seq invalid-commands)
|
||||
(throw (ex-info "Excluded commands cannot appear in cli-e2e inventory"
|
||||
{:invalid-commands invalid-commands
|
||||
:invalid-scopes invalid-scopes
|
||||
:inventory inventory})))
|
||||
inventory))
|
||||
|
||||
(defn validate-cases!
|
||||
[{:keys [excluded-command-prefixes] :as inventory} cases]
|
||||
(let [invalid-cases (->> cases
|
||||
(filter (fn [{:keys [covers]}]
|
||||
(some #(excluded-command? excluded-command-prefixes %)
|
||||
(get covers :commands []))))
|
||||
(mapv :id))]
|
||||
(when (seq invalid-cases)
|
||||
(throw (ex-info "Excluded commands cannot be covered by cli-e2e cases"
|
||||
{:invalid-case-ids invalid-cases
|
||||
:inventory inventory})))
|
||||
cases))
|
||||
|
||||
(defn command->scope
|
||||
[{:keys [scopes]}]
|
||||
(into {}
|
||||
(mapcat (fn [[scope {:keys [commands]}]]
|
||||
(map (fn [command]
|
||||
[command scope])
|
||||
commands)))
|
||||
scopes))
|
||||
|
||||
(defn required-commands
|
||||
[{:keys [scopes]}]
|
||||
(->> scopes
|
||||
vals
|
||||
(mapcat :commands)
|
||||
normalize-commands))
|
||||
|
||||
(defn covered-commands
|
||||
[cases]
|
||||
(->> cases
|
||||
(mapcat #(get-in % [:covers :commands]))
|
||||
normalize-commands))
|
||||
|
||||
(defn required-options-by-scope
|
||||
[{:keys [scopes]}]
|
||||
(into {}
|
||||
(map (fn [[scope {:keys [options]}]]
|
||||
[scope (normalize-options options)]))
|
||||
scopes))
|
||||
|
||||
(defn covered-options-by-scope
|
||||
[cases]
|
||||
(reduce (fn [acc {:keys [covers]}]
|
||||
(reduce-kv (fn [acc' scope options]
|
||||
(update acc' scope (fnil into #{}) options))
|
||||
acc
|
||||
(get covers :options {})))
|
||||
{}
|
||||
cases))
|
||||
|
||||
(defn coverage-report
|
||||
[inventory cases]
|
||||
(validate-inventory! inventory)
|
||||
(validate-cases! inventory cases)
|
||||
(let [required-commands* (set (required-commands inventory))
|
||||
covered-commands* (set (covered-commands cases))
|
||||
covered-options* (covered-options-by-scope cases)
|
||||
required-options* (required-options-by-scope inventory)]
|
||||
{:missing-commands (->> (set/difference required-commands* covered-commands*)
|
||||
normalize-commands)
|
||||
:missing-options (into {}
|
||||
(map (fn [[scope options]]
|
||||
[scope (->> (set/difference (set options)
|
||||
(get covered-options* scope #{}))
|
||||
normalize-options)]))
|
||||
required-options*)}))
|
||||
|
||||
(defn complete?
|
||||
[{:keys [missing-commands missing-options]}]
|
||||
(and (empty? missing-commands)
|
||||
(every? empty? (vals missing-options))))
|
||||
468
cli-e2e/src/logseq/cli/e2e/main.clj
Normal file
468
cli-e2e/src/logseq/cli/e2e/main.clj
Normal file
@@ -0,0 +1,468 @@
|
||||
(ns logseq.cli.e2e.main
|
||||
(:require [clojure.set :as set]
|
||||
[logseq.cli.e2e.cleanup :as cleanup]
|
||||
[logseq.cli.e2e.coverage :as coverage]
|
||||
[logseq.cli.e2e.manifests :as manifests]
|
||||
[logseq.cli.e2e.preflight :as preflight]
|
||||
[logseq.cli.e2e.report :as report]
|
||||
[logseq.cli.e2e.runner :as runner]
|
||||
[logseq.cli.e2e.shell :as shell]
|
||||
[logseq.cli.e2e.sync-fixture :as sync-fixture])
|
||||
(:import (java.util.concurrent Executors LinkedBlockingQueue TimeUnit)))
|
||||
|
||||
(defn select-cases
|
||||
[cases {:keys [case include]}]
|
||||
(let [cases (remove :skip cases)]
|
||||
(cond
|
||||
case
|
||||
(filterv #(= case (:id %)) cases)
|
||||
|
||||
(seq include)
|
||||
(let [include-tags (set (map keyword include))]
|
||||
(filterv (fn [{:keys [tags]}]
|
||||
(not-empty (set/intersection include-tags (set tags))))
|
||||
cases))
|
||||
|
||||
:else
|
||||
(vec cases))))
|
||||
|
||||
(def default-suite :non-sync)
|
||||
(def default-jobs 1)
|
||||
(def default-cli-jobs 4)
|
||||
|
||||
(defn- suite-from-opts
|
||||
[opts]
|
||||
(or (:suite opts) default-suite))
|
||||
|
||||
(defn- elapsed-ms
|
||||
[started-at]
|
||||
(long (/ (- (System/nanoTime) started-at) 1000000)))
|
||||
|
||||
(defn- format-duration
|
||||
[started-at]
|
||||
(format "%.2fs" (/ (double (- (System/nanoTime) started-at)) 1000000000.0)))
|
||||
|
||||
(defn- positive-jobs
|
||||
[jobs]
|
||||
(let [jobs (or jobs default-jobs)]
|
||||
(when-not (and (integer? jobs) (pos? jobs))
|
||||
(throw (ex-info "--jobs must be a positive integer"
|
||||
{:jobs jobs})))
|
||||
jobs))
|
||||
|
||||
(defn run-selected-cases!
|
||||
[selected-cases run-case run-command {:keys [on-case-start on-case-success on-case-failure detailed-log? timings? jobs]}]
|
||||
(let [total (count selected-cases)
|
||||
_ (positive-jobs jobs)]
|
||||
(reduce (fn [acc [idx case]]
|
||||
(let [index (inc idx)
|
||||
started-at (System/nanoTime)]
|
||||
(when on-case-start
|
||||
(on-case-start {:index index
|
||||
:total total
|
||||
:case case}))
|
||||
(try
|
||||
(let [result (run-case case {:run-command run-command
|
||||
:detailed-log? detailed-log?
|
||||
:timings? timings?})
|
||||
payload {:index index
|
||||
:total total
|
||||
:case case
|
||||
:result result
|
||||
:elapsed-ms (elapsed-ms started-at)}]
|
||||
(when on-case-success
|
||||
(on-case-success payload))
|
||||
(conj acc result))
|
||||
(catch Exception error
|
||||
(when on-case-failure
|
||||
(on-case-failure {:index index
|
||||
:total total
|
||||
:case case
|
||||
:error error
|
||||
:elapsed-ms (elapsed-ms started-at)}))
|
||||
(throw error)))))
|
||||
[]
|
||||
(map-indexed vector selected-cases))))
|
||||
|
||||
(defn run-selected-cases-in-parallel!
|
||||
[selected-cases run-case run-command {:keys [on-case-start on-case-success on-case-failure detailed-log? timings? jobs]}]
|
||||
(let [total (count selected-cases)
|
||||
jobs (positive-jobs jobs)
|
||||
executor (Executors/newFixedThreadPool jobs)
|
||||
completions (LinkedBlockingQueue.)]
|
||||
(try
|
||||
(doseq [[idx case] (map-indexed vector selected-cases)]
|
||||
(let [index (inc idx)]
|
||||
(when on-case-start
|
||||
(on-case-start {:index index
|
||||
:total total
|
||||
:case case}))
|
||||
(.submit executor
|
||||
^Runnable
|
||||
(fn []
|
||||
(let [started-at (System/nanoTime)]
|
||||
(.put completions
|
||||
(try
|
||||
(let [result (run-case case {:run-command run-command
|
||||
:detailed-log? detailed-log?
|
||||
:timings? timings?})]
|
||||
{:index index
|
||||
:total total
|
||||
:case case
|
||||
:result result
|
||||
:elapsed-ms (elapsed-ms started-at)})
|
||||
(catch Exception error
|
||||
{:index index
|
||||
:total total
|
||||
:case case
|
||||
:error error
|
||||
:elapsed-ms (elapsed-ms started-at)}))))))))
|
||||
(loop [remaining total
|
||||
results []
|
||||
failure nil]
|
||||
(if (zero? remaining)
|
||||
(do
|
||||
(when failure
|
||||
(throw failure))
|
||||
(->> results
|
||||
(sort-by :index)
|
||||
(mapv :result)))
|
||||
(let [payload (.take completions)]
|
||||
(if-let [error (:error payload)]
|
||||
(do
|
||||
(when on-case-failure
|
||||
(on-case-failure payload))
|
||||
(recur (dec remaining) results (or failure error)))
|
||||
(do
|
||||
(when on-case-success
|
||||
(on-case-success payload))
|
||||
(recur (dec remaining) (conj results payload) failure))))))
|
||||
(finally
|
||||
(.shutdown executor)
|
||||
(.awaitTermination executor 1 TimeUnit/MINUTES)))))
|
||||
|
||||
(defn run!
|
||||
[{:keys [inventory cases skip-build run-command jobs]
|
||||
:as opts}]
|
||||
(let [run-command (or run-command shell/run!)
|
||||
run-case (or (:run-case opts) runner/run-case!)
|
||||
suite (suite-from-opts opts)
|
||||
sync-suite? (= suite :sync)
|
||||
jobs (positive-jobs jobs)
|
||||
targeted-run? (or (:case opts) (seq (:include opts)))
|
||||
on-preflight-start (:on-preflight-start opts)
|
||||
on-preflight-complete (:on-preflight-complete opts)
|
||||
on-cases-ready (:on-cases-ready opts)]
|
||||
(when on-preflight-start
|
||||
(on-preflight-start {:skip-build skip-build}))
|
||||
(let [preflight-result (preflight/run! {:skip-build skip-build
|
||||
:run-command run-command})
|
||||
inventory (or inventory (manifests/load-inventory suite))
|
||||
cases (or cases (manifests/load-cases suite))]
|
||||
(when on-preflight-complete
|
||||
(on-preflight-complete preflight-result))
|
||||
(let [selected-cases (select-cases cases opts)
|
||||
coverage-result (when-not targeted-run?
|
||||
(coverage/coverage-report inventory selected-cases))]
|
||||
(when (and coverage-result
|
||||
(not (coverage/complete? coverage-result)))
|
||||
(throw (ex-info "Missing coverage"
|
||||
{:coverage coverage-result
|
||||
:message (report/format-missing-coverage coverage-result)})))
|
||||
(when on-cases-ready
|
||||
(on-cases-ready {:total (count selected-cases)
|
||||
:targeted-run? targeted-run?}))
|
||||
(let [suite-context (when sync-suite?
|
||||
(sync-fixture/before-suite! {:run-command run-command}))
|
||||
sync-context (if suite-context
|
||||
(assoc suite-context :e2ee-password (:e2ee-password opts))
|
||||
suite-context)
|
||||
run-case* (if sync-suite?
|
||||
(fn [case case-opts]
|
||||
(run-case (sync-fixture/prepare-case case sync-context)
|
||||
case-opts))
|
||||
run-case)]
|
||||
(try
|
||||
{:status :ok
|
||||
:cases selected-cases
|
||||
:coverage coverage-result
|
||||
:results ((if (> jobs 1)
|
||||
run-selected-cases-in-parallel!
|
||||
run-selected-cases!)
|
||||
selected-cases
|
||||
run-case*
|
||||
run-command
|
||||
(assoc opts :jobs jobs))}
|
||||
(finally
|
||||
(when suite-context
|
||||
(sync-fixture/after-suite! suite-context {:run-command run-command})))))))))
|
||||
(defn build!
|
||||
[opts]
|
||||
(preflight/run! opts))
|
||||
|
||||
(defn list-cases!
|
||||
[opts]
|
||||
(doseq [{:keys [id]} (manifests/load-cases (suite-from-opts opts))]
|
||||
(println id)))
|
||||
|
||||
(defn list-sync-cases!
|
||||
[opts]
|
||||
(list-cases! (assoc opts :suite :sync)))
|
||||
|
||||
(defn- print-cleanup-help!
|
||||
[]
|
||||
(println "Usage: bb -f cli-e2e/bb.edn cleanup [options]")
|
||||
(println)
|
||||
(println "Options:")
|
||||
(println " -h, --help Show this help and exit")
|
||||
(println " --dry-run Scan and report only; do not kill/delete")
|
||||
(println)
|
||||
(println "Cleanups performed:")
|
||||
(println " - Terminate cli-e2e db-worker-node processes")
|
||||
(println " - Terminate db-sync server listeners on port 18080")
|
||||
(println " - Remove cli-e2e temp roots")
|
||||
(flush))
|
||||
|
||||
(defn cleanup!
|
||||
[opts]
|
||||
(if (:help opts)
|
||||
(do
|
||||
(print-cleanup-help!)
|
||||
{:status :help})
|
||||
(let [dry-run? (boolean (:dry-run opts))
|
||||
cleanup-opts (cond-> {}
|
||||
dry-run? (assoc :dry-run true))
|
||||
processes (cleanup/cleanup-db-worker-processes! cleanup-opts)
|
||||
db-sync-port-processes (cleanup/cleanup-db-sync-port-processes! cleanup-opts)
|
||||
temp-roots (cleanup/cleanup-temp-roots! cleanup-opts)]
|
||||
(println "==> Running cli-e2e cleanup")
|
||||
(if dry-run?
|
||||
(do
|
||||
(println (format "[dry-run] db-worker-node processes: found %d, would kill %d"
|
||||
(count (:found-pids processes))
|
||||
(count (:would-kill-pids processes))))
|
||||
(println (format "[dry-run] db-sync server processes (port 18080): found %d, would kill %d"
|
||||
(count (:found-pids db-sync-port-processes))
|
||||
(count (:would-kill-pids db-sync-port-processes))))
|
||||
(println (format "[dry-run] temp roots: found %d, would remove %d"
|
||||
(count (:found-dirs temp-roots))
|
||||
(count (:would-remove-dirs temp-roots)))))
|
||||
(do
|
||||
(println (format "db-worker-node processes: found %d, killed %d, failed %d"
|
||||
(count (:found-pids processes))
|
||||
(count (:killed-pids processes))
|
||||
(count (:failed-pids processes))))
|
||||
(println (format "db-sync server processes (port 18080): found %d, killed %d, failed %d"
|
||||
(count (:found-pids db-sync-port-processes))
|
||||
(count (:killed-pids db-sync-port-processes))
|
||||
(count (:failed-pids db-sync-port-processes))))
|
||||
(println (format "temp roots: found %d, removed %d, failed %d"
|
||||
(count (:found-dirs temp-roots))
|
||||
(count (:removed-dirs temp-roots))
|
||||
(count (:failed-dirs temp-roots))))))
|
||||
(flush)
|
||||
{:status :ok
|
||||
:dry-run? dry-run?
|
||||
:processes processes
|
||||
:db-sync-port-processes db-sync-port-processes
|
||||
:temp-roots temp-roots})))
|
||||
|
||||
(defn- print-failure-details!
|
||||
[error]
|
||||
(let [data (ex-data error)]
|
||||
(println (str " reason: " (.getMessage error)))
|
||||
(when-let [cmd (:cmd data)]
|
||||
(println (str " cmd: " cmd)))
|
||||
(when-let [stream (:stream data)]
|
||||
(println (str " stream: " stream)))
|
||||
(when-let [snippet (:snippet data)]
|
||||
(println (str " snippet: " snippet)))
|
||||
(flush)))
|
||||
|
||||
(defn- format-step-label
|
||||
[{:keys [phase step-index step-total]}]
|
||||
(let [phase-name (name (or phase :command))]
|
||||
(format "%s %d/%d" phase-name (or step-index 1) (or step-total 1))))
|
||||
|
||||
(defn- print-case-timings!
|
||||
[timings]
|
||||
(when (seq timings)
|
||||
(println " step timings:")
|
||||
(doseq [{:keys [elapsed-ms status cmd] :as timing} timings]
|
||||
(println (format " - [%-12s] %6dms (%s) $ %s"
|
||||
(format-step-label timing)
|
||||
elapsed-ms
|
||||
(name (or status :ok))
|
||||
cmd)))
|
||||
(flush)))
|
||||
|
||||
(defn- print-slow-steps!
|
||||
[all-step-timings]
|
||||
(when (seq all-step-timings)
|
||||
(println "Slow steps (top 10):")
|
||||
(doseq [{:keys [case-id elapsed-ms cmd] :as timing}
|
||||
(->> all-step-timings
|
||||
(sort-by :elapsed-ms >)
|
||||
(take 10))]
|
||||
(println (format " - %-45s [%-12s] %6dms $ %s"
|
||||
case-id
|
||||
(format-step-label timing)
|
||||
elapsed-ms
|
||||
cmd)))
|
||||
(flush)))
|
||||
|
||||
(defn- progress-prefix
|
||||
[{:keys [parallel? index total]} symbol]
|
||||
(if parallel?
|
||||
(str symbol " ")
|
||||
(format "[%d/%d] %s " index total symbol)))
|
||||
|
||||
(defn- print-test-help!
|
||||
[command-name suite]
|
||||
(let [sync-suite? (= suite :sync)]
|
||||
(println (str "Usage: bb -f cli-e2e/bb.edn " command-name " [options]"))
|
||||
(println)
|
||||
(println "Options:")
|
||||
(println " -h, --help Show this help and exit")
|
||||
(println " --skip-build Skip build preflight steps")
|
||||
(println " -i, --include TAG Run only cases with matching tag (repeatable)")
|
||||
(println " --case ID Run a single case by id")
|
||||
(println (format " --jobs N %s (Default: %d)"
|
||||
(if sync-suite?
|
||||
"Run up to N sync cases in parallel"
|
||||
"Run up to N non-sync cases in parallel")
|
||||
default-cli-jobs))
|
||||
(when sync-suite?
|
||||
(println " --e2ee-password VALUE E2EE password for sync commands (Default: 11111)"))
|
||||
(println " --verbose Enable verbose output")
|
||||
(println " --timings Print per-step timings and slow-step summary")
|
||||
(println)
|
||||
(println "Examples:")
|
||||
(if sync-suite?
|
||||
(println (str " bb -f cli-e2e/bb.edn " command-name))
|
||||
(println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build")))
|
||||
(println (str " bb -f cli-e2e/bb.edn " command-name
|
||||
(if sync-suite?
|
||||
" --jobs 4"
|
||||
" --skip-build --jobs 4")))
|
||||
(println (str " bb -f cli-e2e/bb.edn " command-name " -i smoke"))
|
||||
(if sync-suite?
|
||||
(println (str " bb -f cli-e2e/bb.edn " command-name " --case sync-upload-download-mvp"))
|
||||
(println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build --case global-help")))
|
||||
(when sync-suite?
|
||||
(println (str " bb -f cli-e2e/bb.edn " command-name " --e2ee-password 'my-secret'")))
|
||||
(flush)))
|
||||
|
||||
(defn- test-suite!
|
||||
[opts {:keys [suite command-name]
|
||||
:or {suite default-suite
|
||||
command-name "test"}}]
|
||||
(let [suite (or suite default-suite)
|
||||
opts (assoc opts :suite suite)]
|
||||
(if (:help opts)
|
||||
(do
|
||||
(print-test-help! command-name suite)
|
||||
{:status :help})
|
||||
(let [started-at (System/nanoTime)
|
||||
passed (atom 0)
|
||||
failed (atom 0)
|
||||
total-count (atom 0)
|
||||
timings? (boolean (:timings opts))
|
||||
all-step-timings (atom [])
|
||||
detailed-case-log? (some? (:case opts))
|
||||
parallel? (> (positive-jobs (:jobs opts)) 1)
|
||||
base-run-command (or (:run-command opts) shell/run!)
|
||||
run-command (if detailed-case-log?
|
||||
(fn [{:keys [cmd phase step-index step-total] :as command-opts}]
|
||||
(let [prefix (case phase
|
||||
:setup (format " [setup %d/%d]" step-index step-total)
|
||||
:main (format " [main %d/%d]" (or step-index 1) (or step-total 1))
|
||||
:cleanup (format " [cleanup %d/%d]" step-index step-total)
|
||||
" [command]")]
|
||||
(println (str prefix " $ " cmd))
|
||||
(flush)
|
||||
(base-run-command (assoc command-opts :stream-output? true))))
|
||||
base-run-command)]
|
||||
(println "==> Running cli-e2e cases")
|
||||
(when detailed-case-log?
|
||||
(println (format "==> Detailed case logging enabled (--case %s)" (:case opts))))
|
||||
(when timings?
|
||||
(println "==> Step timing enabled (--timings)"))
|
||||
(flush)
|
||||
(try
|
||||
(run! (assoc opts
|
||||
:run-command run-command
|
||||
:detailed-log? detailed-case-log?
|
||||
:timings? timings?
|
||||
:on-preflight-start (fn [_]
|
||||
(println "==> Build preflight: running...")
|
||||
(flush))
|
||||
:on-preflight-complete (fn [{:keys [status]}]
|
||||
(println (case status
|
||||
:skipped "==> Build preflight: skipped (--skip-build)"
|
||||
"==> Build preflight: completed"))
|
||||
(flush))
|
||||
:on-cases-ready (fn [{:keys [total]}]
|
||||
(reset! total-count total)
|
||||
(println (format "==> Prepared %d case(s), starting execution" total))
|
||||
(flush))
|
||||
:on-case-start (fn [{:keys [index total case]}]
|
||||
(println (str (progress-prefix {:parallel? parallel?
|
||||
:index index
|
||||
:total total}
|
||||
"▶")
|
||||
(:id case)))
|
||||
(flush))
|
||||
:on-case-success (fn [{:keys [index total result elapsed-ms]}]
|
||||
(swap! passed inc)
|
||||
(println (format "%s%s (%dms)"
|
||||
(progress-prefix {:parallel? parallel?
|
||||
:index index
|
||||
:total total}
|
||||
"✓")
|
||||
(:id result)
|
||||
elapsed-ms))
|
||||
(when timings?
|
||||
(let [case-timings (vec (:timings result))]
|
||||
(swap! all-step-timings into
|
||||
(map #(assoc % :case-id (:id result)) case-timings))
|
||||
(print-case-timings! case-timings)))
|
||||
(flush))
|
||||
:on-case-failure (fn [{:keys [index total case error elapsed-ms]}]
|
||||
(swap! failed inc)
|
||||
(println (format "%s%s (%dms)"
|
||||
(progress-prefix {:parallel? parallel?
|
||||
:index index
|
||||
:total total}
|
||||
"✗")
|
||||
(:id case)
|
||||
elapsed-ms))
|
||||
(print-failure-details! error)
|
||||
(when timings?
|
||||
(let [case-timings (vec (:timings (ex-data error)))]
|
||||
(swap! all-step-timings into
|
||||
(map #(assoc % :case-id (:id case)) case-timings))
|
||||
(print-case-timings! case-timings))))))
|
||||
(println (format "Summary: %d passed, %d failed" @passed @failed))
|
||||
(println (str "Selected cases: " @total-count))
|
||||
(println (str "Duration: " (format-duration started-at)))
|
||||
(when timings?
|
||||
(print-slow-steps! @all-step-timings))
|
||||
(catch Exception error
|
||||
(let [failed-count (max 1 @failed)]
|
||||
(println (format "Summary: %d passed, %d failed" @passed failed-count))
|
||||
(println (str "Selected cases: " (max @total-count failed-count)))
|
||||
(println (str "Duration: " (format-duration started-at)))
|
||||
(when timings?
|
||||
(print-slow-steps! @all-step-timings)))
|
||||
(throw error)))))))
|
||||
|
||||
(defn test!
|
||||
[opts]
|
||||
(test-suite! opts {:suite :non-sync
|
||||
:command-name "test"}))
|
||||
|
||||
(defn test-sync!
|
||||
[opts]
|
||||
(test-suite! opts {:suite :sync
|
||||
:command-name "test-sync"}))
|
||||
237
cli-e2e/src/logseq/cli/e2e/manifests.clj
Normal file
237
cli-e2e/src/logseq/cli/e2e/manifests.clj
Normal file
@@ -0,0 +1,237 @@
|
||||
(ns logseq.cli.e2e.manifests
|
||||
(:require [clojure.edn :as edn]
|
||||
[logseq.cli.e2e.paths :as paths]))
|
||||
|
||||
(def suite->manifest-files
|
||||
{:non-sync {:inventory "non_sync_inventory.edn"
|
||||
:cases "non_sync_cases.edn"}
|
||||
:sync {:inventory "sync_inventory.edn"
|
||||
:cases "sync_cases.edn"}})
|
||||
|
||||
(def default-suite :non-sync)
|
||||
|
||||
(def ^:private append-merge-keys
|
||||
#{:setup :cmds :cleanup :tags})
|
||||
|
||||
(def ^:private deep-merge-keys
|
||||
#{:vars :covers :expect})
|
||||
|
||||
(defn read-edn-file
|
||||
[path]
|
||||
(edn/read-string (slurp path)))
|
||||
|
||||
(defn- normalize-suite
|
||||
[suite]
|
||||
(let [suite' (cond
|
||||
(nil? suite) default-suite
|
||||
(keyword? suite) suite
|
||||
(string? suite) (keyword suite)
|
||||
:else suite)]
|
||||
(when-not (contains? suite->manifest-files suite')
|
||||
(throw (ex-info "Unknown cli-e2e suite"
|
||||
{:suite suite
|
||||
:known-suites (sort (keys suite->manifest-files))})))
|
||||
suite'))
|
||||
|
||||
(defn- manifest-file
|
||||
[suite kind]
|
||||
(get-in suite->manifest-files [(normalize-suite suite) kind]))
|
||||
|
||||
(defn load-inventory
|
||||
([]
|
||||
(load-inventory nil))
|
||||
([suite]
|
||||
(read-edn-file (paths/spec-path (manifest-file suite :inventory)))))
|
||||
|
||||
(defn- normalize-extends
|
||||
[extends]
|
||||
(let [extends' (cond
|
||||
(nil? extends) []
|
||||
(keyword? extends) [extends]
|
||||
(vector? extends) extends
|
||||
:else
|
||||
(throw (ex-info "Invalid :extends value in cli-e2e manifest"
|
||||
{:extends extends
|
||||
:expected "keyword | vector<keyword> | nil"})))
|
||||
invalid-entries (remove keyword? extends')]
|
||||
(when (seq invalid-entries)
|
||||
(throw (ex-info "Invalid :extends entries in cli-e2e manifest"
|
||||
{:extends extends
|
||||
:invalid-entries (vec invalid-entries)
|
||||
:expected "keyword | vector<keyword> | nil"})))
|
||||
extends'))
|
||||
|
||||
(defn- parse-manifest
|
||||
[manifest-data]
|
||||
(if-not (map? manifest-data)
|
||||
(throw (ex-info "Invalid cli-e2e manifest format"
|
||||
{:manifest-type (type manifest-data)
|
||||
:expected "{:templates {...} :cases [...]}"}))
|
||||
(let [templates (or (:templates manifest-data) {})
|
||||
cases (:cases manifest-data)]
|
||||
(when-not (map? templates)
|
||||
(throw (ex-info "Invalid cli-e2e manifest :templates format"
|
||||
{:templates templates
|
||||
:expected "map"})))
|
||||
(when-not (vector? cases)
|
||||
(throw (ex-info "Invalid cli-e2e manifest :cases format"
|
||||
{:cases cases
|
||||
:expected "vector"})))
|
||||
(doseq [case cases]
|
||||
(when-not (map? case)
|
||||
(throw (ex-info "Invalid cli-e2e case format"
|
||||
{:case case
|
||||
:expected "map"}))))
|
||||
{:templates templates
|
||||
:cases cases})))
|
||||
|
||||
(defn- reachable-template-ids
|
||||
[templates roots]
|
||||
(loop [stack (vec roots)
|
||||
visited #{}]
|
||||
(if-let [template-id (peek stack)]
|
||||
(if (contains? visited template-id)
|
||||
(recur (pop stack) visited)
|
||||
(let [template (get templates template-id)
|
||||
parent-ids (if template
|
||||
(normalize-extends (:extends template))
|
||||
[])]
|
||||
(recur (into (pop stack) parent-ids)
|
||||
(conj visited template-id))))
|
||||
visited)))
|
||||
|
||||
(defn- lint-manifest!
|
||||
[{:keys [templates cases]}]
|
||||
(let [template-ids (set (keys templates))
|
||||
template-refs (mapcat (fn [[template-id template]]
|
||||
(map (fn [target]
|
||||
{:type :invalid-extends
|
||||
:source-type :template
|
||||
:source template-id
|
||||
:target target})
|
||||
(normalize-extends (:extends template))))
|
||||
templates)
|
||||
case-refs (mapcat (fn [[index case]]
|
||||
(map (fn [target]
|
||||
{:type :invalid-extends
|
||||
:source-type :case
|
||||
:source (or (:id case)
|
||||
(str "case#" (inc index)))
|
||||
:target target})
|
||||
(normalize-extends (:extends case))))
|
||||
(map-indexed vector cases))
|
||||
invalid-extends-issues (->> (concat template-refs case-refs)
|
||||
(filter (fn [{:keys [target]}]
|
||||
(not (contains? template-ids target)))))
|
||||
duplicate-id-issues (->> cases
|
||||
(keep :id)
|
||||
frequencies
|
||||
(filter (fn [[_ count]] (> count 1)))
|
||||
(sort-by first)
|
||||
(map (fn [[case-id count]]
|
||||
{:type :duplicate-case-id
|
||||
:id case-id
|
||||
:count count})))
|
||||
roots (->> cases
|
||||
(mapcat #(normalize-extends (:extends %)))
|
||||
distinct)
|
||||
used-template-ids (reachable-template-ids templates roots)
|
||||
unused-template-issues (->> (keys templates)
|
||||
(remove used-template-ids)
|
||||
sort
|
||||
(map (fn [template-id]
|
||||
{:type :unused-template
|
||||
:template template-id})))
|
||||
issues (vec (concat invalid-extends-issues
|
||||
duplicate-id-issues
|
||||
unused-template-issues))]
|
||||
(when (seq issues)
|
||||
(throw (ex-info "cli-e2e manifest lint failed"
|
||||
{:issues issues})))))
|
||||
|
||||
(defn- as-seq
|
||||
[value]
|
||||
(cond
|
||||
(nil? value) []
|
||||
(sequential? value) value
|
||||
:else [value]))
|
||||
|
||||
(defn- deep-merge-maps
|
||||
[left right]
|
||||
(merge-with (fn [left-val right-val]
|
||||
(if (and (map? left-val)
|
||||
(map? right-val))
|
||||
(deep-merge-maps left-val right-val)
|
||||
right-val))
|
||||
(or left {})
|
||||
(or right {})))
|
||||
|
||||
(defn- merge-entry
|
||||
[parent child]
|
||||
(let [all-keys (set (concat (keys parent) (keys child)))]
|
||||
(reduce (fn [acc key]
|
||||
(let [parent-val (get parent key)
|
||||
child-val (get child key)]
|
||||
(assoc acc
|
||||
key
|
||||
(cond
|
||||
(contains? append-merge-keys key)
|
||||
(if (contains? child key)
|
||||
(vec (concat (as-seq parent-val)
|
||||
(as-seq child-val)))
|
||||
(vec (as-seq parent-val)))
|
||||
|
||||
(contains? deep-merge-keys key)
|
||||
(if (contains? child key)
|
||||
(deep-merge-maps parent-val child-val)
|
||||
parent-val)
|
||||
|
||||
(contains? child key)
|
||||
child-val
|
||||
|
||||
:else
|
||||
parent-val))))
|
||||
{}
|
||||
all-keys)))
|
||||
|
||||
(defn- resolve-template
|
||||
[templates template-id stack]
|
||||
(when (some #{template-id} stack)
|
||||
(throw (ex-info "Circular template inheritance detected in cli-e2e manifest"
|
||||
{:template template-id
|
||||
:cycle (conj (vec stack) template-id)})))
|
||||
(let [template (get templates template-id)]
|
||||
(when-not template
|
||||
(throw (ex-info "Unknown template in cli-e2e manifest"
|
||||
{:template template-id
|
||||
:known-templates (sort (keys templates))})))
|
||||
(let [parent-ids (normalize-extends (:extends template))
|
||||
parent-values (map #(resolve-template templates % (conj stack template-id))
|
||||
parent-ids)]
|
||||
(reduce merge-entry
|
||||
{}
|
||||
(concat parent-values
|
||||
[(dissoc template :extends)])))))
|
||||
|
||||
(defn- expand-manifest-cases
|
||||
[{:keys [templates cases]}]
|
||||
(mapv (fn [case]
|
||||
(let [parent-ids (normalize-extends (:extends case))
|
||||
parent-values (map #(resolve-template templates % [])
|
||||
parent-ids)]
|
||||
(reduce merge-entry
|
||||
{}
|
||||
(concat parent-values
|
||||
[(dissoc case :extends)]))))
|
||||
cases))
|
||||
|
||||
(defn load-cases
|
||||
([]
|
||||
(load-cases nil))
|
||||
([suite]
|
||||
(let [manifest-data (-> (manifest-file suite :cases)
|
||||
paths/spec-path
|
||||
read-edn-file
|
||||
parse-manifest)]
|
||||
(lint-manifest! manifest-data)
|
||||
(expand-manifest-cases manifest-data))))
|
||||
49
cli-e2e/src/logseq/cli/e2e/paths.clj
Normal file
49
cli-e2e/src/logseq/cli/e2e/paths.clj
Normal file
@@ -0,0 +1,49 @@
|
||||
(ns logseq.cli.e2e.paths
|
||||
(:require [babashka.fs :as fs]))
|
||||
|
||||
(defn- ancestors
|
||||
[path]
|
||||
(take-while some? (iterate fs/parent path)))
|
||||
|
||||
(defn- find-parent-named
|
||||
[path name]
|
||||
(some (fn [candidate]
|
||||
(when (= name (fs/file-name candidate))
|
||||
candidate))
|
||||
(ancestors (fs/canonicalize path))))
|
||||
|
||||
(def ^:private cli-e2e-root-path
|
||||
(or (some-> *file*
|
||||
(find-parent-named "cli-e2e")
|
||||
str)
|
||||
(throw (ex-info "Unable to locate cli-e2e root"
|
||||
{:file *file*}))))
|
||||
|
||||
(defn cli-e2e-root
|
||||
[]
|
||||
cli-e2e-root-path)
|
||||
|
||||
(defn repo-root
|
||||
[]
|
||||
(str (fs/parent cli-e2e-root-path)))
|
||||
|
||||
(defn repo-path
|
||||
[& segments]
|
||||
(str (apply fs/path (repo-root) segments)))
|
||||
|
||||
(defn cli-e2e-path
|
||||
[& segments]
|
||||
(str (apply fs/path (cli-e2e-root) segments)))
|
||||
|
||||
(defn spec-path
|
||||
[filename]
|
||||
(cli-e2e-path "spec" filename))
|
||||
|
||||
(defn required-artifacts
|
||||
[]
|
||||
[(repo-path "cli" "_build" "default" "dist" "logseq-cli.js")
|
||||
(repo-path "static" "logseq-cli.js")
|
||||
(repo-path "static" "db-worker-node.js")
|
||||
(repo-path "dist" "db-worker-node.js")
|
||||
(repo-path "dist" "db-worker-node-assets.json")
|
||||
(repo-path "deps" "db-sync" "worker" "dist" "node-adapter.js")])
|
||||
37
cli-e2e/src/logseq/cli/e2e/preflight.clj
Normal file
37
cli-e2e/src/logseq/cli/e2e/preflight.clj
Normal file
@@ -0,0 +1,37 @@
|
||||
(ns logseq.cli.e2e.preflight
|
||||
(:require [babashka.fs :as fs]
|
||||
[logseq.cli.e2e.paths :as paths]
|
||||
[logseq.cli.e2e.shell :as shell]))
|
||||
|
||||
(def build-plan
|
||||
[{:cmd "pnpm cli:release"}
|
||||
{:cmd "pnpm db-worker-node:compile:bundle"}
|
||||
{:cmd "pnpm --dir deps/db-sync build:node-adapter"}])
|
||||
|
||||
(defn missing-artifacts
|
||||
([]
|
||||
(missing-artifacts (paths/required-artifacts) fs/exists?))
|
||||
([artifacts file-exists?]
|
||||
(->> artifacts
|
||||
(remove file-exists?)
|
||||
vec)))
|
||||
|
||||
(defn run!
|
||||
[{:keys [skip-build run-command file-exists?]
|
||||
:or {run-command shell/run!
|
||||
file-exists? fs/exists?}}]
|
||||
(if skip-build
|
||||
{:status :skipped
|
||||
:commands []
|
||||
:missing-artifacts []}
|
||||
(let [artifacts (paths/required-artifacts)]
|
||||
(doseq [{:keys [cmd]} build-plan]
|
||||
(run-command {:cmd cmd
|
||||
:dir (paths/repo-root)}))
|
||||
(let [missing-after-build (missing-artifacts artifacts file-exists?)]
|
||||
(when (seq missing-after-build)
|
||||
(throw (ex-info "Build preflight completed but required artifacts are missing"
|
||||
{:missing-artifacts missing-after-build})))
|
||||
{:status :ok
|
||||
:commands build-plan
|
||||
:missing-artifacts []}))))
|
||||
18
cli-e2e/src/logseq/cli/e2e/report.clj
Normal file
18
cli-e2e/src/logseq/cli/e2e/report.clj
Normal file
@@ -0,0 +1,18 @@
|
||||
(ns logseq.cli.e2e.report
|
||||
(:require [clojure.string :as string]))
|
||||
|
||||
(defn format-missing-coverage
|
||||
[{:keys [missing-commands missing-options]}]
|
||||
(str
|
||||
"Missing coverage\n"
|
||||
(when (seq missing-commands)
|
||||
(str "Commands:\n"
|
||||
(string/join "\n" (map #(str "- " %) missing-commands))
|
||||
"\n"))
|
||||
(string/join
|
||||
"\n"
|
||||
(keep (fn [[scope options]]
|
||||
(when (seq options)
|
||||
(str (name scope) " options:\n"
|
||||
(string/join "\n" (map #(str "- " %) options)))))
|
||||
missing-options))))
|
||||
280
cli-e2e/src/logseq/cli/e2e/runner.clj
Normal file
280
cli-e2e/src/logseq/cli/e2e/runner.clj
Normal file
@@ -0,0 +1,280 @@
|
||||
(ns logseq.cli.e2e.runner
|
||||
(:require [babashka.fs :as fs]
|
||||
[cheshire.core :as json]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.string :as string]
|
||||
[logseq.cli.e2e.paths :as paths]
|
||||
[logseq.cli.e2e.shell :as shell]))
|
||||
|
||||
(defn shell-escape
|
||||
[value]
|
||||
(let [text (str value)]
|
||||
(if (re-find #"[^\w@%+=:,./-]" text)
|
||||
(str "'" (string/replace text #"'" "'\"'\"'") "'")
|
||||
text)))
|
||||
|
||||
(def template-pattern #"\{\{([^}]+)\}\}")
|
||||
(def ^:private e2e-env {"CLI_E2E_TEST" "1"
|
||||
"NO_COLOR" "1"})
|
||||
|
||||
(defn- render-string
|
||||
[template context]
|
||||
(string/replace template
|
||||
template-pattern
|
||||
(fn [[_ key]]
|
||||
(let [lookup-key (keyword (string/trim key))]
|
||||
(when-not (contains? context lookup-key)
|
||||
(throw (ex-info "Missing case template value"
|
||||
{:template template
|
||||
:key lookup-key
|
||||
:context context})))
|
||||
(str (get context lookup-key))))))
|
||||
|
||||
(defn- render-value
|
||||
[value context]
|
||||
(cond
|
||||
(string? value) (render-string value context)
|
||||
(map? value) (into {}
|
||||
(map (fn [[k v]]
|
||||
[k (render-value v context)]))
|
||||
value)
|
||||
(vector? value) (mapv #(render-value % context) value)
|
||||
(seq? value) (doall (map #(render-value % context) value))
|
||||
:else value))
|
||||
|
||||
(defn render-case
|
||||
[case context]
|
||||
(render-value case context))
|
||||
|
||||
(defn- ensure-contains!
|
||||
[id cmd output snippets stream]
|
||||
(doseq [snippet (or snippets [])]
|
||||
(let [normalize-pathish-text (fn [text]
|
||||
(-> (str text)
|
||||
(string/replace "\\\\" "\\")
|
||||
(string/replace "\\" "/")
|
||||
(string/replace "\r" "")))
|
||||
matches? (or (string/includes? output snippet)
|
||||
(string/includes? (normalize-pathish-text output)
|
||||
(normalize-pathish-text snippet)))]
|
||||
(when-not matches?
|
||||
(throw (ex-info (str stream " did not contain expected text")
|
||||
{:id id
|
||||
:cmd cmd
|
||||
:snippet snippet
|
||||
:output output
|
||||
:stream stream}))))))
|
||||
|
||||
(defn- ensure-not-contains!
|
||||
[id cmd output snippets stream]
|
||||
(doseq [snippet (or snippets [])]
|
||||
(when (string/includes? output snippet)
|
||||
(throw (ex-info (str stream " contained forbidden text")
|
||||
{:id id
|
||||
:cmd cmd
|
||||
:snippet snippet
|
||||
:output output
|
||||
:stream stream})))))
|
||||
|
||||
(defn- ensure-equals!
|
||||
[id cmd actual expected label]
|
||||
(when (and (some? expected)
|
||||
(not= expected actual))
|
||||
(throw (ex-info (str label " did not match")
|
||||
{:id id
|
||||
:cmd cmd
|
||||
:expected expected
|
||||
:actual actual}))))
|
||||
|
||||
(defn- ensure-json-paths!
|
||||
[id cmd output expected-paths]
|
||||
(when expected-paths
|
||||
(let [payload (json/parse-string output true)]
|
||||
(doseq [[path expected] expected-paths]
|
||||
(let [actual (get-in payload path)]
|
||||
(when-not (= expected actual)
|
||||
(throw (ex-info "stdout JSON path did not match"
|
||||
{:id id
|
||||
:cmd cmd
|
||||
:path path
|
||||
:expected expected
|
||||
:actual actual
|
||||
:payload payload}))))))))
|
||||
|
||||
(defn- ensure-edn-paths!
|
||||
[id cmd output expected-paths]
|
||||
(when expected-paths
|
||||
(let [payload (edn/read-string output)]
|
||||
(doseq [[path expected] expected-paths]
|
||||
(let [actual (get-in payload path)]
|
||||
(when-not (= expected actual)
|
||||
(throw (ex-info "stdout EDN path did not match"
|
||||
{:id id
|
||||
:cmd cmd
|
||||
:path path
|
||||
:expected expected
|
||||
:actual actual
|
||||
:payload payload}))))))))
|
||||
|
||||
(defn assert-result!
|
||||
[{:keys [id expect]} {:keys [cmd exit out err] :as result}]
|
||||
(when-let [expected-exit (:exit expect)]
|
||||
(when-not (= expected-exit exit)
|
||||
(throw (ex-info "Unexpected exit code"
|
||||
{:id id
|
||||
:cmd cmd
|
||||
:expected expected-exit
|
||||
:actual exit
|
||||
:result result}))))
|
||||
(ensure-equals! id cmd out (:stdout-equals expect) "stdout")
|
||||
(ensure-equals! id cmd err (:stderr-equals expect) "stderr")
|
||||
(ensure-contains! id cmd out (:stdout-contains expect) "stdout")
|
||||
(ensure-contains! id cmd err (:stderr-contains expect) "stderr")
|
||||
(ensure-not-contains! id cmd out (:stdout-not-contains expect) "stdout")
|
||||
(ensure-not-contains! id cmd err (:stderr-not-contains expect) "stderr")
|
||||
(ensure-json-paths! id cmd out (:stdout-json-paths expect))
|
||||
(ensure-edn-paths! id cmd out (:stdout-edn-paths expect))
|
||||
nil)
|
||||
|
||||
(defn- default-context
|
||||
[case context]
|
||||
(let [tmp-dir (or (:tmp-dir context)
|
||||
(str (fs/create-temp-dir {:prefix (str "logseq-cli-e2e-" (:id case) "-")})))
|
||||
root-dir (or (:root-dir context)
|
||||
tmp-dir)
|
||||
config-path (or (:config-path context)
|
||||
(str (fs/path root-dir "cli.edn")))
|
||||
export-path (or (:export-path context)
|
||||
(str (fs/path tmp-dir "graph-export.edn")))
|
||||
graph (or (:graph context)
|
||||
(:graph case)
|
||||
(str "cli-e2e-" (:id case)))]
|
||||
(fs/create-dirs (fs/path root-dir "graphs"))
|
||||
(spit config-path "{:output-format :json}\n")
|
||||
{:tmp-dir tmp-dir
|
||||
:tmp-dir-arg (shell-escape tmp-dir)
|
||||
:root-dir root-dir
|
||||
:root-dir-arg (shell-escape root-dir)
|
||||
:config-path config-path
|
||||
:config-path-arg (shell-escape config-path)
|
||||
:export-path export-path
|
||||
:export-path-arg (shell-escape export-path)
|
||||
:repo-root (paths/repo-root)
|
||||
:repo-root-arg (shell-escape (paths/repo-root))
|
||||
:cli (str "node " (shell-escape (paths/repo-path "static" "logseq-cli.js")))
|
||||
:graph graph
|
||||
:graph-arg (shell-escape graph)}))
|
||||
|
||||
(defn- case-context
|
||||
[case {:keys [context]}]
|
||||
(merge (default-context case context)
|
||||
context
|
||||
(:vars case)))
|
||||
|
||||
(defn- run-command!
|
||||
[command context {:keys [run-command stdin allow-failure phase step-index step-total case-id]}]
|
||||
(run-command {:cmd (render-string command context)
|
||||
:dir (paths/repo-root)
|
||||
:env e2e-env
|
||||
:stdin (some-> stdin (render-string context))
|
||||
:phase phase
|
||||
:step-index step-index
|
||||
:step-total step-total
|
||||
:case-id case-id
|
||||
:throw? (not allow-failure)}))
|
||||
|
||||
(defn- elapsed-ms
|
||||
[started-at]
|
||||
(long (/ (- (System/nanoTime) started-at) 1000000)))
|
||||
|
||||
(defn- run-step!
|
||||
[timings command context {:keys [timings? phase step-index step-total]
|
||||
:as run-opts}]
|
||||
(if-not timings?
|
||||
(run-command! command context run-opts)
|
||||
(let [started-at (System/nanoTime)]
|
||||
(try
|
||||
(let [result (run-command! command context run-opts)]
|
||||
(swap! timings conj {:phase phase
|
||||
:step-index step-index
|
||||
:step-total step-total
|
||||
:cmd (:cmd result)
|
||||
:elapsed-ms (elapsed-ms started-at)
|
||||
:status :ok})
|
||||
result)
|
||||
(catch Exception error
|
||||
(swap! timings conj {:phase phase
|
||||
:step-index step-index
|
||||
:step-total step-total
|
||||
:cmd (or (:cmd (ex-data error))
|
||||
(render-string command context))
|
||||
:elapsed-ms (elapsed-ms started-at)
|
||||
:status :failed})
|
||||
(throw error))))))
|
||||
|
||||
(defn run-case!
|
||||
[case {:keys [run-command detailed-log? timings?]
|
||||
:or {run-command shell/run!}
|
||||
:as opts}]
|
||||
(let [context (case-context case opts)
|
||||
rendered (render-case case context)
|
||||
case-id (:id rendered)
|
||||
timings? (boolean timings?)
|
||||
timings (atom [])
|
||||
cleanup-commands (vec (:cleanup rendered))
|
||||
setup-commands (vec (:setup rendered))
|
||||
main-commands (vec (:cmds rendered))
|
||||
cleanup! (fn []
|
||||
(doseq [[idx command] (map-indexed vector cleanup-commands)]
|
||||
(try
|
||||
(run-step! timings command context {:run-command run-command
|
||||
:timings? timings?
|
||||
:allow-failure true
|
||||
:phase (when (or detailed-log? timings?) :cleanup)
|
||||
:step-index (inc idx)
|
||||
:step-total (count cleanup-commands)
|
||||
:case-id case-id})
|
||||
(catch Exception _
|
||||
nil))))]
|
||||
(try
|
||||
(doseq [[idx command] (map-indexed vector setup-commands)]
|
||||
(run-step! timings command context {:run-command run-command
|
||||
:timings? timings?
|
||||
:phase (when (or detailed-log? timings?) :setup)
|
||||
:step-index (inc idx)
|
||||
:step-total (count setup-commands)
|
||||
:case-id case-id}))
|
||||
(let [main-total (count main-commands)
|
||||
_ (when (zero? main-total)
|
||||
(throw (ex-info "Missing case commands"
|
||||
{:id case-id
|
||||
:case rendered})))
|
||||
result (reduce (fn [_ [idx command]]
|
||||
(let [last-step? (= idx (dec main-total))]
|
||||
(run-step! timings command context {:run-command run-command
|
||||
:timings? timings?
|
||||
:stdin (when last-step? (:stdin rendered))
|
||||
:allow-failure last-step?
|
||||
:phase (when (or detailed-log? timings?) :main)
|
||||
:step-index (inc idx)
|
||||
:step-total main-total
|
||||
:case-id case-id})))
|
||||
nil
|
||||
(map-indexed vector main-commands))]
|
||||
(assert-result! rendered result)
|
||||
(cleanup!)
|
||||
(cond-> {:id case-id
|
||||
:status :ok
|
||||
:cmd (:cmd result)
|
||||
:result result
|
||||
:context context}
|
||||
timings? (assoc :timings @timings)))
|
||||
(catch Exception error
|
||||
(cleanup!)
|
||||
(if timings?
|
||||
(throw (ex-info (.getMessage error)
|
||||
(assoc (or (ex-data error) {})
|
||||
:timings @timings
|
||||
:case-id case-id)
|
||||
error))
|
||||
(throw error))))))
|
||||
67
cli-e2e/src/logseq/cli/e2e/shell.clj
Normal file
67
cli-e2e/src/logseq/cli/e2e/shell.clj
Normal file
@@ -0,0 +1,67 @@
|
||||
(ns logseq.cli.e2e.shell
|
||||
(:require [babashka.process :as process])
|
||||
(:import [java.io ByteArrayOutputStream OutputStream]))
|
||||
|
||||
(defn- utf8-string
|
||||
[^ByteArrayOutputStream payload]
|
||||
(.toString payload "UTF-8"))
|
||||
|
||||
(defn- capture-stream
|
||||
[target stream-output?]
|
||||
(let [payload (ByteArrayOutputStream.)]
|
||||
{:payload payload
|
||||
:stream (if stream-output?
|
||||
(proxy [OutputStream] []
|
||||
(write
|
||||
([byte]
|
||||
(.write payload byte)
|
||||
(.write target byte)
|
||||
(.flush target))
|
||||
([bytes offset length]
|
||||
(.write payload bytes offset length)
|
||||
(.write target bytes offset length)
|
||||
(.flush target)))
|
||||
(flush []
|
||||
(.flush target))
|
||||
(close []
|
||||
(.flush target)))
|
||||
payload)}))
|
||||
|
||||
(defn- default-executor
|
||||
[cmd {:keys [dir extra-env in stream-output?]}]
|
||||
(let [{out-payload :payload
|
||||
out-stream :stream} (capture-stream System/out stream-output?)
|
||||
{err-payload :payload
|
||||
err-stream :stream} (capture-stream System/err stream-output?)
|
||||
result @(process/process {:continue true
|
||||
:dir dir
|
||||
:extra-env extra-env
|
||||
:in in
|
||||
:out out-stream
|
||||
:err err-stream}
|
||||
"bash"
|
||||
"-c"
|
||||
cmd)]
|
||||
{:exit (:exit result 0)
|
||||
:out (utf8-string out-payload)
|
||||
:err (utf8-string err-payload)}))
|
||||
|
||||
(defn run!
|
||||
[{:keys [cmd dir env stdin executor throw? stream-output?]
|
||||
:or {throw? true}}]
|
||||
(let [runner (or executor default-executor)
|
||||
result (runner cmd {:dir dir
|
||||
:extra-env env
|
||||
:in stdin
|
||||
:stream-output? stream-output?})
|
||||
exit (:exit result 0)
|
||||
payload {:cmd cmd
|
||||
:dir dir
|
||||
:exit exit
|
||||
:out (:out result "")
|
||||
:err (:err result "")}]
|
||||
(when (and throw?
|
||||
(not (zero? exit)))
|
||||
(throw (ex-info "Shell command failed"
|
||||
payload)))
|
||||
payload))
|
||||
140
cli-e2e/src/logseq/cli/e2e/sync_fixture.clj
Normal file
140
cli-e2e/src/logseq/cli/e2e/sync_fixture.clj
Normal file
@@ -0,0 +1,140 @@
|
||||
(ns logseq.cli.e2e.sync-fixture
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.string :as string]
|
||||
[logseq.cli.e2e.paths :as paths]
|
||||
[logseq.cli.e2e.runner :as runner]
|
||||
[logseq.cli.e2e.shell :as shell]))
|
||||
|
||||
(def default-sync-port "18080")
|
||||
(def default-e2ee-password "11111")
|
||||
(def default-user-keys-graph "sync-e2e-user-keys-bootstrap")
|
||||
|
||||
(def ^:private heavy-setup-patterns
|
||||
[#"^mkdir -p '\{\{tmp-dir\}\}/home/logseq'$"
|
||||
#"^cp ~/logseq/auth\.json\b"
|
||||
#"prepare_sync_config\.py"
|
||||
#"db_sync_server\.py'? start"])
|
||||
|
||||
(def ^:private heavy-cleanup-patterns
|
||||
[#"db_sync_server\.py'? stop"])
|
||||
|
||||
(defn- shell-quote
|
||||
[value]
|
||||
(runner/shell-escape value))
|
||||
|
||||
(defn- heavy-command?
|
||||
[command patterns]
|
||||
(boolean (some #(re-find % command) patterns)))
|
||||
|
||||
(defn- case-local-setup-prefix
|
||||
[]
|
||||
["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}}'"])
|
||||
|
||||
(def ^:private case-local-resource-markers
|
||||
["{{cli-home}}"
|
||||
"{{config-path}}"
|
||||
"{{config-path-arg}}"
|
||||
"{{tmp-dir}}/cli-b.edn"
|
||||
"{{auth-path}}"
|
||||
"{{home-dir}}"])
|
||||
|
||||
(defn- requires-case-local-resources?
|
||||
[command]
|
||||
(boolean (some #(string/includes? command %) case-local-resource-markers)))
|
||||
|
||||
(defn- suite-user-keys-bootstrap-command
|
||||
[suite-tmp-dir]
|
||||
(let [lock-dir (shell-quote (str (fs/path suite-tmp-dir "user-rsa-keys.lock")))
|
||||
done-file (shell-quote (str (fs/path suite-tmp-dir "user-rsa-keys.ready")))]
|
||||
(format (str "LOCK_DIR=%s; DONE_FILE=%s; "
|
||||
"if [ -f \"$DONE_FILE\" ]; then exit 0; fi; "
|
||||
"while ! mkdir \"$LOCK_DIR\" 2>/dev/null; do [ -f \"$DONE_FILE\" ] && exit 0; sleep 0.1; done; "
|
||||
"trap 'rmdir \"$LOCK_DIR\" 2>/dev/null || true' EXIT; "
|
||||
"if [ ! -f \"$DONE_FILE\" ]; then "
|
||||
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json graph create --graph {{user-keys-graph-arg}} >/dev/null 2>/dev/null || true; "
|
||||
"if {{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json sync ensure-keys --graph {{user-keys-graph-arg}} --e2ee-password {{e2ee-password-arg}} --upload-keys >/dev/null; then "
|
||||
"{{cli-home}} --root-dir {{root-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{user-keys-graph-arg}} >/dev/null 2>/dev/null || true; "
|
||||
"touch \"$DONE_FILE\"; "
|
||||
"else exit 1; fi; "
|
||||
"fi")
|
||||
lock-dir
|
||||
done-file)))
|
||||
|
||||
(defn before-suite!
|
||||
[{:keys [run-command sync-port]
|
||||
:or {run-command shell/run!
|
||||
sync-port default-sync-port}}]
|
||||
(let [sync-port (str sync-port)
|
||||
suite-tmp-dir (str (fs/create-temp-dir {:prefix "logseq-cli-e2e-sync-suite-"}))
|
||||
db-sync-pid-file (str (fs/path suite-tmp-dir "db-sync-server.pid"))
|
||||
db-sync-log-file (str (fs/path suite-tmp-dir "db-sync-server.log"))
|
||||
db-sync-root-dir (str (fs/path suite-tmp-dir "db-sync-server-data"))
|
||||
sync-http-base (str "http://127.0.0.1:" sync-port)
|
||||
sync-ws-url (str "ws://127.0.0.1:" sync-port "/sync/%s")
|
||||
auth-path (str (fs/path (System/getProperty "user.home") "logseq" "auth.json"))
|
||||
start-db-sync-cmd (format "python3 %s start --repo-root %s --pid-file %s --log-file %s --data-dir %s --port %s --startup-timeout-s 60 --auth-path %s"
|
||||
(shell-quote (paths/repo-path "cli-e2e" "scripts" "db_sync_server.py"))
|
||||
(shell-quote (paths/repo-root))
|
||||
(shell-quote db-sync-pid-file)
|
||||
(shell-quote db-sync-log-file)
|
||||
(shell-quote db-sync-root-dir)
|
||||
sync-port
|
||||
(shell-quote auth-path))]
|
||||
(run-command {:cmd start-db-sync-cmd
|
||||
:dir (paths/repo-root)})
|
||||
{:suite-tmp-dir suite-tmp-dir
|
||||
:db-sync-pid-file db-sync-pid-file
|
||||
:db-sync-log-file db-sync-log-file
|
||||
:db-sync-root-dir db-sync-root-dir
|
||||
:sync-port sync-port
|
||||
:sync-http-base sync-http-base
|
||||
:sync-ws-url sync-ws-url}))
|
||||
|
||||
(defn prepare-case
|
||||
[case {:keys [suite-tmp-dir sync-port sync-http-base sync-ws-url e2ee-password]}]
|
||||
(let [e2ee-password (or e2ee-password default-e2ee-password)
|
||||
user-keys-graph (or (get-in case [:vars :user-keys-graph])
|
||||
default-user-keys-graph)
|
||||
setup-commands (vec (:setup case))
|
||||
insertion-point? (fn [command]
|
||||
(or (heavy-command? command heavy-setup-patterns)
|
||||
(requires-case-local-resources? command)))
|
||||
leading-setup (->> setup-commands
|
||||
(take-while #(not (insertion-point? %)))
|
||||
vec)
|
||||
trailing-setup (->> setup-commands
|
||||
(drop (count leading-setup))
|
||||
(remove #(heavy-command? % heavy-setup-patterns))
|
||||
vec)
|
||||
bootstrap-setup (when suite-tmp-dir
|
||||
[(suite-user-keys-bootstrap-command suite-tmp-dir)])
|
||||
cleanup' (->> (:cleanup case)
|
||||
(remove #(heavy-command? % heavy-cleanup-patterns))
|
||||
vec)]
|
||||
(-> case
|
||||
(update :vars merge {:sync-port sync-port
|
||||
:sync-http-base sync-http-base
|
||||
:sync-ws-url sync-ws-url
|
||||
:e2ee-password e2ee-password
|
||||
:e2ee-password-arg (shell-quote e2ee-password)
|
||||
:user-keys-graph-arg (shell-quote user-keys-graph)})
|
||||
(assoc :setup (vec (concat leading-setup
|
||||
(case-local-setup-prefix)
|
||||
bootstrap-setup
|
||||
trailing-setup)))
|
||||
(assoc :cleanup cleanup'))))
|
||||
|
||||
(defn after-suite!
|
||||
[{:keys [db-sync-pid-file]}
|
||||
{:keys [run-command]
|
||||
:or {run-command shell/run!}}]
|
||||
(when (and (string? db-sync-pid-file)
|
||||
(not (string/blank? db-sync-pid-file)))
|
||||
(run-command {:cmd (format "python3 %s stop --pid-file %s"
|
||||
(shell-quote (paths/repo-path "cli-e2e" "scripts" "db_sync_server.py"))
|
||||
(shell-quote db-sync-pid-file))
|
||||
:dir (paths/repo-root)
|
||||
:throw? false})))
|
||||
23
cli-e2e/src/logseq/cli/e2e/test_runner.clj
Normal file
23
cli-e2e/src/logseq/cli/e2e/test_runner.clj
Normal file
@@ -0,0 +1,23 @@
|
||||
(ns logseq.cli.e2e.test-runner
|
||||
(:require [clojure.test :as test]))
|
||||
|
||||
(def test-namespaces
|
||||
'[logseq.cli.e2e.coverage-test
|
||||
logseq.cli.e2e.preflight-test
|
||||
logseq.cli.e2e.paths-test
|
||||
logseq.cli.e2e.shell-test
|
||||
logseq.cli.e2e.runner-test
|
||||
logseq.cli.e2e.cleanup-test
|
||||
logseq.cli.e2e.manifests-test
|
||||
logseq.cli.e2e.sync-fixture-test
|
||||
logseq.cli.e2e.main-test])
|
||||
|
||||
(defn run!
|
||||
[_opts]
|
||||
(doseq [ns-sym test-namespaces]
|
||||
(require ns-sym))
|
||||
(let [{:keys [fail error]} (apply test/run-tests test-namespaces)]
|
||||
(when (pos? (+ fail error))
|
||||
(throw (ex-info "cli-e2e unit tests failed"
|
||||
{:fail fail
|
||||
:error error})))))
|
||||
Reference in New Issue
Block a user