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

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

8294 lines
335 KiB
OCaml

open Test_support
let expect_equal name expected actual =
if expected = actual then pass
else fail_test (Printf.sprintf "%s: expected %S, got %S" name expected actual)
let expect_string_vec name expected actual =
if Vec.to_array actual = expected then pass
else
fail_test
(Printf.sprintf "%s: expected %S, got %S" name
(Vec.string_concat "," (Vec.of_array expected))
(Vec.string_concat "," actual))
let expect_bool name expected actual =
if expected = actual then pass
else fail_test (Printf.sprintf "%s: expected %b, got %b" name expected actual)
let expect_int name expected actual =
if expected = actual then pass
else fail_test (Printf.sprintf "%s: expected %d, got %d" name expected actual)
let expect_int64 name expected actual =
if expected = actual then pass
else
fail_test
(Printf.sprintf "%s: expected %s, got %s" name (Int64.to_string expected)
(Int64.to_string actual))
external string_char_code_at : string -> int -> int = "charCodeAt" [@@mel.send]
let expect_some name = function
| Some value -> value
| None ->
fail_test (name ^ ": expected Some value");
assert false
let expect_none name = function
| None -> pass
| Some _ -> fail_test (name ^ ": expected None")
let expect_ok name = function
| Ok value -> value
| Error err ->
fail_test (name ^ ": " ^ err.Error.message);
assert false
let expect_error_code name expected = function
| Error err ->
let normalize_code code =
if String.length code > 0 && code.[0] = ':' then
String.sub code 1 (String.length code - 1)
else code
in
expect_equal name (normalize_code expected)
(normalize_code (Error.code_to_string err.Error.code))
| Ok _ -> fail_test (name ^ ": expected Error")
let expect_error_context_code name expected err =
match err.Error.context with
| None -> fail_test (name ^ ": expected error context")
| Some context ->
expect_equal name expected
(expect_some (name ^ " code")
(Option.bind (Edn_util.get context "code") Edn_util.as_keyword))
let expect_parse_ok name argv =
match Cli_parse.parse (Vec.of_array argv) with
| Ok request -> request
| Error err ->
fail_test (name ^ ": " ^ err.Error.message);
assert false
let expect_parse_error_code name expected argv =
expect_error_code name expected (Cli_parse.parse (Vec.of_array argv))
let keyword_text value = Edn_util.keyword_to_string value
let edn_of_string text = Melange_edn_melange.of_edn_string text
let repo_text repo = Cli_primitive.string_of_repo repo
let graph_text graph = Cli_primitive.string_of_graph graph
let unicode_text codepoints =
codepoints |> Vec.of_array
|> Vec.map Js.String.fromCodePoint
|> Vec.string_concat ""
let start_open_url_capture () : unit =
[%mel.raw
{|
(function () {
const childProcess = require("child_process");
const capture = {
originalSpawn: childProcess.spawn,
originalPlatformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"),
calls: []
};
globalThis.__logseqOpenUrlCapture = capture;
childProcess.spawn = function (command, args, options) {
capture.calls.push({ command, args: Array.from(args), options });
return { pid: 4242, unref: function () {} };
};
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
})()
|}]
let stop_open_url_capture () : unit =
[%mel.raw
{|
(function () {
const capture = globalThis.__logseqOpenUrlCapture;
if (!capture) return;
const childProcess = require("child_process");
childProcess.spawn = capture.originalSpawn;
if (capture.originalPlatformDescriptor) {
Object.defineProperty(process, "platform", capture.originalPlatformDescriptor);
}
delete globalThis.__logseqOpenUrlCapture;
})()
|}]
let open_url_capture_calls () : string =
[%mel.raw
{|
JSON.stringify((globalThis.__logseqOpenUrlCapture && globalThis.__logseqOpenUrlCapture.calls) || [])
|}]
let start_spawn_capture () : unit =
[%mel.raw
{|
(function () {
const childProcess = require("child_process");
const capture = {
originalSpawn: childProcess.spawn,
calls: []
};
globalThis.__logseqSpawnCapture = capture;
childProcess.spawn = function (command, args, options) {
capture.calls.push({ command, args: Array.from(args), options });
if (typeof capture.onSpawn === "function") capture.onSpawn(command, args, options);
return { pid: 4242, unref: function () {} };
};
})()
|}]
let stop_spawn_capture () : unit =
[%mel.raw
{|
(function () {
const capture = globalThis.__logseqSpawnCapture;
if (!capture) return;
const childProcess = require("child_process");
childProcess.spawn = capture.originalSpawn;
delete globalThis.__logseqSpawnCapture;
})()
|}]
let spawn_capture_calls () : string =
[%mel.raw
{|
JSON.stringify((globalThis.__logseqSpawnCapture && globalThis.__logseqSpawnCapture.calls) || [])
|}]
let set_spawn_capture_on_spawn_raw : string -> string -> int -> unit =
[%mel.raw
{|
function (lockPath, serverListPath, port) {
const capture = globalThis.__logseqSpawnCapture;
if (!capture) throw new Error("spawn capture is not active");
capture.onSpawn = function () {
const fs = require("fs");
fs.writeFileSync(lockPath, JSON.stringify({
repo: "logseq_db_demo",
pid: process.pid,
"lock-id": "test",
"owner-source": "cli"
}));
fs.writeFileSync(serverListPath, process.pid + " " + port + "\n");
};
}
|}]
let set_spawn_capture_on_spawn ~lock_path ~server_list_path ~port : unit =
set_spawn_capture_on_spawn_raw lock_path server_list_path port
let set_env key value : unit = Js.Dict.set Node.Process.process##env key value
let unset_env_raw : string -> unit =
[%mel.raw {| function (key) { delete process.env[key]; } |}]
let unset_env key : unit = unset_env_raw key
let property_key_text = function
| Property.Key_ident ident -> keyword_text ident
| Property.Key_id id -> Int64.to_string id
| Property.Key_name name -> name
let empty_common_opts =
{
List_command.fields = None;
limit = None;
offset = None;
sort = None;
order = None;
}
let entity ?id ?title ?name ?ident ?updated_at ?created_at () =
let add_field fields key value =
Vec.push_back fields (Edn_util.keyword key, value)
in
let raw_fields =
( ( ( ( ( Vec.empty |> fun fields ->
match id with
| Some id -> add_field fields "db/id" (Edn_util.int64 id)
| None -> fields )
|> fun fields ->
match title with
| Some title ->
add_field fields "block/title" (Edn_util.string title)
| None -> fields )
|> fun fields ->
match name with
| Some name -> add_field fields "block/name" (Edn_util.string name)
| None -> fields )
|> fun fields ->
match ident with
| Some ident -> add_field fields "db/ident" (Edn_util.keyword ident)
| None -> fields )
|> fun fields ->
match updated_at with
| Some updated_at ->
add_field fields "block/updated-at" (Edn_util.int64 updated_at)
| None -> fields )
|> fun fields ->
match created_at with
| Some created_at ->
add_field fields "block/created-at" (Edn_util.int64 created_at)
| None -> fields
in
Entity.of_value (Edn_util.map_vec raw_fields)
let span stage span_id start_ms end_ms =
let start_time = Time.time_of_epoch_ms (Int64.of_int start_ms) in
let end_time = Time.time_of_epoch_ms (Int64.of_int end_ms) in
{
Profile_types.stage;
span_id;
start_time;
end_time;
elapsed_span = Time.non_negative_diff ~start_time ~end_time;
}
let stage_count stage summaries =
summaries
|> Vec.find_map (fun (summary : Profile_types.stage_summary) ->
if summary.stage = stage then Some summary.count else None)
let config ?graph ?repo ?output_format ?auth_path ?id_token ?access_token
?refresh_token ?raw_file_config ?list_title_max_display_width
?(root_dir = "/tmp/logseq-cli-test") () =
let defaults = Cli_config.defaults () in
{
Cli_config.graph = Option.map Cli_primitive.create_graph graph;
repo = Option.map Cli_primitive.create_repo repo;
root_dir;
config_path = Node.Path.join [| root_dir; "cli.edn" |];
timeout_span = defaults.timeout_span;
login_timeout_span = defaults.login_timeout_span;
logout_timeout_span = defaults.logout_timeout_span;
list_title_max_display_width =
Option.value list_title_max_display_width
~default:defaults.list_title_max_display_width;
output_format;
verbose = false;
profile = false;
ws_url = Some defaults.ws_url;
http_base = Some defaults.http_base;
auth_path;
id_token;
access_token;
refresh_token;
base_url = None;
owner_source = Cli_primitive.Cli;
project_dir = None;
raw_file_config;
profile_session = None;
}
let config_with_output config mode =
{ config with Cli_config.output_format = Some (Output.Mode.Packed mode) }
let execute_with_output execute action config mode =
execute action (config_with_output config mode)
let effect_result name task =
let value = ref None in
let error = ref None in
Cli_effect.on_any task
(fun result -> value := Some result)
(fun exn -> error := Some exn);
match (!value, !error) with
| Some value, None -> value
| None, Some exn ->
fail_test (name ^ ": " ^ Printexc.to_string exn);
assert false
| None, None ->
fail_test (name ^ ": effect is still pending");
assert false
| Some _, Some _ ->
fail_test (name ^ ": effect resolved and rejected");
assert false
let effect_to_promise task =
Js.Promise.make (fun ~resolve ~reject ->
Cli_effect.on_any task
(fun value -> (resolve value [@u]))
(fun exn -> (reject exn [@u])))
type lifecycle_output = {
stdout : string option;
stderr : string Rrbvec.t;
exit_code : int;
}
let run_cli_lifecycle ?(env = [||]) argv =
let result = spawn_cli ~env argv in
let stderr =
let stderr = string_trim_end result##stderr in
if stderr = "" then Vec.empty else Vec.of_array (string_split stderr "\n")
in
{ stdout = Some result##stdout; stderr; exit_code = result_status result }
let stdout_text name output = expect_some name output.stdout
let invoke_args body =
match Json_util.object_of_json_string body with
| Some object_ -> (
match
Option.bind (Js.Dict.get object_ "argsTransit") Js.Json.decodeString
with
| Some args -> Transit_melange.Transit.Json.(of_string args |> to_edn)
| None ->
fail_test ("missing invoke argsTransit: " ^ body);
Edn_util.vector_vec Vec.empty)
| None ->
fail_test ("invalid invoke JSON body: " ^ body);
Edn_util.vector_vec Vec.empty
let invoke_arg_string body index =
let args =
expect_some "invoke args vector" (Edn_util.as_seq (invoke_args body))
in
expect_some
("invoke arg " ^ string_of_int index)
(Edn_util.as_string (Vec.nth args index))
let sample_auth ?(id_token = Some "id-token-1")
?(access_token = Some "access-token-1")
?(refresh_token = Some "refresh-token-1") ?expires_at
?(updated_at = 1_735_686_000_000L) ?(sub = Some "user-123")
?(email = Some "user@example.com") () : Auth_state.auth_data =
{
provider = "cognito";
id_token;
access_token;
refresh_token;
expires_at;
sub;
email;
updated_at = Time.time_of_epoch_ms updated_at;
}
let env_from_pairs pairs key = Vec.assoc_opt key (Vec.of_array pairs)
let resolve_config ?(env = [||]) globals =
let resolved =
expect_ok "resolve config"
(effect_result "resolve config"
(Cli_config.resolve ~defaults:(Cli_config.defaults ())
~env:(env_from_pairs env) globals))
in
resolved.Cli_config.config
let mode_text (Output.Mode.Packed mode) = Output.Mode.to_string mode
let agent_task_entity ?(uuid = Some "11111111-1111-4111-8111-111111111111")
?(tags = [| "logseq.class/Task" |]) ?(status = Some "todo")
?(assignees = [| "build-host" |]) ?session_id () =
let tag_values =
tags |> Vec.of_array
|> Vec.map (fun ident ->
Edn_util.map_vec
(Vec.of_array
[|
(Edn_util.keyword "db/ident", Edn_util.keyword ident);
(Edn_util.keyword "block/title", Edn_util.string "Task");
|]))
in
let assignee_values =
assignees |> Vec.of_array
|> Vec.map (fun title ->
Edn_util.map_vec
(Vec.of_array
[| (Edn_util.keyword "block/title", Edn_util.string title) |]))
in
let fields =
Vec.of_array
[|
(Edn_util.keyword "db/id", Edn_util.int64 42L);
(Edn_util.keyword "block/title", Edn_util.string "Ship the CLI bridge");
(Edn_util.keyword "block/tags", Edn_util.vector_vec tag_values);
( Edn_util.keyword "logseq.property/assignee",
Edn_util.vector_vec assignee_values );
|]
in
let fields =
match uuid with
| Some uuid ->
Vec.push_front fields
(Edn_util.keyword "block/uuid", Edn_util.string uuid)
| None -> fields
in
let fields =
match status with
| Some status ->
Vec.push_front fields
( Edn_util.keyword "logseq.property/status",
Edn_util.map_vec
(Vec.of_array
[|
( Edn_util.keyword "db/ident",
Edn_util.keyword ("logseq.property/status." ^ status) );
|]) )
| None -> fields
in
let fields =
match session_id with
| Some session_id ->
Vec.push_front fields
( Edn_util.keyword "logseq.property.agent/session-id",
Edn_util.string session_id )
| None -> fields
in
Entity.of_value (Edn_util.map_vec fields)
let routable_reason_text = function
| Agent.Missing_stable_uuid -> "missing-stable-uuid"
| Missing_task_tag -> "missing-task-tag"
| Not_todo -> "not-todo"
| Assignee_mismatch -> "assignee-mismatch"
| Already_routed -> "already-routed"
let () =
test "CLI parity output modes parse and mark structured modes" (fun () ->
let mode_name = function
| Output.Mode.Packed mode -> Output.Mode.to_string mode
in
expect_equal "human mode" "human"
(mode_name (expect_some "human" (Output.Mode.of_string "human")));
expect_equal "json mode" "json"
(mode_name (expect_some "json" (Output.Mode.of_string "json")));
expect_equal "edn mode" "edn"
(mode_name (expect_some "edn" (Output.Mode.of_string "edn")));
expect_none "invalid output mode" (Output.Mode.of_string "yaml");
expect_bool "human structured" false (Output.Mode.structured Human);
expect_bool "json structured" true (Output.Mode.structured Json);
expect_bool "edn structured" true (Output.Mode.structured Edn));
test "CLI parity primitive parsers keep graph repo shell and uuid contracts"
(fun () ->
let graph = Cli_primitive.create_graph "demo" in
let repo = Cli_config.graph_to_repo graph in
expect_equal "graph text" "demo" (Cli_primitive.string_of_graph graph);
expect_equal "repo text" "logseq_db_demo"
(Cli_primitive.string_of_repo repo);
expect_equal "repo to graph" "demo"
(Cli_primitive.string_of_graph (Cli_config.repo_to_graph repo));
expect_bool "uuid valid" true
(Cli_primitive.is_uuid_string "00000000-0000-4000-8000-000000000001");
expect_bool "uuid invalid" false
(Cli_primitive.is_uuid_string "not-a-uuid");
(match expect_some "bash" (Cli_primitive.shell_of_string "bash") with
| Cli_primitive.Bash -> pass
| _ -> fail_test "expected bash shell");
expect_none "unknown shell" (Cli_primitive.shell_of_string "fish"));
test "CLI parity ustring decodes utf8 byte strings to JS unicode strings"
(fun () ->
let ascii = Ustring.of_string "ABC" |> Ustring.to_string in
expect_equal "ascii text" "ABC" ascii;
let utf8_byte_string =
Js.String.fromCharCode 0xe4
^ Js.String.fromCharCode 0xb8
^ Js.String.fromCharCode 0xad
in
let decoded = Ustring.of_string utf8_byte_string |> Ustring.to_string in
expect_equal "decoded unicode text" (unicode_text [| 0x4e2d |]) decoded;
expect_int "decoded JS length" 1 (String.length decoded);
expect_int "decoded char code" 0x4e2d (string_char_code_at decoded 0);
let latin1_unicode =
"Diese Vorteile " ^ unicode_text [| 0x00f6 |] ^ " und "
^ unicode_text [| 0x00fc |]
in
let preserved = Ustring.of_string latin1_unicode |> Ustring.to_string in
expect_equal "latin1 supplement unicode text" latin1_unicode preserved;
expect_int "latin1 supplement char code" 0x00f6
(string_char_code_at preserved 15);
expect_int "latin1 supplement char code 2" 0x00fc
(string_char_code_at preserved 21));
test
"CLI parity id parser accepts single multi comma whitespace and vector \
input" (fun () ->
let single = expect_ok "single id" (Id_parse.parse_id_string "42") in
expect_bool "single multi" false single.multi;
expect_int64 "single id value" 42L
(expect_some "single" (Id_parse.to_single single));
let multi =
expect_ok "multi ids" (Id_parse.parse_id_string "[1, 2 3\t4]")
in
expect_bool "multi flag" true multi.multi;
expect_int "multi length" 4 (Vec.length multi.ids);
expect_none "multi to_single" (Id_parse.to_single multi);
expect_error_code "zero id" ":invalid-options"
(Id_parse.parse_id_string "0");
expect_error_code "non numeric id" ":invalid-options"
(Id_parse.parse_id_string "abc"));
test
"CLI parity graph directory names preserve canonical and old-format \
encoding" (fun () ->
expect_equal "space graph dir" "space name"
(Graph_dir.encode_graph_dir_name "space name");
expect_equal "slash graph dir" "old~2Fname"
(Graph_dir.encode_graph_dir_name "old/name");
expect_equal "tilde graph dir" "tilde~7Ename"
(Graph_dir.encode_graph_dir_name "tilde~name");
expect_equal "decode canonical" "old/name"
(expect_some "canonical"
(Graph_dir.canonical_graph_name_of_dir "old~2Fname"));
expect_none "old-format dir is not canonical"
(Graph_dir.canonical_graph_name_of_dir "old++name");
expect_equal "decode old-format slash" "old/name"
(expect_some "old-format"
(Graph_dir.decode_legacy_graph_dir_name "old++name")));
test "CLI parity unlink graph moves canonical encoded dir to unlinked dir"
(fun () ->
let root = temp_dir "logseq-cli-parity-unlink-graph-" in
let graphs_dir = Node.Path.join [| root; "graphs" |] in
let graph = Cli_primitive.create_graph "foo/bar" in
let repo = Cli_config.graph_to_repo graph in
let encoded_dir = Graph_dir.encode_graph_dir_name "foo/bar" in
let graph_path = Node.Path.join [| graphs_dir; encoded_dir |] in
let unlinked_path =
Node.Path.join [| graphs_dir; "Unlinked graphs"; encoded_dir |]
in
try
mkdir_p graph_path;
write_file (Node.Path.join [| graph_path; "db.sqlite" |]) "test-data";
let result =
effect_result "graph remove slash"
(execute_with_output Graph.execute
(Graph.Graph_remove { graph; repo })
(config ~root_dir:root ()) Output.Mode.Human)
in
expect_bool "graph remove ok" true
(result.Cli_result.status = Cli_result.Ok);
expect_bool "source removed" false (Cli_unix.file_exists graph_path);
expect_bool "destination exists" true
(Cli_unix.file_exists unlinked_path);
expect_equal "contents preserved" "test-data"
(read_file (Node.Path.join [| unlinked_path; "db.sqlite" |]));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test
"CLI parity unlink graph moves space-preserving canonical dir to unlinked \
dir" (fun () ->
let root = temp_dir "logseq-cli-parity-unlink-space-" in
let graphs_dir = Node.Path.join [| root; "graphs" |] in
let graph = Cli_primitive.create_graph "space name" in
let repo = Cli_config.graph_to_repo graph in
let graph_path = Node.Path.join [| graphs_dir; "space name" |] in
let unlinked_path =
Node.Path.join [| graphs_dir; "Unlinked graphs"; "space name" |]
in
try
mkdir_p graph_path;
write_file (Node.Path.join [| graph_path; "db.sqlite" |]) "test-data";
let result =
effect_result "graph remove space"
(execute_with_output Graph.execute
(Graph.Graph_remove { graph; repo })
(config ~root_dir:root ()) Output.Mode.Human)
in
expect_bool "space graph remove ok" true
(result.Cli_result.status = Cli_result.Ok);
expect_bool "space source removed" false
(Cli_unix.file_exists graph_path);
expect_bool "space destination exists" true
(Cli_unix.file_exists unlinked_path);
expect_equal "space contents preserved" "test-data"
(read_file (Node.Path.join [| unlinked_path; "db.sqlite" |]));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity root-dir helpers create directories and reject files"
(fun () ->
let root = temp_dir "logseq-cli-parity-root-" in
let nested = Node.Path.join [| root; "a"; "b" |] in
let file_path = Node.Path.join [| root; "file" |] in
try
let ensured =
expect_ok "ensure nested"
(Root_dir_types.ensure_root_dir (Some nested))
in
expect_bool "nested exists" true (Node.Fs.existsSync ensured);
write_file file_path "not a dir";
expect_error_code "reject file" ":root-dir-permission"
(Root_dir_types.ensure_root_dir (Some file_path));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity root-dir defaults and normalization stay stable" (fun () ->
let home = Sys.getenv_opt "HOME" |> Option.value ~default:"." in
let default_root = Node.Path.join [| home; "logseq" |] in
expect_equal "default root" default_root
(Root_dir_types.normalize_root_dir None);
expect_equal "relative root"
(Node.Path.join [| Sys.getcwd (); "relative-logseq" |])
(Root_dir_types.normalize_root_dir (Some "relative-logseq"));
expect_equal "home root"
(Node.Path.join [| home; "custom-logseq" |])
(Root_dir_types.normalize_root_dir (Some "~/custom-logseq")));
test "CLI parity root-dir rejects read-only directories" (fun () ->
if Node.Process.process##platform = "win32" then pass
else
let root = temp_dir "logseq-cli-parity-root-readonly-" in
try
chmod_sync root 0o555;
expect_error_code "read-only root" ":root-dir-permission"
(Root_dir_types.ensure_root_dir (Some root));
chmod_sync root 0o755;
remove_tree root
with exn ->
chmod_sync root 0o755;
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity auth default path follows HOME logseq auth.json" (fun () ->
let home = Sys.getenv_opt "HOME" |> Option.value ~default:"." in
let expected = Node.Path.join [| home; "logseq"; "auth.json" |] in
expect_equal "default auth path" expected
(Auth_state.default_auth_path ()));
test "CLI parity auth file write read and delete round-trips token data"
(fun () ->
let root = temp_dir "logseq-cli-parity-auth-" in
let auth_path =
Node.Path.join [| root; "nested"; "tokens"; "auth.json" |]
in
let config = config ~root_dir:root ~auth_path () in
let auth = sample_auth () in
try
expect_bool "auth dir missing" false
(Node.Fs.existsSync (Node.Path.dirname auth_path));
ignore
(expect_ok "write auth"
(effect_result "write auth"
(Auth_state.write_auth_file config auth)));
expect_bool "auth dir exists" true
(Node.Fs.existsSync (Node.Path.dirname auth_path));
expect_bool "auth file exists" true (Node.Fs.existsSync auth_path);
let stored =
expect_some "stored auth"
(expect_ok "read auth"
(effect_result "read auth" (Auth_state.read_auth_file config)))
in
expect_equal "provider" auth.provider stored.provider;
expect_equal "id-token" "id-token-1"
(expect_some "id-token" stored.id_token);
expect_equal "access-token" "access-token-1"
(expect_some "access-token" stored.access_token);
expect_equal "refresh-token" "refresh-token-1"
(expect_some "refresh-token" stored.refresh_token);
expect_equal "sub" "user-123" (expect_some "sub" stored.sub);
expect_equal "email" "user@example.com"
(expect_some "email" stored.email);
ignore
(expect_ok "delete auth"
(effect_result "delete auth" (Auth_state.delete_auth_file config)));
expect_bool "auth file deleted" false (Node.Fs.existsSync auth_path);
ignore
(expect_ok "delete auth idempotent"
(effect_result "delete auth again"
(Auth_state.delete_auth_file config)));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test
"CLI parity auth read returns none when missing and errors on invalid json"
(fun () ->
let root = temp_dir "logseq-cli-parity-auth-read-" in
let missing_path = Node.Path.join [| root; "missing"; "auth.json" |] in
let invalid_path = Node.Path.join [| root; "auth.json" |] in
try
let missing =
expect_ok "missing auth"
(effect_result "read missing auth"
(Auth_state.read_auth_file
(config ~root_dir:root ~auth_path:missing_path ())))
in
expect_none "missing auth none" missing;
write_file invalid_path "{\"provider\":";
expect_error_code "invalid auth json" "invalid-auth-file"
(effect_result "read invalid auth"
(Auth_state.read_auth_file
(config ~root_dir:root ~auth_path:invalid_path ())));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity auth expired status uses expires-at strictly" (fun () ->
expect_bool "missing expires-at is expired" true
(Auth_state.expired_auth (sample_auth ()));
expect_bool "past expires-at is expired" true
(Auth_state.expired_auth
(sample_auth ~expires_at:(Time.time_of_epoch_ms 0L) ()));
expect_bool "future expires-at is not expired" false
(Auth_state.expired_auth (sample_auth ~expires_at:Time.max_time ())));
test "CLI parity auth read supports old underscore fields and config tokens"
(fun () ->
let root = temp_dir "logseq-cli-parity-auth-compat-" in
let auth_path = Node.Path.join [| root; "auth.json" |] in
try
write_file auth_path
"{\"id_token\":\"id-token-2\",\"access_token\":\"access-token-2\",\"refresh_token\":\"refresh-token-2\",\"updated_at\":1735686000000}";
let underscore =
expect_ok "read underscore auth"
(effect_result "read underscore auth"
(Auth_state.read_auth_file (config ~root_dir:root ~auth_path ())))
in
let underscore = expect_some "underscore auth" underscore in
expect_equal "default provider" "cognito" underscore.provider;
expect_equal "underscore id token" "id-token-2"
(expect_some "underscore id token" underscore.id_token);
expect_int64 "underscore updated-at" 1_735_686_000_000L
(Time.time_to_epoch_ms underscore.updated_at);
let from_config =
expect_ok "resolve config auth"
(effect_result "resolve config auth"
(Auth_state.resolve_auth
(config ~id_token:"id-token-cfg"
~access_token:"access-token-cfg"
~refresh_token:"refresh-token-cfg" ())))
in
expect_equal "config auth provider" "cognito" from_config.provider;
expect_equal "config auth id token" "id-token-cfg"
(expect_some "config id token" from_config.id_token);
expect_none "config auth expires-at" from_config.expires_at;
expect_error_code "resolve missing auth" "missing-auth"
(effect_result "resolve missing auth"
(Auth_state.resolve_auth
(config ~root_dir:root
~auth_path:(Node.Path.join [| root; "missing.json" |])
())));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test_promise
"CLI parity auth refresh uses token endpoint and validates id token"
(fun () ->
let request_bodies = ref Vec.empty in
let token_server response_body =
create_server (fun[@u] req res ->
let body = ref "" in
req_set_encoding req "utf8";
req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk);
req_on_end req "end" (fun[@u] () ->
request_bodies := Vec.push_back !request_bodies !body;
if req_method req <> "POST" || req_url req <> "/oauth2/token"
then write_json res 404 (error_response "not found")
else write_json res 200 response_body))
in
let* () =
with_server
(token_server
"{\"id_token\":\"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJmcmVzaC11c2VyIiwiZW1haWwiOiJmcmVzaEBleGFtcGxlLmNvbSIsImV4cCI6NDEwMjQ0NDgwMH0.signature\",\"access_token\":\"fresh-access-token\"}")
(fun base_url ->
let raw_config =
Edn_util.map
[
( Edn_util.keyword "oauth-token-endpoint",
Edn_util.string (base_url ^ "/oauth2/token") );
( Edn_util.keyword "oauth-client-id",
Edn_util.string "client-1" );
]
in
let config =
config ~raw_file_config:raw_config
~refresh_token:"refresh-token-cfg" ()
in
let* refreshed =
effect_to_promise
(Auth_state.refresh_auth config
(sample_auth ~id_token:(Some "expired-id")
~access_token:(Some "expired-access")
~refresh_token:(Some "refresh-token-1")
~expires_at:(Time.time_of_epoch_ms 0L) ()))
in
let refreshed = expect_ok "refresh auth" refreshed in
expect_equal "fresh id token sub" "fresh-user"
(expect_some "fresh sub" refreshed.sub);
expect_equal "fresh email" "fresh@example.com"
(expect_some "fresh email" refreshed.email);
expect_equal "fresh access token" "fresh-access-token"
(expect_some "fresh access token" refreshed.access_token);
expect_equal "refresh token preserved" "refresh-token-1"
(expect_some "fresh refresh token" refreshed.refresh_token);
let body = Vec.string_concat "\n" !request_bodies in
expect_named_contains "refresh grant type" body
"grant_type=refresh_token";
expect_named_contains "refresh token body" body
"refresh_token=refresh-token-1";
expect_named_contains "refresh client id" body "client_id=client-1";
Js.Promise.resolve pass)
in
with_server
(token_server
"{\"access_token\":\"access-token-only\",\"refresh_token\":\"refresh-token-2\"}")
(fun base_url ->
let raw_config =
Edn_util.map
[
( Edn_util.keyword "oauth-token-endpoint",
Edn_util.string (base_url ^ "/oauth2/token") );
]
in
let* result =
effect_to_promise
(Auth_state.refresh_auth
(config ~raw_file_config:raw_config ())
(sample_auth ~refresh_token:(Some "refresh-token-1") ()))
in
expect_error_code "missing id token" "missing-id-token" result;
Js.Promise.resolve pass));
test_promise "CLI parity auth refresh returns precise token errors" (fun () ->
let token_server response_body =
create_server (fun[@u] req res ->
let body = ref "" in
req_set_encoding req "utf8";
req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk);
req_on_end req "end" (fun[@u] () ->
ignore !body;
if req_method req <> "POST" || req_url req <> "/oauth2/token"
then write_json res 404 (error_response "not found")
else write_json res 200 response_body))
in
let config_for base_url =
config
~raw_file_config:
(Edn_util.map
[
( Edn_util.keyword "oauth-token-endpoint",
Edn_util.string (base_url ^ "/oauth2/token") );
])
()
in
let* () =
with_server
(token_server
"{\"id_token\":\"not-a-jwt\",\"access_token\":\"access-token\"}")
(fun base_url ->
let* result =
effect_to_promise
(Auth_state.refresh_auth (config_for base_url)
(sample_auth ~refresh_token:(Some "refresh-token-1") ()))
in
expect_error_code "invalid token" "invalid-auth-token" result;
Js.Promise.resolve pass)
in
let missing_result = ref None in
let* () =
with_server (token_server "{\"access_token\":\"access-token-only\"}")
(fun base_url ->
let* result =
effect_to_promise
(Auth_state.refresh_auth (config_for base_url)
(sample_auth ~refresh_token:(Some "refresh-token-1") ()))
in
missing_result := Some result;
Js.Promise.resolve pass)
in
let result = expect_some "missing id token result" !missing_result in
match result with
| Ok _ ->
fail_test "missing id token: expected Error";
Js.Promise.resolve pass
| Error err ->
expect_equal "missing id token code" "missing-id-token"
(Error.code_to_string err.Error.code);
Js.Promise.resolve pass);
test_promise
"CLI parity resolve auth refreshes expired file auth and stores it"
(fun () ->
let root = temp_dir "logseq-cli-parity-auth-resolve-refresh-" in
let auth_path = Node.Path.join [| root; "auth.json" |] in
let refreshed_id_token =
id_token ~sub:"resolved-user" ~email:"resolved@example.com" ()
in
let response_body =
Printf.sprintf
"{\"id_token\":%s,\"access_token\":\"resolved-access-token\",\"refresh_token\":\"resolved-refresh-token\"}"
(Js.Json.stringify (Js.Json.string refreshed_id_token))
in
let server =
create_server (fun[@u] req res ->
let body = ref "" in
req_set_encoding req "utf8";
req_on_data req "data" (fun[@u] chunk -> body := !body ^ chunk);
req_on_end req "end" (fun[@u] () ->
ignore !body;
if req_method req <> "POST" || req_url req <> "/oauth2/token"
then write_json res 404 (error_response "not found")
else write_json res 200 response_body))
in
with_server server (fun base_url ->
let raw_config =
Edn_util.map
[
( Edn_util.keyword "oauth-token-endpoint",
Edn_util.string (base_url ^ "/oauth2/token") );
]
in
let config =
config ~root_dir:root ~auth_path ~raw_file_config:raw_config ()
in
let* written =
effect_to_promise
(Auth_state.write_auth_file config
(sample_auth ~id_token:(Some "expired-id-token")
~access_token:(Some "expired-access-token")
~refresh_token:(Some "refresh-token-1")
~expires_at:(Time.time_of_epoch_ms 0L) ()))
in
ignore (expect_ok "write expired auth" written);
let* resolved = effect_to_promise (Auth_state.resolve_auth config) in
let resolved = expect_ok "resolve refreshed auth" resolved in
expect_equal "resolved sub" "resolved-user"
(expect_some "resolved sub" resolved.sub);
expect_equal "resolved email" "resolved@example.com"
(expect_some "resolved email" resolved.email);
expect_equal "resolved access" "resolved-access-token"
(expect_some "resolved access" resolved.access_token);
let* stored = effect_to_promise (Auth_state.read_auth_file config) in
let stored =
expect_some "stored refreshed auth"
(expect_ok "read refreshed auth" stored)
in
expect_equal "stored id token" refreshed_id_token
(expect_some "stored id token" stored.id_token);
expect_equal "stored refresh token" "resolved-refresh-token"
(expect_some "stored refresh token" stored.refresh_token);
remove_tree root;
Js.Promise.resolve pass));
test "CLI parity auth commands parse and build login logout actions"
(fun () ->
let login_request = expect_parse_ok "login parse" [| "login" |] in
(match login_request.command with
| Cli_request.Auth Auth_command.Parsed_login -> pass
| _ -> fail_test "login parse: expected auth login");
let logout_request = expect_parse_ok "logout parse" [| "logout" |] in
(match logout_request.command with
| Cli_request.Auth Auth_command.Parsed_logout -> pass
| _ -> fail_test "logout parse: expected auth logout");
(match
Auth_command.build (config ()) (Global_opts.create ())
Auth_command.Parsed_login
with
| Ok Auth_command.Login -> pass
| Ok _ -> fail_test "login build: expected Login"
| Error err -> fail_test ("login build: " ^ err.Error.message));
match
Auth_command.build (config ()) (Global_opts.create ())
Auth_command.Parsed_logout
with
| Ok Auth_command.Logout -> pass
| Ok _ -> fail_test "logout build: expected Logout"
| Error err -> fail_test ("logout build: " ^ err.Error.message));
test "CLI parity Windows open_url keeps query params inside start URL"
(fun () ->
let url =
"https://logseq-prod.auth.us-east-1.amazoncognito.com/oauth2/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fauth%2Fcallback&state=xyz&code_challenge=pkce&code_challenge_method=S256"
in
try
start_open_url_capture ();
expect_bool "open url result" true (Cli_unix.open_url url);
let calls = open_url_capture_calls () in
stop_open_url_capture ();
expect_named_contains "windows open command" calls
"\"command\":\"cmd.exe\"";
expect_named_contains "windows verbatim arguments" calls
"\"windowsVerbatimArguments\":true";
expect_named_contains "windows quoted start url" calls
("start \\\"\\\" \\\"" ^ url ^ "\\\"");
expect_named_not_contains "windows split start args" calls
("\"start\",\"\",\"" ^ url ^ "\"")
with exn ->
stop_open_url_capture ();
fail_test (Printexc.to_string exn));
test "CLI parity auth logout command deletes file and reports absent file"
(fun () ->
let root = temp_dir "logseq-cli-parity-auth-logout-" in
let auth_path = Node.Path.join [| root; "auth.json" |] in
let raw_config =
Edn_util.map
[
(Edn_util.keyword "open-browser", Edn_util.bool false);
(Edn_util.keyword "oauth-logout-state", Edn_util.string "state-1");
]
in
let config =
{
(config ~root_dir:root ~auth_path ()) with
raw_file_config = Some raw_config;
}
in
let deleted_flag result =
let data = expect_some "logout data" (Cli_result.data_value result) in
expect_some "deleted flag" (Edn_util.get_bool data "deleted")
in
try
ignore
(expect_ok "write auth"
(effect_result "write auth"
(Auth_state.write_auth_file config (sample_auth ()))));
let result =
effect_result "logout existing"
(execute_with_output Auth_command.execute Auth_command.Logout config
Output.Mode.Json)
in
expect_bool "logout status" false (Cli_result.is_error result);
expect_bool "deleted existing" true (deleted_flag result);
expect_bool "auth file removed" false (Node.Fs.existsSync auth_path);
let missing_result =
effect_result "logout missing"
(execute_with_output Auth_command.execute Auth_command.Logout config
Output.Mode.Json)
in
expect_bool "missing logout status" false
(Cli_result.is_error missing_result);
expect_bool "deleted missing" false (deleted_flag missing_result);
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity config resolves argv over env over file" (fun () ->
let root = temp_dir "logseq-cli-parity-config-" in
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
try
write_file cfg_path
"{:graph \"file-graph\" :root-dir \"file-root\" :timeout-ms 111 \
:login-timeout-ms 444 :logout-timeout-ms 555 :output-format :edn \
:auth-token \"legacy-secret\" :retries 2 :e2ee-password \
\"legacy-password\"}\n";
let config =
resolve_config
~env:
[|
("LOGSEQ_CLI_GRAPH", "env-graph");
("LOGSEQ_CLI_ROOT_DIR", "env-root");
("LOGSEQ_CLI_TIMEOUT_MS", "222");
("LOGSEQ_CLI_LOGIN_TIMEOUT_MS", "666");
("LOGSEQ_CLI_LOGOUT_TIMEOUT_MS", "777");
("LOGSEQ_CLI_OUTPUT", "json");
|]
(Global_opts.create
~graph:(Cli_primitive.create_graph "argv-graph")
~root_dir:"argv-root" ~config_path:cfg_path
~timeout_span:(Time.span_of_ms 333L)
~output_format:(Output.Mode.Packed Output.Mode.Human) ())
in
expect_equal "config path" cfg_path config.config_path;
expect_equal "graph" "argv-graph"
(Cli_primitive.string_of_graph (expect_some "graph" config.graph));
expect_equal "root-dir" "argv-root" config.root_dir;
expect_int64 "timeout" 333L (Time.span_to_ms config.timeout_span);
expect_int64 "login timeout" 666L
(Time.span_to_ms config.login_timeout_span);
expect_int64 "logout timeout" 777L
(Time.span_to_ms config.logout_timeout_span);
expect_equal "output" "human"
(mode_text (expect_some "output" config.output_format));
(match config.raw_file_config with
| Some raw ->
expect_none "sanitized auth-token" (Edn_util.get raw "auth-token");
expect_none "sanitized retries" (Edn_util.get raw "retries");
expect_none "sanitized e2ee-password"
(Edn_util.get raw "e2ee-password")
| None -> fail_test "missing raw file config");
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity config env output overrides file output" (fun () ->
let root = temp_dir "logseq-cli-parity-config-output-" in
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
try
write_file cfg_path "{:output-format :edn}\n";
let config =
resolve_config
~env:[| ("LOGSEQ_CLI_OUTPUT", "json") |]
(Global_opts.create ~config_path:cfg_path ())
in
expect_equal "env output" "json"
(mode_text (expect_some "output" config.output_format));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity config output-format key overrides old output key" (fun () ->
let root = temp_dir "logseq-cli-parity-config-output-key-" in
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
try
write_file cfg_path "{:output :json :output-format :edn}\n";
let config =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
expect_equal "output-format wins" "edn"
(mode_text (expect_some "output" config.output_format));
write_file cfg_path "{:output :json}\n";
let config =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
expect_equal "old output fallback" "json"
(mode_text (expect_some "output" config.output_format));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity config defaults use standard root URLs and timeouts"
(fun () ->
let root = temp_dir "logseq-cli-parity-config-default-" in
let cfg_path = Node.Path.join [| root; "missing-cli.edn" |] in
try
let config =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
expect_equal "config path" cfg_path config.config_path;
expect_equal "root-dir"
(Node.Path.join
[| Sys.getenv_opt "HOME" |> Option.value ~default:"."; "logseq" |])
config.root_dir;
expect_equal "ws-url" "wss://api.logseq.io/sync/%s"
(expect_some "ws-url" config.ws_url);
expect_equal "http-base" "https://api.logseq.io"
(expect_some "http-base" config.http_base);
expect_int64 "timeout" 10_000L (Time.span_to_ms config.timeout_span);
expect_int64 "login timeout" 300_000L
(Time.span_to_ms config.login_timeout_span);
expect_int64 "logout timeout" 120_000L
(Time.span_to_ms config.logout_timeout_span);
expect_int "title max width" 40 config.list_title_max_display_width;
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity empty config file uses defaults" (fun () ->
let root = temp_dir "logseq-cli-parity-config-empty-" in
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
try
write_file cfg_path " \n\t";
let config =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
expect_equal "config path" cfg_path config.config_path;
expect_equal "root-dir"
(Node.Path.join
[| Sys.getenv_opt "HOME" |> Option.value ~default:"."; "logseq" |])
config.root_dir;
expect_equal "ws-url" "wss://api.logseq.io/sync/%s"
(expect_some "ws-url" config.ws_url);
expect_equal "http-base" "https://api.logseq.io"
(expect_some "http-base" config.http_base);
expect_int64 "timeout" 10_000L (Time.span_to_ms config.timeout_span);
expect_int64 "login timeout" 300_000L
(Time.span_to_ms config.login_timeout_span);
expect_int64 "logout timeout" 120_000L
(Time.span_to_ms config.logout_timeout_span);
expect_int "title max width" 40 config.list_title_max_display_width;
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity config path follows root-dir unless explicitly set"
(fun () ->
let root = temp_dir "logseq-cli-parity-config-path-" in
let custom_root = Node.Path.join [| root; "custom-logseq" |] in
let explicit_cfg =
Node.Path.join [| root; "nested"; "custom-cli.edn" |]
in
try
let derived =
resolve_config (Global_opts.create ~root_dir:custom_root ())
in
expect_equal "derived root" custom_root derived.root_dir;
expect_equal "derived config path"
(Node.Path.join [| custom_root; "cli.edn" |])
derived.config_path;
let explicit =
resolve_config
(Global_opts.create ~root_dir:custom_root ~config_path:explicit_cfg
())
in
expect_equal "explicit root" custom_root explicit.root_dir;
expect_equal "explicit config path" explicit_cfg explicit.config_path;
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity server runtime dirs are derived from resolved root"
(fun () ->
let config = config ~root_dir:"/tmp/logseq-root" () in
expect_equal "resolved root" "/tmp/logseq-root"
(Server_runtime.resolve_root_dir config);
expect_equal "graphs dir" "/tmp/logseq-root/graphs"
(Server_runtime.graphs_dir config));
test
"CLI parity config list title max width reads positive and defaults \
non-positive" (fun () ->
let root = temp_dir "logseq-cli-parity-title-width-" in
try
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
write_file cfg_path "{:list-title-max-display-width 72}\n";
let config =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
expect_int "positive width" 72 config.list_title_max_display_width;
write_file cfg_path "{:list-title-max-display-width 0}\n";
let config =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
expect_int "zero width default" 40 config.list_title_max_display_width;
write_file cfg_path "{:list-title-max-display-width -3}\n";
let config =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
expect_int "negative width default" 40
config.list_title_max_display_width;
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity config invalid list title width fails fast" (fun () ->
let root = temp_dir "logseq-cli-parity-title-width-invalid-" in
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
try
write_file cfg_path "{:list-title-max-display-width \"abc\"}\n";
let result =
effect_result "resolve invalid title width fallback"
(Cli_config.resolve ~defaults:(Cli_config.defaults ())
~env:(env_from_pairs [||])
(Global_opts.create ~config_path:cfg_path ()))
in
(match result with
| Ok _ -> fail_test "invalid width: expected Error"
| Error err ->
expect_equal "invalid width code" "invalid-config"
(Error.code_to_string err.Error.code);
expect_named_contains "invalid width message" err.Error.message
"Expected integer");
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity config invalid output fails fast" (fun () ->
let root = temp_dir "logseq-cli-parity-invalid-output-" in
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
try
write_file cfg_path "{:output-format :edn}\n";
let result =
effect_result "resolve invalid output fallback"
(Cli_config.resolve ~defaults:(Cli_config.defaults ())
~env:(env_from_pairs [| ("LOGSEQ_CLI_OUTPUT", "yaml") |])
(Global_opts.create ~config_path:cfg_path ()))
in
(match result with
| Ok _ -> fail_test "invalid output: expected Error"
| Error err ->
expect_equal "invalid output code" "invalid-config"
(Error.code_to_string err.Error.code);
expect_named_contains "invalid output message" err.Error.message
"Expected one of human, json, edn");
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test
"CLI parity uuid ref helpers extract unique lowercase refs and replace \
labels" (fun () ->
let first = "11111111-1111-4111-8111-111111111111" in
let upper = "22222222-2222-4222-8222-222222222222" in
let text =
"A [[" ^ first ^ "]] B [[not-a-uuid]] C [["
^ String.uppercase_ascii upper
^ "]] A [[" ^ first ^ "]]"
in
let refs = Uuid_refs_types.extract_uuid_refs text in
expect_int "uuid ref count" 2 (Vec.length refs);
expect_equal "first uuid" first (Vec.nth refs 0);
expect_equal "second uuid" upper (Vec.nth refs 1);
let replaced =
Uuid_refs_types.replace_uuid_refs text
(Vec.of_array [| (first, "First"); (upper, "Second") |])
in
expect_named_contains "replace first" replaced "[[First]]";
expect_named_contains "replace second" replaced "[[Second]]";
expect_named_contains "keep invalid" replaced "[[not-a-uuid]]");
test "CLI parity uuid refs are collected from entity string fields" (fun () ->
let uuid = "33333333-3333-4333-8333-333333333333" in
let items =
Vec.of_array
[|
entity ~title:("Title [[" ^ uuid ^ "]]") ();
entity ~name:("Name [[" ^ uuid ^ "]]") ();
|]
in
let refs =
Uuid_refs_types.collect_uuid_refs_from_items items
(Vec.of_array
[|
Edn_util.keyword_t "block/title"; Edn_util.keyword_t "block/name";
|])
in
expect_int "collected uuid refs" 1 (Vec.length refs);
expect_equal "collected uuid" uuid (Vec.peek_front refs));
test "CLI parity human count helpers use grouped counts and pluralization"
(fun () ->
expect_equal "count" "1,234" (Humanize_types.format_count 1234);
expect_equal "singular" "1 graph"
(Humanize_types.format_count_with_noun 1 "graph");
expect_equal "plural" "2 graphs"
(Humanize_types.format_count_with_noun 2 "graph"));
test "CLI parity display width truncates ascii cjk and combining text"
(fun () ->
let cjk = decode_uri_component "%E7%95%8C" in
let combining_acute = decode_uri_component "%CC%81" in
let o_umlaut = decode_uri_component "%C3%B6" in
let accented_title =
decode_uri_component
"Diese%20Vorteile%20k%C3%B6nnten%20besonders%20f%C3%BCr%20eine"
in
let repeat count value =
Vec.string_concat "" (Vec.init count (fun _ -> value))
in
expect_int "ascii width" 5 (Display_width.width "Hello");
expect_int "cjk width" 2 (Display_width.width cjk);
expect_int "combining width" 1
(Display_width.width ("e" ^ combining_acute));
expect_int "latin-1 code point width" 1 (Display_width.width o_umlaut);
expect_int "latin-1 followed by ascii width" 4
(Display_width.width (o_umlaut ^ "abc"));
expect_int "accented latin title width" 41
(Display_width.width accented_title);
expect_equal "short segment" "Project Alpha"
(Display_width.truncate "Project Alpha" 24);
expect_equal "exact ascii segment" "123456789012345678901234"
(Display_width.truncate "123456789012345678901234" 24);
expect_equal "one over ascii segment"
("12345678901234567890123" ^ Cli_platform.Symbols.ellipsis)
(Display_width.truncate "1234567890123456789012345" 24);
expect_equal "exact cjk segment" (repeat 12 cjk)
(Display_width.truncate (repeat 12 cjk) 24);
expect_equal "one over cjk segment"
(repeat 11 cjk ^ Cli_platform.Symbols.ellipsis)
(Display_width.truncate (repeat 13 cjk) 24);
expect_equal "accented latin title truncation"
(decode_uri_component
"Diese%20Vorteile%20k%C3%B6nnten%20besonders%20f%C3%BCr%20ei"
^ Cli_platform.Symbols.ellipsis)
(Display_width.truncate accented_title 40));
test "CLI parity profile sessions aggregate repeated stages" (fun () ->
expect_none "disabled profile" (Profile_types.create_session false);
let session =
expect_some "enabled profile" (Profile_types.create_session true)
in
Profile_types.record_span session (span "cli.parse-argv" 1 0 2);
Profile_types.record_span session (span "cli.parse-argv" 2 3 5);
Profile_types.record_span session (span "cli.build-action" 3 6 8);
let report =
Profile_types.report session ~command:"graph-list"
~status:(Edn_util.keyword_t "ok")
in
expect_equal "profile command" "graph-list" report.command;
expect_equal "profile status" "ok" (keyword_text report.status);
expect_int "parse count" 2
(expect_some "parse count" (stage_count "cli.parse-argv" report.stages));
expect_int "build count" 1
(expect_some "build count"
(stage_count "cli.build-action" report.stages));
expect_int "total count" 1
(expect_some "total count" (stage_count "cli.total" report.stages)));
test "CLI parity profile render lines use containment tree" (fun () ->
let report : Profile_types.report =
{
command = "graph-list";
status = Edn_util.keyword_t "ok";
total_span = Time.span_of_ms 42L;
stages = Vec.empty;
spans =
Vec.of_array
[|
span "cli.total" 0 0 42;
span "cli.parse-argv" 1 1 3;
span "cli.execute-action" 2 5 35;
span "transport.invoke:thread-api/q" 3 12 20;
|];
}
in
let lines = Profile_types.render_lines report in
expect_equal "profile headline" "42ms command=graph-list status=ok"
(Vec.peek_front lines);
expect_bool "profile stages heading" true
(Vec.exists (fun line -> String.trim line = "stages") lines);
expect_bool "profile total line" true
(Vec.exists
(fun line -> Js.String.includes ~search:"cli.total" line)
lines);
expect_bool "profile nested transport" true
(Vec.exists
(fun line ->
Js.String.includes ~search:"transport.invoke:thread-api/q" line)
lines);
expect_bool "profile no aggregate count labels" false
(Vec.exists
(fun line -> Js.String.includes ~search:"count=" line)
lines));
test "CLI parity profile render keeps repeated stage calls separate"
(fun () ->
let report : Profile_types.report =
{
command = "query";
status = Edn_util.keyword_t "ok";
total_span = Time.span_of_ms 30L;
stages = Vec.empty;
spans =
Vec.of_array
[|
span "outer" 0 0 30;
span "transport.invoke:thread-api/q" 1 5 10;
span "transport.invoke:thread-api/q" 2 15 20;
|];
}
in
let lines = Profile_types.render_lines report in
let q_lines =
Vec.filter
(fun line ->
Js.String.includes ~search:"transport.invoke:thread-api/q" line)
lines
in
expect_int "repeated stage lines" 2 (Vec.length q_lines));
test "CLI parity human table formats blanks and appends footer" (fun () ->
let output =
Output.Human_output.create
~headers:(Vec.of_array [| "ID"; "TITLE" |])
~rows:
(Vec.of_array
[| Vec.of_array [| "1"; "Alpha" |]; Vec.of_array [| "2"; "" |] |])
~footer:"Count: 2" ()
|> Output.Human_output.to_string
in
expect_named_contains "header" output "ID TITLE";
expect_named_contains "blank value" output "2 -";
expect_named_contains "footer" output "Count: 2");
test "CLI parity human table renders keyword headers without namespaces"
(fun () ->
let output =
Output.Human_output.create
~headers:
(Vec.of_array
[|
"db/id";
"block/title";
":logseq.property/status";
"Input/Output";
|])
~rows:
(Vec.of_array
[|
Vec.of_array
[| "1"; "Ship"; "logseq.property/status.todo"; "Ready" |];
|])
()
|> Output.Human_output.to_string
in
expect_named_contains "keyword headers" output "id title status";
expect_named_contains "plain slash header" output "Input/Output";
expect_named_not_contains "db namespace" output "db/id";
expect_named_not_contains "block namespace" output "block/title";
expect_named_not_contains "property namespace" output
":logseq.property/status");
test "CLI parity output mode parser accepts exact lowercase values only"
(fun () ->
expect_equal "json" "json"
(mode_text (expect_some "json" (Output.Mode.of_string "json")));
expect_equal "edn" "edn"
(mode_text (expect_some "edn" (Output.Mode.of_string "edn")));
expect_none "uppercase json" (Output.Mode.of_string " JSON ");
expect_none "uppercase edn" (Output.Mode.of_string "EDN");
expect_none "blank output" (Output.Mode.of_string " ");
expect_none "unknown output" (Output.Mode.of_string "yaml"));
test "CLI parity log truncate preview keeps string length without EDN quotes"
(fun () ->
let long_value = String.make 50 'a' |> Edn_util.string in
let long_preview = Log_types.truncate_preview ~max_len:10 long_value in
expect_int "long string length" 50 long_preview.length;
expect_int "long preview length" 10 (String.length long_preview.preview);
expect_bool "long preview truncated" true long_preview.truncated;
let short_preview =
Log_types.truncate_preview ~max_len:10 (Edn_util.string "short")
in
expect_int "short string length" 5 short_preview.length;
expect_equal "short preview" "short" short_preview.preview;
expect_bool "short preview not truncated" false short_preview.truncated;
let nil_preview = Log_types.truncate_preview ~max_len:10 Edn_util.nil in
expect_int "nil length" 3 nil_preview.length;
expect_equal "nil preview" "nil" nil_preview.preview;
expect_bool "nil not truncated" false nil_preview.truncated;
let vector_preview =
Log_types.truncate_preview ~max_len:100
(Edn_util.vector_vec
(Vec.of_array [| Edn_util.int 1; Edn_util.int 2; Edn_util.int 3 |]))
in
expect_equal "vector preview" "[1 2 3]" vector_preview.preview;
expect_bool "vector not truncated" false vector_preview.truncated);
test "CLI parity pretty edn matches jet command formatting" (fun () ->
expect_equal "flat pretty edn" "{:a 1, :bb 22, :ccc 333, :dddd 4444}\n"
(Pretty_print.pprint_edn
(edn_of_string "{:a 1 :bb 22 :ccc 333 :dddd 4444}"));
expect_equal "escaped string pretty edn"
"{:text \"a { b [ c ] } \\\" q\", :empty [], :m {}}\n"
(Pretty_print.pprint_edn
(edn_of_string "{:text \"a { b [ c ] } \\\" q\" :empty [] :m {}}"));
expect_equal "nested pretty edn"
"{:classes {:user.class/DUOL {:block/title \"DUOL\",\n\
\ :block/created-at 1773581891284,\n\
\ :block/updated-at 1773581891284},\n\
\ :user.class/NVDA {:block/title \"NVDA\",\n\
\ :block/created-at 1773583745410,\n\
\ :block/updated-at 1773583745410}},\n\
\ :pages [{:block/title \"A\", :block/tags [:x :y]}\n\
\ {:block/title \"B\", :block/properties {:k \"v\"}}]}\n"
(Pretty_print.pprint_edn
(edn_of_string
"{:classes {:user.class/DUOL {:block/title \"DUOL\" \
:block/created-at 1773581891284 :block/updated-at \
1773581891284} :user.class/NVDA {:block/title \"NVDA\" \
:block/created-at 1773583745410 :block/updated-at \
1773583745410}} :pages [{:block/title \"A\" :block/tags [:x \
:y]} {:block/title \"B\" :block/properties {:k \"v\"}}]}")));
test "CLI parity json output preserves namespaced keys and keyword values"
(fun () ->
let value =
Edn_util.map
[
(Edn_util.keyword "block/title", Edn_util.string "Block title");
(Edn_util.keyword "db/id", Edn_util.int64 42L);
(Edn_util.keyword "db/ident", Edn_util.keyword "logseq.class/Tag");
]
in
let output =
Format_types.to_json
(Cli_result.ok Output.Mode.Json (Cli_result.Raw value))
in
expect_named_contains "json title" output
"\"block/title\":\"Block title\"";
expect_named_contains "json id" output "\"db/id\":42";
expect_named_contains "json ident" output
"\"db/ident\":\"logseq.class/Tag\"");
test "CLI parity cli result exit codes follow status" (fun () ->
let ok = Cli_result.ok Output.Mode.Human (Cli_result.Message "ok") in
expect_bool "ok is not error" false (Cli_result.is_error ok);
expect_int "ok exit" 0 (Cli_result.exit_code ok);
let err = Error.make Error.Missing_auth "missing auth" in
let error = Cli_result.error Output.Mode.Human err in
expect_bool "error is error" true (Cli_result.is_error error);
expect_int "error exit" 1 (Cli_result.exit_code error));
test "CLI parity edn includes hint and human error stays concise" (fun () ->
let err =
Error.make ~hint:"Run `logseq login` first." Error.Missing_auth
"missing auth"
in
let edn = Format_types.to_edn (Cli_result.error Output.Mode.Edn err) in
let human =
Format_types.format_result
(Cli_result.error Output.Mode.Human err)
(config ())
in
expect_named_contains "edn code" edn ":missing-auth";
expect_named_contains "edn hint" edn "Run `logseq login` first.";
expect_equal "human error" "Error (missing-auth): missing auth" human);
test
"CLI parity parse rejects unknown command and retired qmd qsearch commands"
(fun () ->
expect_parse_error_code "unknown command" ":unknown-command" [| "bogus" |];
expect_parse_error_code "qmd retired" ":unknown-command" [| "qmd" |];
expect_parse_error_code "qsearch retired" ":unknown-command"
[| "qsearch" |]);
test
"CLI parity parse reads global graph output timeout verbose and profile \
flags" (fun () ->
let request =
expect_parse_ok "global parse"
[|
"--graph";
"demo";
"--output";
"json";
"--timeout-ms";
"1234";
"--verbose";
"--profile";
"graph";
"list";
|]
in
expect_equal "path" "graph/list" (Vec.string_concat "/" request.path);
expect_equal "graph" "demo"
(Cli_primitive.string_of_graph
(expect_some "graph" request.globals.Global_opts.graph));
expect_bool "verbose" true request.globals.verbose;
expect_bool "profile" true request.globals.profile;
match request.globals.output_format with
| Some (Output.Mode.Packed Output.Mode.Json) -> pass
| _ -> fail_test "expected json output mode");
test "CLI parity parse skips an earlier duplicate option without a value"
(fun () ->
let request =
expect_parse_ok "duplicate output parse"
[| "--output"; "--output"; "json"; "graph"; "list" |]
in
match request.globals.output_format with
| Some (Output.Mode.Packed Output.Mode.Json) -> pass
| _ -> fail_test "expected later json output mode");
test "CLI parity parse supports list page filters and validation" (fun () ->
let request =
expect_parse_ok "list page parse"
[|
"list";
"page";
"--fields";
"title,updated-at";
"--limit";
"10";
"--offset";
"2";
"--sort";
"updated-at";
"--order";
"asc";
"--include-hidden";
|]
in
match request.command with
| Cli_request.List (List_command.Parsed_page opts) ->
expect_int "limit" 10 (expect_some "limit" opts.common.limit);
expect_int "offset" 2 (expect_some "offset" opts.common.offset);
expect_equal "sort" "updated-at" (expect_some "sort" opts.common.sort);
expect_equal "order" "asc"
(List_command.string_of_order
(expect_some "order" opts.common.order));
expect_bool "include hidden" true opts.include_hidden
| _ ->
fail_test "expected list page request";
expect_parse_error_code "bad order" ":invalid-options"
[| "list"; "page"; "--order"; "sideways" |];
expect_parse_error_code "bad limit" ":invalid-options"
[| "list"; "page"; "--limit"; "nope" |]);
test "CLI parity parse rejects malformed boolean and selector values"
(fun () ->
expect_parse_error_code "bad boolean equals" ":invalid-options"
[| "list"; "page"; "--include-built-in=maybe" |];
expect_parse_error_code "bad boolean value" ":invalid-options"
[| "show"; "--id"; "1"; "--linked-references=maybe" |];
expect_parse_error_code "bad remove page id" ":invalid-options"
[| "remove"; "page"; "--id"; "abc"; "--page"; "Home" |];
expect_parse_error_code "bad sync asset id" ":invalid-options"
[| "sync"; "asset"; "download"; "--id"; "abc"; "--uuid"; "id" |];
expect_parse_error_code "bad debug id" ":invalid-options"
[| "debug"; "pull"; "--id"; "abc"; "--uuid"; "id" |]);
test "CLI parity parse supports list tag property task node and asset options"
(fun () ->
let tag =
expect_parse_ok "list tag parse"
[|
"list";
"tag";
"--expand";
"--include-built-in";
"--with-properties";
"--with-extends";
"--fields";
"title,properties";
|]
in
(match tag.command with
| Cli_request.List (List_command.Parsed_tag opts) ->
expect_bool "tag expand" true opts.expand;
expect_bool "tag include built in" true
(expect_some "tag include built in" opts.include_built_in);
expect_bool "tag with properties" true opts.with_properties;
expect_bool "tag with extends" true opts.with_extends;
expect_int "tag fields" 2
(Vec.length (expect_some "tag fields" opts.common.fields))
| _ -> fail_test "expected list tag");
let property =
expect_parse_ok "list property parse"
[|
"list";
"property";
"--expand";
"--include-built-in";
"--with-classes";
"--with-type";
"--sort";
"cardinality";
"--fields";
"title,type,cardinality";
|]
in
(match property.command with
| Cli_request.List (List_command.Parsed_property opts) ->
expect_bool "property expand" true opts.expand;
expect_bool "property include built in" true
(expect_some "property include built in" opts.include_built_in);
expect_bool "property with classes" true opts.with_classes;
expect_bool "property with type" true opts.with_type;
expect_equal "property sort" "cardinality"
(expect_some "property sort" opts.common.sort)
| _ -> fail_test "expected list property");
let task =
expect_parse_ok "list task parse"
[|
"list";
"task";
"--status";
"doing";
"--priority";
"high";
"-c";
"1111";
"--fields";
"id,title,status,priority";
"--limit";
"10";
"--offset";
"2";
"--sort";
"priority";
"--order";
"desc";
|]
in
(match task.command with
| Cli_request.List (List_command.Parsed_task opts) ->
expect_equal "task status" "doing"
(expect_some "task status" opts.status);
expect_equal "task priority" "high"
(expect_some "task priority" opts.priority);
expect_equal "task numeric content" "1111"
(expect_some "task content" opts.content);
expect_int "task limit" 10
(expect_some "task limit" opts.common.limit);
expect_equal "task order" "desc"
(List_command.string_of_order
(expect_some "task order" opts.common.order))
| _ -> fail_test "expected list task");
let node =
expect_parse_ok "list node parse"
[|
"list";
"node";
"--tags";
"project,work";
"--properties";
"status,priority";
"--fields";
"id,title,type,updated-at";
"--limit";
"10";
"--offset";
"2";
"--sort";
"updated-at";
"--order";
"desc";
|]
in
(match node.command with
| Cli_request.List (List_command.Parsed_node opts) ->
expect_int "node tags" 2 (Vec.length opts.tags);
expect_int "node properties" 2 (Vec.length opts.properties);
expect_equal "node sort" "updated-at"
(expect_some "node sort" opts.common.sort)
| _ -> fail_test "expected list node");
let asset =
expect_parse_ok "list asset parse"
[|
"list";
"asset";
"--fields";
"id,title,asset-type,size,updated-at";
"--limit";
"5";
"--offset";
"1";
"--sort";
"updated-at";
"--order";
"desc";
|]
in
match asset.command with
| Cli_request.List (List_command.Parsed_asset opts) ->
expect_int "asset limit" 5
(expect_some "asset limit" opts.common.limit);
expect_int "asset offset" 1
(expect_some "asset offset" opts.common.offset);
expect_equal "asset order" "desc"
(List_command.string_of_order
(expect_some "asset order" opts.common.order))
| _ -> fail_test "expected list asset");
test "CLI parity list rejects old name fields for tag and property" (fun () ->
expect_parse_error_code "list tag old name field" ":invalid-options"
[| "list"; "tag"; "--fields"; "name,properties" |];
expect_parse_error_code "list property old name field" ":invalid-options"
[| "list"; "property"; "--fields"; "name,type,cardinality" |]);
test "CLI parity list apply offset limit returns requested window" (fun () ->
let opts = { empty_common_opts with offset = Some 1; limit = Some 2 } in
let items =
Vec.of_array
[|
entity ~id:1L ();
entity ~id:2L ();
entity ~id:3L ();
entity ~id:4L ();
|]
in
let result = List_command.apply_offset_limit opts items in
expect_int "window size" 2 (Vec.length result);
expect_int64 "first window id" 2L
(expect_some "id" (Vec.peek_front result).Entity.id));
test "CLI parity list build action validates repo and option contracts"
(fun () ->
let page_opts =
List_command.Parsed_page
{
common =
{
empty_common_opts with
fields = Some (Vec.of_array [| "id"; "title" |]);
limit = Some 20;
sort = Some "updated-at";
order = Some List_command.Desc;
};
expand = false;
include_built_in = None;
include_journal = Some false;
journal_only = true;
include_hidden = false;
updated_after = None;
created_after = None;
}
in
let action =
expect_ok "list page build"
(List_command.build (config ~repo:"demo" ()) (Global_opts.create ())
page_opts)
in
expect_bool "list page kind" true
(action.List_command.kind = List_command.Page);
expect_equal "list page command" "list-page"
(Command_id.to_string action.command);
expect_equal "list page repo" "logseq_db_demo"
(Cli_primitive.string_of_repo action.repo);
expect_equal "list page graph" "demo"
(Cli_primitive.string_of_graph action.graph);
expect_int "list page limit" 20
(expect_some "limit" (Edn_util.get_int action.options "limit"));
expect_equal "list page order" "desc"
(expect_some "order" (Edn_util.get_string action.options "order"));
expect_error_code "list requires repo" "missing-repo"
(List_command.build (config ()) (Global_opts.create ()) page_opts);
let conflict =
List_command.Parsed_page
{
common = empty_common_opts;
expand = false;
include_built_in = None;
include_journal = Some true;
journal_only = true;
include_hidden = false;
updated_after = None;
created_after = None;
}
in
expect_error_code "journal conflict" "invalid-options"
(List_command.build (config ~repo:"demo" ()) (Global_opts.create ())
conflict);
let task =
List_command.Parsed_task
{
common = empty_common_opts;
status = Some "todo";
priority = Some "bad";
content = None;
}
in
expect_error_code "task bad priority" "invalid-options"
(List_command.build (config ~repo:"demo" ()) (Global_opts.create ())
task);
let node =
List_command.Parsed_node
{
common = empty_common_opts;
tags = Vec.empty;
properties = Vec.empty;
}
in
expect_error_code "node requires filters" "invalid-options"
(List_command.build (config ~repo:"demo" ()) (Global_opts.create ())
node));
test_promise
"CLI parity list execute default sort limit and explicit order contracts"
(fun () ->
let call_count = ref 0 in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/cli-list-pages" body then (
incr call_count;
match !call_count with
| 1 ->
"[[\"^ \",\"~:db/id\",11,\"~:block/title\",\"Page \
C\",\"~:block/updated-at\",30],[\"^ \
\",\"^0\",7,\"^1\",\"Page B\",\"^2\",10],[\"^ \
\",\"^0\",5,\"^1\",\"Page A\",\"^2\",10]]"
| 2 ->
"[[\"^ \",\"~:db/id\",4,\"~:block/title\",\"Page \
4\",\"~:block/updated-at\",4],[\"^ \
\",\"^0\",12,\"^1\",\"Page 12\",\"^2\",12],[\"^ \
\",\"^0\",1,\"^1\",\"Page 1\",\"^2\",1],[\"^ \
\",\"^0\",8,\"^1\",\"Page 8\",\"^2\",8],[\"^ \
\",\"^0\",3,\"^1\",\"Page 3\",\"^2\",3],[\"^ \
\",\"^0\",11,\"^1\",\"Page 11\",\"^2\",11],[\"^ \
\",\"^0\",2,\"^1\",\"Page 2\",\"^2\",2],[\"^ \
\",\"^0\",10,\"^1\",\"Page 10\",\"^2\",10],[\"^ \
\",\"^0\",5,\"^1\",\"Page 5\",\"^2\",5],[\"^ \
\",\"^0\",9,\"^1\",\"Page 9\",\"^2\",9],[\"^ \
\",\"^0\",6,\"^1\",\"Page 6\",\"^2\",6],[\"^ \
\",\"^0\",7,\"^1\",\"Page 7\",\"^2\",7]]"
| _ ->
"[[\"^ \
\",\"~:db/id\",3,\"~:block/title\",\"Beta\",\"~:block/updated-at\",20],[\"^ \
\",\"^0\",2,\"^1\",\"Gamma\",\"^2\",5],[\"^ \
\",\"^0\",1,\"^1\",\"Alpha\",\"^2\",10]]")
else "null")
in
with_server server (fun base_url ->
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let run ?(common = empty_common_opts) () =
let action =
expect_ok "list page build"
(List_command.build cfg (Global_opts.create ())
(List_command.Parsed_page
{
common;
expand = false;
include_built_in = None;
include_journal = None;
journal_only = false;
include_hidden = false;
updated_after = None;
created_after = None;
}))
in
execute_with_output List_command.execute action cfg
Output.Mode.Human
in
let ids result =
let data = expect_some "list data" (Cli_result.data_value result) in
let items =
expect_some "items"
(Option.bind (Edn_util.get data "items") Edn_util.as_seq)
in
items
|> Vec.map (fun item ->
Int64.to_string
(expect_some "item id" (Edn_util.get_int64 item "db/id")))
|> Vec.string_concat ","
in
let* default_result = effect_to_promise (run ()) in
expect_equal "default updated-at desc with id tie" "11,7,5"
(ids default_result);
let* limited =
effect_to_promise
(run ~common:{ empty_common_opts with limit = Some 10 } ())
in
expect_equal "default limit newest records" "12,11,10,9,8,7,6,5,4,3"
(ids limited);
let* explicit_order =
effect_to_promise
(run
~common:
{ empty_common_opts with order = Some List_command.Asc }
())
in
expect_equal "explicit asc order" "2,1,3" (ids explicit_order);
let* explicit_sort =
effect_to_promise
(run ~common:{ empty_common_opts with sort = Some "title" } ())
in
expect_equal "explicit title sort desc" "2,3,1" (ids explicit_sort);
Js.Promise.resolve pass));
test_promise
"CLI parity list property execute supports cardinality sort and fields"
(fun () ->
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/cli-list-properties" body
then
"[[\"^ \
\",\"~:db/id\",30,\"~:block/title\",\"Gamma\",\"~:db/cardinality\",\"~:db.cardinality/one\"],[\"^ \
\",\"^0\",10,\"^1\",\"Alpha\",\"^2\",\"~:db.cardinality/many\"],[\"^ \
\",\"^0\",20,\"^1\",\"Beta\",\"^2\",\"~:db.cardinality/many\"]]"
else "null")
in
with_server server (fun base_url ->
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
expect_ok "list property build"
(List_command.build cfg (Global_opts.create ())
(List_command.Parsed_property
{
common =
{
empty_common_opts with
sort = Some "cardinality";
fields =
Some
(Vec.of_array [| "id"; "title"; "cardinality" |]);
};
expand = false;
include_built_in = None;
with_classes = false;
with_type = true;
}))
in
let* result =
effect_to_promise
(execute_with_output List_command.execute action cfg
Output.Mode.Human)
in
let data =
expect_some "property list data" (Cli_result.data_value result)
in
let items =
expect_some "property items"
(Option.bind (Edn_util.get data "items") Edn_util.as_seq)
in
let ids =
items
|> Vec.map (fun item ->
Int64.to_string
(expect_some "item id" (Edn_util.get_int64 item "db/id")))
|> Vec.string_concat ","
in
expect_equal "cardinality sort ids" "30,20,10" ids;
Vec.iter
(fun item ->
expect_bool "has id" true
(Option.is_some (Edn_util.get item "db/id"));
expect_bool "has title" true
(Option.is_some (Edn_util.get item "block/title"));
expect_bool "has cardinality" true
(Option.is_some (Edn_util.get item "db/cardinality"));
expect_bool "only selected fields" true
(match Edn_util.as_map item with
| Some fields -> Vec.length fields = 3
| None -> false))
items;
Js.Promise.resolve pass));
test_promise "CLI parity list asset execute filters by asset tag id"
(fun () ->
let calls = ref Vec.empty in
let server =
invoke_server (fun body ->
calls := Vec.push_front !calls body;
if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \",\"~:db/id\",900]"
else if Js.String.includes ~search:"thread-api/cli-list-nodes" body
then (
expect_named_contains "asset tag id option" body "900";
"[[\"^ \
\",\"~:db/id\",2,\"~:block/title\",\"asset-b\",\"~:node/type\",\"block\",\"~:block/updated-at\",30],[\"^ \
\",\"^0\",1,\"^1\",\"asset-a\",\"^2\",\"block\",\"^3\",10]]")
else "null")
in
with_server server (fun base_url ->
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
expect_ok "list asset build"
(List_command.build cfg (Global_opts.create ())
(List_command.Parsed_asset
{ common = { empty_common_opts with limit = Some 1 } }))
in
let* result =
effect_to_promise
(execute_with_output List_command.execute action cfg
Output.Mode.Human)
in
let data =
expect_some "asset list data" (Cli_result.data_value result)
in
let items =
expect_some "asset items"
(Option.bind (Edn_util.get data "items") Edn_util.as_seq)
in
expect_int "asset limit" 1 (Vec.length items);
expect_int64 "newest asset first" 2L
(expect_some "asset id"
(Edn_util.get_int64 (Vec.peek_front items) "db/id"));
expect_int "pull plus list calls" 2 (Vec.length !calls);
Js.Promise.resolve pass));
test "CLI parity parse supports search scopes and rejects unknown option"
(fun () ->
let request =
expect_parse_ok "search tag" [| "search"; "tag"; "--content"; "quote" |]
in
match request.command with
| Cli_request.Search (Search.Parsed_tag opts) ->
expect_equal "query" "quote" opts.content
| _ ->
fail_test "expected search tag";
expect_parse_error_code "unknown search option" ":invalid-options"
[| "search"; "tag"; "--unknown"; "x"; "quote" |]);
test
"CLI parity search parser keeps content alias globals and strict \
positionals" (fun () ->
let alias =
expect_parse_ok "search block alias"
[| "search"; "block"; "-c"; "1111"; "--output"; "json" |]
in
(match alias.command with
| Cli_request.Search (Search.Parsed_block opts) ->
expect_equal "alias content" "1111" opts.content
| _ -> fail_test "expected search block");
(match alias.globals.output_format with
| Some (Output.Mode.Packed Output.Mode.Json) -> pass
| _ -> fail_test "expected search json output");
let page =
expect_parse_ok "search page multi-word"
[| "search"; "page"; "--content"; "project notes" |]
in
(match page.command with
| Cli_request.Search (Search.Parsed_page opts) ->
expect_equal "page query" "project notes" opts.content
| _ -> fail_test "expected search page");
expect_parse_error_code "search positional command" ":unknown-command"
[| "search"; "block"; "alpha" |]);
test "CLI parity search build action validates repo and query text" (fun () ->
let action =
expect_ok "search block action"
(Search.build (config ~repo:"demo" ()) (Global_opts.create ())
(Search.Parsed_block { content = " Alpha Beta " }))
in
expect_bool "search block scope" true (action.Search.scope = Search.Block);
expect_equal "search block command" "search-block"
(Command_id.to_string action.command);
expect_equal "search repo" "logseq_db_demo"
(Cli_primitive.string_of_repo action.repo);
expect_equal "search graph" "demo"
(Cli_primitive.string_of_graph action.graph);
expect_equal "search query trim" "Alpha Beta" action.query;
expect_error_code "search requires repo" "missing-repo"
(Search.build (config ()) (Global_opts.create ())
(Search.Parsed_page { content = "Home" }));
expect_error_code "search rejects blank content" "missing-query-text"
(Search.build (config ~repo:"demo" ()) (Global_opts.create ())
(Search.Parsed_tag { content = " " })));
test_promise "CLI parity search execute sorts scopes and strips raw fields"
(fun () ->
let calls = ref Vec.empty in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/q" body then (
calls := Vec.push_front !calls body;
"[[\"^ \
\",\"~:db/id\",9,\"~:block/title\",\"beta\",\"~:unused\",true],[\"^ \
\",\"^0\",7,\"^1\",\"Alpha\"]]")
else "null")
in
with_server server (fun base_url ->
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let repo = Cli_primitive.create_repo "demo" in
let run scope command query =
execute_with_output Search.execute
{
Search.scope;
command;
repo;
graph = Cli_config.repo_to_graph repo;
query;
}
cfg Output.Mode.Human
in
let item_titles result =
let data =
expect_some "search data" (Cli_result.data_value result)
in
let items =
expect_some "search items"
(Option.bind (Edn_util.get data "items") Edn_util.as_seq)
in
Vec.map
(fun item ->
expect_some "item title"
(Edn_util.get_string item "block/title"))
items
in
let* block =
effect_to_promise (run Search.Block Command_id.Search_block "alpha")
in
let* page =
effect_to_promise (run Search.Page Command_id.Search_page "home")
in
let* property =
effect_to_promise
(run Search.Property Command_id.Search_property "owner")
in
let* tag =
effect_to_promise (run Search.Tag Command_id.Search_tag "quote")
in
Vec.iter
(fun result ->
expect_bool "search ok" false (Cli_result.is_error result);
expect_equal "sorted titles" "Alpha,beta"
(Vec.string_concat "," (item_titles result)))
(Vec.of_array [| block; page; property; tag |]);
expect_int "q calls" 4 (Vec.length !calls);
expect_named_contains "block query pulls deleted-at"
(Vec.nth !calls 3) "logseq.property/deleted-at";
expect_named_contains "page query uses block name" (Vec.nth !calls 2)
"block/name";
expect_named_contains "property query class" (Vec.nth !calls 1)
"logseq.class/Property";
expect_named_contains "tag query class" (Vec.nth !calls 0)
"logseq.class/Tag";
Js.Promise.resolve pass));
test_promise "CLI parity search execute filters recycled pages and blocks"
(fun () ->
let block_results =
"[[\"^ \",\"~:db/id\",1,\"~:block/title\",\"alpha \
live\",\"~:block/parent\",[\"^ \",\"^0\",51,\"^1\",\"Live \
Page\"]],[\"^ \",\"^0\",2,\"^1\",\"alpha orphan\",\"^2\",[\"^ \
\",\"^0\",10,\"^1\",\"intermediate\",\"^2\",[\"^ \
\",\"^0\",50,\"^1\",\"Old \
Page\",\"~:logseq.property/deleted-at\",1712000000000,\"^2\",[\"^ \
\",\"^0\",999,\"^1\",\"Recycle\"]]]],[\"^ \",\"^0\",3,\"^1\",\"alpha \
tombstone\",\"^2\",[\"^ \",\"^0\",50,\"^1\",\"Old \
Page\",\"^3\",1712000000000]]]"
in
let page_results =
"[[\"^ \",\"~:db/id\",1,\"~:block/title\",\"Home\"],[\"^ \
\",\"^0\",2,\"^1\",\"Recycled \
Home\",\"~:logseq.property/deleted-at\",1712000000000],[\"^ \
\",\"^0\",3,\"^1\",\"Homework\"]]"
in
let call_count = ref 0 in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/q" body then (
incr call_count;
if !call_count = 1 then block_results else page_results)
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let run scope command query =
execute_with_output Search.execute
{
Search.scope;
command;
repo;
graph = Cli_config.repo_to_graph repo;
query;
}
cfg Output.Mode.Human
in
let titles result =
let data =
expect_some "search data" (Cli_result.data_value result)
in
let items =
expect_some "items"
(Option.bind (Edn_util.get data "items") Edn_util.as_seq)
in
Vec.map
(fun item ->
expect_some "title" (Edn_util.get_string item "block/title"))
items
in
let* block =
effect_to_promise (run Search.Block Command_id.Search_block "alpha")
in
expect_equal "live block only" "alpha live"
(Vec.string_concat "," (titles block));
let* page =
effect_to_promise (run Search.Page Command_id.Search_page "home")
in
expect_equal "live pages only" "Home,Homework"
(Vec.string_concat "," (titles page));
Js.Promise.resolve pass));
test_promise "CLI parity search execute replaces uuid refs in block titles"
(fun () ->
let ref_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" in
let calls = ref Vec.empty in
let server =
invoke_server (fun body ->
calls := Vec.push_front !calls body;
if Js.String.includes ~search:"thread-api/q" body then
"[[\"^ \",\"~:db/id\",7,\"~:block/title\",\"foo \
[[aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa]]\"]]"
else if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \
\",\"~:db/id\",99,\"~:block/uuid\",\"~uaaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"~:block/title\",\"bar\"]"
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Search.execute
{
Search.scope = Search.Block;
command = Command_id.Search_block;
repo;
graph = Cli_config.repo_to_graph repo;
query = "foo";
}
cfg Output.Mode.Human)
in
let data = expect_some "search data" (Cli_result.data_value result) in
let items =
expect_some "items"
(Option.bind (Edn_util.get data "items") Edn_util.as_seq)
in
let title =
expect_some "title"
(Edn_util.get_string (Vec.peek_front items) "block/title")
in
expect_equal "replaced uuid ref" "foo [[bar]]" title;
expect_int "q and pull calls" 2 (Vec.length !calls);
expect_named_contains "pull lookup uuid" (Vec.peek_front !calls)
ref_uuid;
Js.Promise.resolve pass));
test "CLI parity remove validation rejects ambiguous and blank targets"
(fun () ->
expect_equal "remove page ambiguous"
"only one of --id or --page is allowed"
(expect_some "invalid"
(Remove.invalid_options
(Remove.Parsed_page { id = Some 1L; page = Some "Home" })));
expect_equal "remove tag blank" "name must be non-empty"
(expect_some "invalid"
(Remove.invalid_options
(Remove.Parsed_tag { id = None; name = Some " " }))));
test "CLI parity remove build action resolves targets and repo" (fun () ->
let block =
expect_ok "remove block ids"
(Remove.build (config ~repo:"demo" ()) (Global_opts.create ())
(Remove.Parsed_block { id_raw = Some "[1,2]"; uuid = None }))
in
(match block with
| Remove.Remove_block { repo; graph; id; ids; multi_id; uuid } ->
expect_equal "remove repo" "logseq_db_demo"
(Cli_primitive.string_of_repo repo);
expect_equal "remove graph" "demo"
(Cli_primitive.string_of_graph graph);
expect_none "remove block single id" id;
expect_int "remove block ids" 2 (Vec.length ids);
expect_bool "remove block multi" true multi_id;
expect_none "remove block uuid none" uuid
| _ -> fail_test "expected remove block");
let block_uuid =
expect_ok "remove block uuid"
(Remove.build (config ~repo:"demo" ()) (Global_opts.create ())
(Remove.Parsed_block
{
id_raw = None;
uuid = Some "00000000-0000-4000-8000-000000000001";
}))
in
(match block_uuid with
| Remove.Remove_block { uuid = Some uuid; multi_id; ids; _ } ->
expect_equal "remove block uuid"
"00000000-0000-4000-8000-000000000001" uuid;
expect_bool "remove uuid multi" false multi_id;
expect_int "remove uuid ids" 0 (Vec.length ids)
| _ -> fail_test "expected uuid remove block");
let page =
expect_ok "remove page by title"
(Remove.build (config ~repo:"demo" ()) (Global_opts.create ())
(Remove.Parsed_page { id = None; page = Some " Home " }))
in
(match page with
| Remove.Remove_page { page = Some page; id = None; _ } ->
expect_equal "remove page trims" "Home" page
| _ -> fail_test "expected remove page by title");
let tag =
expect_ok "remove tag by id"
(Remove.build (config ~repo:"demo" ()) (Global_opts.create ())
(Remove.Parsed_tag { id = Some 42L; name = None }))
in
(match tag with
| Remove.Remove_tag { id = Some 42L; name = None; _ } -> pass
| _ -> fail_test "expected remove tag by id");
expect_error_code "remove requires repo" "missing-repo"
(Remove.build (config ()) (Global_opts.create ())
(Remove.Parsed_block { id_raw = Some "1"; uuid = None }));
expect_error_code "remove block missing target" "missing-target"
(Remove.build (config ~repo:"demo" ()) (Global_opts.create ())
(Remove.Parsed_block { id_raw = None; uuid = None }));
expect_error_code "remove page missing" "missing-page-name"
(Remove.build (config ~repo:"demo" ()) (Global_opts.create ())
(Remove.Parsed_page { id = None; page = None })));
test_promise "CLI parity remove page unwraps apply result map" (fun () ->
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/cli-list-pages" body then
"[[\"^ \
\",\"~:db/id\",190,\"~:block/title\",\"Home\",\"~:block/name\",\"home\",\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000190\"]]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then "[\"^ \",\"~:result\",true]"
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Remove.Remove_page
{
repo;
graph = Cli_config.repo_to_graph repo;
id = None;
page = Some "Home";
}
in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Remove.execute action cfg Output.Mode.Json)
in
expect_bool "remove page succeeds" false (Cli_result.is_error result);
let data = expect_some "remove page data" (Cli_result.data_value result) in
expect_bool "remove page result" true
(expect_some "remove page result bool"
(Edn_util.get_bool data "result"));
Js.Promise.resolve pass));
test_promise "CLI parity remove block rejects page entities before delete"
(fun () ->
let apply_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \
\",\"~:db/id\",190,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000190\",\"~:block/name\",\"some-page\"]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_called := true;
"[\"^ \",\"~:result\",true]")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Remove.Remove_block
{
repo;
graph = Cli_config.repo_to_graph repo;
id = Some 190L;
ids = Vec.singleton 190L;
multi_id = false;
uuid = None;
}
in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Remove.execute action cfg Output.Mode.Human)
in
expect_bool "remove page target is error" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "remove page target code" "invalid-target"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected remove error");
expect_bool "delete not called" false !apply_called;
Js.Promise.resolve pass));
test_promise "CLI parity remove block by id deletes resolved block uuid"
(fun () ->
let captured_apply_body = ref None in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \
\",\"~:db/id\",190,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000190\"]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
captured_apply_body := Some body;
"[\"^ \",\"~:result\",true]")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Remove.Remove_block
{
repo;
graph = Cli_config.repo_to_graph repo;
id = Some 190L;
ids = Vec.singleton 190L;
multi_id = false;
uuid = None;
}
in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Remove.execute action cfg Output.Mode.Human)
in
expect_bool "remove block succeeds" false (Cli_result.is_error result);
let body = expect_some "captured apply body" !captured_apply_body in
expect_named_contains "delete method" body
"thread-api/apply-outliner-ops";
expect_named_contains "delete op" body "delete-blocks";
expect_named_contains "delete uuid" body
"00000000-0000-4000-8000-000000000190";
Js.Promise.resolve pass));
test "CLI parity show target resolves stdin ids vectors ids uuid and pages"
(fun () ->
expect_equal "stdin trim" "42"
(expect_some "stdin" (Show.normalize_stdin_id (Some " 42\n")));
expect_none "blank stdin" (Show.normalize_stdin_id (Some " \n"));
let by_id =
expect_ok "show id"
(Show.resolve_target
{
id_raw = Some "42";
uuid = None;
page = None;
page_hierarchy = false;
linked_references = None;
ref_id_footer = None;
level = None;
stdin_id = None;
})
in
(match by_id with
| Show.By_id 42L -> pass
| _ -> fail_test "expected By_id 42");
let multi =
expect_ok "show ids"
(Show.resolve_target
{
id_raw = Some "[1,2]";
uuid = None;
page = None;
page_hierarchy = false;
linked_references = None;
ref_id_footer = None;
level = None;
stdin_id = None;
})
in
(match multi with
| Show.By_ids ids ->
expect_int "show id count" 2 (Vec.length ids);
expect_int64 "show first id" 1L (Vec.nth ids 0);
expect_int64 "show second id" 2L (Vec.nth ids 1)
| _ -> fail_test "expected By_ids");
expect_equal "ambiguous show"
"only one of --id, --uuid, or --page is allowed"
(expect_some "invalid"
(Show.invalid_options
{
id_raw = Some "1";
uuid = Some "00000000-0000-4000-8000-000000000001";
page = None;
page_hierarchy = false;
linked_references = None;
ref_id_footer = None;
level = None;
stdin_id = None;
})));
test "CLI parity show build threads hierarchy references footer and level"
(fun () ->
let base_opts =
{
Show.id_raw = Some "42";
uuid = None;
page = None;
page_hierarchy = false;
linked_references = None;
ref_id_footer = None;
level = None;
stdin_id = None;
}
in
let default_action =
expect_ok "show defaults"
(Show.build (config ~repo:"demo" ()) (Global_opts.create ())
(Show.Parsed_show base_opts))
in
expect_equal "show repo" "logseq_db_demo"
(repo_text default_action.Show.repo);
expect_equal "show graph" "demo" (graph_text default_action.Show.graph);
(match default_action.Show.target with
| Show.By_id 42L -> pass
| _ -> fail_test "expected By_id target");
expect_bool "single id" false default_action.Show.multi_id;
expect_bool "linked refs default" true
default_action.Show.linked_references;
expect_bool "ref id footer default" true default_action.Show.ref_id_footer;
expect_bool "page hierarchy default" false
default_action.Show.page_hierarchy;
expect_int "default level" 10
(expect_some "level" default_action.Show.level);
let custom_action =
expect_ok "show custom flags"
(Show.build (config ~repo:"demo" ()) (Global_opts.create ())
(Show.Parsed_show
{
base_opts with
id_raw = Some "[1,2]";
linked_references = Some false;
ref_id_footer = Some false;
page_hierarchy = true;
level = Some 3;
}))
in
(match custom_action.Show.target with
| Show.By_ids ids ->
expect_int "multi id count" 2 (Vec.length ids);
expect_int64 "multi first id" 1L (Vec.nth ids 0);
expect_int64 "multi second id" 2L (Vec.nth ids 1)
| _ -> fail_test "expected multi-id target");
expect_bool "multi id flag" true custom_action.Show.multi_id;
expect_bool "linked refs disabled" false
custom_action.Show.linked_references;
expect_bool "ref id footer disabled" false
custom_action.Show.ref_id_footer;
expect_bool "page hierarchy enabled" true
custom_action.Show.page_hierarchy;
expect_int "custom level" 3
(expect_some "custom level" custom_action.Show.level);
expect_error_code "show requires repo" "missing-repo"
(Show.build (config ()) (Global_opts.create ())
(Show.Parsed_show base_opts));
expect_error_code "show invalid level" "invalid-options"
(Show.build (config ~repo:"demo" ()) (Global_opts.create ())
(Show.Parsed_show { base_opts with level = Some 0 })));
test_promise "CLI parity show rejects recycled pages" (fun () ->
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \
\",\"~:db/id\",42,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000042\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]"
else if Js.String.includes ~search:"thread-api/q" body then "[]"
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
{
Show.repo;
graph = Cli_config.repo_to_graph repo;
target = Show.By_page "home";
multi_id = false;
linked_references = false;
ref_id_footer = false;
page_hierarchy = false;
level = Some 10;
}
in
let* result =
effect_to_promise
(execute_with_output Show.execute action cfg Output.Mode.Human)
in
expect_bool "show recycled page errors" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "show recycled code" "recycled-page"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected show error");
Js.Promise.resolve pass));
test
"CLI parity upsert validation rejects ambiguous and mode-specific options"
(fun () ->
expect_equal "upsert page ambiguous"
"only one of --id or --page is allowed"
(expect_some "invalid"
(Upsert.invalid_options
(Upsert.Parsed_page
{
id = Some 1L;
page = Some "Home";
restore = false;
update_tags_edn = None;
update_properties_edn = None;
remove_tags_edn = None;
remove_properties_edn = None;
})));
expect_equal "asset missing path" "--path is required in create mode"
(expect_some "invalid"
(Upsert.invalid_options
(Upsert.Parsed_asset
{
id = None;
uuid = None;
path = None;
target_id = None;
target_uuid = None;
target_page = None;
pos = None;
content = None;
})));
expect_equal "task no status conflict"
"--status and --no-status are mutually exclusive"
(expect_some "invalid"
(Upsert.invalid_options
(Upsert.Parsed_task
{
id = None;
uuid = None;
page = Some "Home";
content = None;
target_id = None;
target_uuid = None;
target_page = None;
pos = None;
status = Some "todo";
priority = None;
scheduled = None;
deadline = None;
no_status = true;
no_priority = false;
no_scheduled = false;
no_deadline = false;
}))));
test "CLI parity upsert page parses restore option" (fun () ->
let request =
expect_parse_ok "upsert page restore"
[|
"upsert";
"page";
"--page";
"Home";
"--restore";
"--update-properties";
"{\"status\" \"done\"}";
|]
in
match request.command with
| Cli_request.Upsert (Upsert.Parsed_page opts) ->
expect_equal "page" "Home" (expect_some "page" opts.page);
expect_bool "restore" true opts.restore
| _ -> fail_test "expected upsert page");
test_promise "CLI parity upsert page rejects recycled page by default"
(fun () ->
let apply_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/q" body then
"[[\"^ \
\",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_called := true;
"true")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
Upsert.Upsert_page
{
repo;
graph = Cli_config.repo_to_graph repo;
mode = Upsert.Create;
id = None;
page = Some "Home";
restore = false;
plan = Property.empty_update_plan;
}
in
let* result =
effect_to_promise
(execute_with_output Upsert.execute action cfg Output.Mode.Human)
in
expect_bool "upsert recycled page errors" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "upsert recycled code" "recycled-page"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected upsert error");
expect_bool "no ops applied" false !apply_called;
Js.Promise.resolve pass));
test_promise "CLI parity upsert page update rejects recycled page by default"
(fun () ->
let apply_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \
\",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_called := true;
"true")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
Upsert.Upsert_page
{
repo;
graph = Cli_config.repo_to_graph repo;
mode = Upsert.Update;
id = Some 50L;
page = None;
restore = false;
plan = Property.empty_update_plan;
}
in
let* result =
effect_to_promise
(execute_with_output Upsert.execute action cfg Output.Mode.Human)
in
expect_bool "upsert recycled page update errors" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "upsert recycled update code" "recycled-page"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected upsert update error");
expect_bool "no update ops applied" false !apply_called;
Js.Promise.resolve pass));
test_promise "CLI parity upsert page restores recycled page when requested"
(fun () ->
let apply_calls = ref Vec.empty in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/q" body then
"[[\"^ \
\",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_calls := Vec.push_back !apply_calls body;
"true")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let plan =
{
Property.empty_update_plan with
update_properties =
Vec.of_array
[|
{
Property.key =
Property.Key_ident
(Edn_util.keyword_t "user.property/status");
value = Edn_util.string "done";
};
|];
}
in
let action =
Upsert.Upsert_page
{
repo;
graph = Cli_config.repo_to_graph repo;
mode = Upsert.Create;
id = None;
page = Some "Home";
restore = true;
plan;
}
in
let* result =
effect_to_promise
(execute_with_output Upsert.execute action cfg Output.Mode.Human)
in
expect_bool "upsert restore ok" false (Cli_result.is_error result);
expect_int "restore then update calls" 2 (Vec.length !apply_calls);
expect_named_contains "first op restores recycled page"
(Vec.nth !apply_calls 0) "restore-recycled";
expect_named_contains "second op updates restored page"
(Vec.nth !apply_calls 1) "batch-set-property";
Js.Promise.resolve pass));
test_promise
"CLI parity upsert page restore rejects recycled page without uuid"
(fun () ->
let apply_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/q" body then
"[[\"^ \
\",\"~:db/id\",50,\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_called := true;
"true")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
Upsert.Upsert_page
{
repo;
graph = Cli_config.repo_to_graph repo;
mode = Upsert.Create;
id = None;
page = Some "Home";
restore = true;
plan = Property.empty_update_plan;
}
in
let* result =
effect_to_promise
(execute_with_output Upsert.execute action cfg Output.Mode.Human)
in
expect_bool "upsert recycled restore without uuid errors" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "upsert recycled restore without uuid code"
"recycled-page"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected upsert restore error");
expect_bool "no restore ops applied" false !apply_called;
Js.Promise.resolve pass));
test_promise
"CLI parity upsert page update restore rejects recycled page without uuid"
(fun () ->
let apply_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \
\",\"~:db/id\",50,\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_called := true;
"true")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
Upsert.Upsert_page
{
repo;
graph = Cli_config.repo_to_graph repo;
mode = Upsert.Update;
id = Some 50L;
page = None;
restore = true;
plan = Property.empty_update_plan;
}
in
let* result =
effect_to_promise
(execute_with_output Upsert.execute action cfg Output.Mode.Human)
in
expect_bool "upsert recycled update restore without uuid errors" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "upsert recycled update restore without uuid code"
"recycled-page"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected upsert update restore error");
expect_bool "no update restore ops applied" false !apply_called;
Js.Promise.resolve pass));
test_promise "CLI parity upsert block create rejects recycled target page"
(fun () ->
let apply_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/q" body then
"[[\"^ \
\",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]]"
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_called := true;
"true")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
Upsert.Upsert_block
(Upsert.Block_create
{
repo;
graph = Cli_config.repo_to_graph repo;
target = Upsert.Target_page "Home";
pos = Block.Last_child;
status = None;
tags = Vec.empty;
properties = Vec.empty;
blocks = Vec.singleton (Block.make ~title:"Child" ());
update_plan = Property.empty_update_plan;
})
in
let* result =
effect_to_promise
(execute_with_output Upsert.execute action cfg Output.Mode.Human)
in
expect_bool "upsert block create recycled target errors" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "upsert block create recycled code" "recycled-page"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected upsert block create error");
expect_bool "no create ops applied" false !apply_called;
Js.Promise.resolve pass));
test_promise "CLI parity upsert block update rejects recycled target page"
(fun () ->
let apply_called = ref false in
let pull_count = ref 0 in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then (
incr pull_count;
match !pull_count with
| 1 ->
"[\"^ \
\",\"~:db/id\",207,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000207\",\"~:block/title\",\"Source\"]"
| 2 ->
"[\"^ \
\",\"~:db/id\",50,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000050\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\",\"~:logseq.property/deleted-at\",1712000000000]"
| _ ->
fail_test
(Printf.sprintf "unexpected pull %d: %s" !pull_count body);
"")
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then (
apply_called := true;
"true")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
Upsert.Upsert_block
(Upsert.Block_update
{
repo;
graph = Cli_config.repo_to_graph repo;
source = Upsert.Source_id 207L;
target = Some (Upsert.Target_page "Home");
pos = Some Block.Last_child;
update_tags = Vec.empty;
update_properties = Vec.empty;
remove_tags = Vec.empty;
remove_properties = Vec.empty;
content = None;
source_label = Some "207";
target_label = Some "page:Home";
})
in
let* result =
effect_to_promise
(execute_with_output Upsert.execute action cfg Output.Mode.Human)
in
expect_bool "upsert block update recycled target errors" true
(Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "upsert block update recycled code" "recycled-page"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected upsert block update error");
expect_bool "no update ops applied" false !apply_called;
Js.Promise.resolve pass));
test "CLI parity upsert block update property parsing keeps update contracts"
(fun () ->
let block_update_opts =
{
Upsert.id = Some 207L;
uuid = None;
target_id = None;
target_uuid = None;
target_page = None;
pos = None;
content = None;
blocks_edn = None;
blocks_file = None;
update_tags_edn = None;
update_properties_edn = None;
remove_tags_edn = None;
remove_properties_edn = None;
}
in
expect_error_code "update-properties vector" "invalid-options"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_block
{ block_update_opts with update_properties_edn = Some "[\"p1\"]" }));
expect_error_code "update-tags map" "invalid-options"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_block
{ block_update_opts with update_tags_edn = Some "{\"t1\" true}" }));
let action =
expect_ok "valid update-properties map"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_block
{
block_update_opts with
update_properties_edn = Some "{\"p1\" \"val\"}";
}))
in
match action with
| Upsert.Upsert_block (Upsert.Block_update update) ->
expect_int "update property count" 1
(Vec.length update.update_properties);
expect_equal "update property key" "p1"
(property_key_text
(Vec.peek_front update.update_properties).Property.key)
| _ -> fail_test "expected Upsert_block update action");
test "CLI parity upsert tag parses schema property vectors" (fun () ->
let action =
expect_ok "valid tag schema properties"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_tag
{
id = None;
name = Some "Project";
add_properties_edn = Some "[\"status\" :user.property/owner]";
remove_properties_edn = Some "[123]";
}))
in
(match action with
| Upsert.Upsert_tag
{ mode = Upsert.Create; add_properties; remove_properties; _ } ->
expect_int "add property count" 2 (Vec.length add_properties);
expect_equal "add property by name" "status"
(property_key_text (Vec.peek_front add_properties));
expect_equal "remove property by id" "123"
(property_key_text (Vec.peek_front remove_properties))
| _ -> fail_test "expected Upsert_tag create action");
expect_error_code "add-properties map" "invalid-options"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_tag
{
id = None;
name = Some "Project";
add_properties_edn = Some "{\"status\" true}";
remove_properties_edn = None;
})));
test
"CLI parity upsert task build preserves create update and clear semantics"
(fun () ->
let base_task_opts =
{
Upsert.id = None;
uuid = None;
page = None;
content = None;
target_id = None;
target_uuid = None;
target_page = None;
pos = None;
status = None;
priority = None;
scheduled = None;
deadline = None;
no_status = false;
no_priority = false;
no_scheduled = false;
no_deadline = false;
}
in
let build_task name opts =
expect_ok name
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_task opts))
in
expect_error_code "task requires target" "missing-target"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_task base_task_opts));
expect_error_code "task rejects page and content" "invalid-options"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_task
{ base_task_opts with page = Some "Home"; content = Some "Task" }));
let scheduled =
expect_some "scheduled date"
(Time.parse_rfc3339 "2026-02-10T08:00:00.000Z")
in
let deadline =
expect_some "deadline date"
(Time.parse_rfc3339 "2026-02-12T18:00:00.000Z")
in
let created =
build_task "create task"
{
base_task_opts with
content = Some "Task from CLI";
status = Some "todo";
priority = Some "high";
scheduled = Some scheduled;
deadline = Some deadline;
}
in
(match created with
| Upsert.Upsert_task task ->
expect_bool "create mode" true (task.mode = Upsert.Create);
expect_equal "create repo" "logseq_db_demo" (repo_text task.repo);
expect_equal "create graph" "demo" (graph_text task.graph);
expect_equal "create content" "Task from CLI"
(expect_some "content" task.content);
expect_equal "status input" "todo"
(expect_some "status input" task.status_input);
let property_keys =
task.update_properties
|> Vec.map (fun item -> item.Property.key)
|> Vec.map property_key_text
in
expect_bool "priority update" true
(Vec.mem "logseq.property/priority" property_keys);
expect_bool "scheduled update" true
(Vec.mem "logseq.property/scheduled" property_keys);
expect_bool "deadline update" true
(Vec.mem "logseq.property/deadline" property_keys)
| _ -> fail_test "expected Upsert_task create action");
let updated =
build_task "update task"
{
base_task_opts with
id = Some 42L;
no_status = true;
no_priority = true;
no_scheduled = true;
no_deadline = true;
}
in
(match updated with
| Upsert.Upsert_task task ->
expect_bool "update mode" true (task.mode = Upsert.Update);
expect_int64 "update id" 42L (expect_some "update id" task.id);
let clear_keys = task.clear_properties |> Vec.map property_key_text in
expect_bool "clear status" true
(Vec.mem "logseq.property/status" clear_keys);
expect_bool "clear priority" true
(Vec.mem "logseq.property/priority" clear_keys);
expect_bool "clear scheduled" true
(Vec.mem "logseq.property/scheduled" clear_keys);
expect_bool "clear deadline" true
(Vec.mem "logseq.property/deadline" clear_keys)
| _ -> fail_test "expected Upsert_task update action");
Vec.iter
(fun opts ->
expect_error_code "task set no conflict" "invalid-options"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_task opts)))
(Vec.of_array
[|
{
base_task_opts with
id = Some 42L;
status = Some "todo";
no_status = true;
};
{
base_task_opts with
id = Some 42L;
priority = Some "high";
no_priority = true;
};
{
base_task_opts with
id = Some 42L;
scheduled = Some scheduled;
no_scheduled = true;
};
{
base_task_opts with
id = Some 42L;
deadline = Some deadline;
no_deadline = true;
};
|]);
let unknown_status =
build_task "unknown status"
{
base_task_opts with
content = Some "Task from CLI";
status = Some "wat";
}
in
(match unknown_status with
| Upsert.Upsert_task task ->
expect_bool "unknown status create mode" true
(task.mode = Upsert.Create);
expect_equal "unknown status input" "wat"
(expect_some "unknown status input" task.status_input)
| _ -> fail_test "expected Upsert_task unknown status action");
let invalid_priority =
Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_task
{
base_task_opts with
content = Some "Task from CLI";
priority = Some "wat";
})
in
expect_error_code "invalid priority code" "invalid-options"
invalid_priority;
match invalid_priority with
| Error err ->
expect_named_contains "invalid priority message" err.Error.message
"Invalid value for option :priority: wat";
expect_named_contains "invalid priority values" err.Error.message
"Available values: low, medium, high, urgent"
| Ok _ -> fail_test "expected invalid priority");
test "CLI parity upsert asset build derives title and selector modes"
(fun () ->
let base_asset_opts =
{
Upsert.id = None;
uuid = None;
path = None;
target_id = None;
target_uuid = None;
target_page = Some "Home";
pos = None;
content = None;
}
in
let created =
expect_ok "asset create"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_asset
{ base_asset_opts with path = Some "/tmp/images/team-logo.png" }))
in
(match created with
| Upsert.Upsert_asset asset ->
expect_bool "asset create mode" true (asset.mode = Upsert.Create);
expect_equal "asset create path" "/tmp/images/team-logo.png"
(expect_some "asset path" asset.path);
expect_equal "asset default title" "team-logo.png"
(expect_some "asset content" asset.content);
ignore (expect_some "asset create action" asset.create_action)
| _ -> fail_test "expected Upsert_asset create action");
let updated =
expect_ok "asset update"
(Upsert.build (config ~repo:"demo" ()) (Global_opts.create ())
(Upsert.Parsed_asset
{
base_asset_opts with
id = Some 42L;
target_page = None;
content = Some "Caption";
}))
in
match updated with
| Upsert.Upsert_asset asset ->
expect_bool "asset update mode" true (asset.mode = Upsert.Update);
expect_int64 "asset id" 42L (expect_some "asset id" asset.id);
expect_none "asset update path ignored" asset.path;
expect_equal "asset update content" "Caption"
(expect_some "asset update content" asset.content)
| _ -> fail_test "expected Upsert_asset update action");
test "CLI parity property and block option parsers normalize known values"
(fun () ->
expect_bool "position first-child" true
(Block.position_of_string "first-child" = Some Block.First_child);
expect_bool "position sibling" true
(Block.position_of_string "sibling" = Some Block.Sibling);
expect_none "position invalid" (Block.position_of_string "middle");
expect_bool "property url kind" true
(Property.kind_of_string "url" = Some Property.Url);
expect_equal "property kind string" "date"
(Property.string_of_kind Property.Date);
expect_equal "property other kind string" "user.property/custom"
(Property.string_of_kind
(Property.Other (Edn_util.keyword_t "user.property/custom")));
expect_bool "cardinality many" true
(Property.cardinality_of_string "many" = Some Property.Many);
expect_none "bad cardinality" (Property.cardinality_of_string "all");
(match Property.parse_key (Edn_util.int64 801L) with
| Some (Property.Key_id 801L) -> pass
| _ -> fail_test "expected property key id");
(match Property.parse_key (Edn_util.keyword "user.property/rating") with
| Some (Property.Key_ident ident) ->
expect_equal "property ident key" "user.property/rating"
(Edn_util.keyword_to_string ident)
| _ -> fail_test "expected property key ident");
(match Property.parse_key (Edn_util.string "Correct Answer") with
| Some (Property.Key_name name) ->
expect_equal "property name key" "Correct Answer" name
| _ -> fail_test "expected property key name");
expect_none "property key rejects bool"
(Property.parse_key (Edn_util.bool true));
let child =
Block.make ~uuid:"00000000-0000-4000-8000-000000000102" ~title:"Child"
()
in
let root =
Block.make ~uuid:"00000000-0000-4000-8000-000000000101" ~title:"Root"
~children:(Vec.singleton child) ()
in
let flat = Block.flatten (Vec.singleton root) in
expect_int "flatten count" 2 (Vec.length flat);
expect_equal "flatten root first" "Root"
(expect_some "root label" (Block.label (Vec.nth flat 0)));
expect_equal "flatten child second" "Child"
(expect_some "child label" (Block.label (Vec.nth flat 1)));
let serialized = Edn_util.any (Block.to_value root) in
expect_equal "serialized block title" "Root"
(expect_some "serialized title"
(Edn_util.get_string serialized "block/title"));
expect_bool "serialized child vector" true
(Option.is_some (Edn_util.get serialized "block/children")));
test "CLI parity add block parsing preserves uuids tags and raw blocks"
(fun () ->
let parsed =
expect_ok "parse blocks"
(Add.parse_blocks_edn ~label:"blocks"
"[{:block/title \"Root\" :block/uuid #uuid \
\"00000000-0000-4000-8000-000000000301\" :block/tags \
[\"Project\" :logseq.class/Tag] :block/children [{:block/content \
\"Child\" :block/uuid #uuid \
\"00000000-0000-4000-8000-000000000302\"}]} \"Loose\"]")
in
expect_int "parsed block count" 2 (Vec.length parsed);
let root = Vec.nth parsed 0 in
expect_equal "root title" "Root" (expect_some "root title" root.title);
expect_equal "root uuid" "00000000-0000-4000-8000-000000000301"
(expect_some "root uuid" root.uuid);
expect_int "root tags" 2 (Vec.length root.tags);
expect_int "root children" 1 (Vec.length root.children);
expect_equal "child content title" "Child"
(expect_some "child title" (Vec.peek_front root.children).title);
expect_equal "string block title" "Loose"
(expect_some "loose title" (Vec.nth parsed 1).title);
expect_error_code "blocks must be vector" "invalid-blocks"
(Add.parse_blocks_edn ~label:"blocks" "{:block/title \"Root\"}");
expect_error_code "invalid blocks edn" "invalid-options"
(Add.parse_blocks_edn ~label:"blocks" "[{:block/title"));
test "CLI parity add collect created block uuids depth-first and unique"
(fun () ->
let nested =
Edn_util.map
[
( Edn_util.keyword "block/uuid",
Edn_util.uuid "00000000-0000-4000-8000-000000000401" );
( Edn_util.keyword "block/children",
Edn_util.vector
[
Edn_util.map
[
( Edn_util.keyword "block/uuid",
Edn_util.uuid "00000000-0000-4000-8000-000000000402" );
];
Edn_util.map
[
(Edn_util.keyword "block/title", Edn_util.string "No uuid");
( Edn_util.keyword "block/children",
Edn_util.vector
[
Edn_util.map
[
( Edn_util.keyword "block/uuid",
Edn_util.uuid
"00000000-0000-4000-8000-000000000403" );
];
] );
];
] );
]
in
let inserted =
Edn_util.vector
[
nested;
Edn_util.map
[
( Edn_util.keyword "block/uuid",
Edn_util.uuid "00000000-0000-4000-8000-000000000404" );
];
Edn_util.map
[
( Edn_util.keyword "block/uuid",
Edn_util.uuid "00000000-0000-4000-8000-000000000402" );
];
]
in
expect_equal "collected uuids"
"00000000-0000-4000-8000-000000000401,00000000-0000-4000-8000-000000000402,00000000-0000-4000-8000-000000000403,00000000-0000-4000-8000-000000000404"
(Vec.string_concat ","
(Add.collect_uuids_from_value inserted |> Add.unique));
let child =
Block.make ~uuid:"00000000-0000-4000-8000-000000000502" ~title:"Child"
()
in
let root =
Block.make ~uuid:"00000000-0000-4000-8000-000000000501" ~title:"Root"
~children:(Vec.singleton child) ()
in
let duplicate =
Block.make ~uuid:"00000000-0000-4000-8000-000000000502"
~title:"Duplicate" ()
in
expect_equal "action uuids"
"00000000-0000-4000-8000-000000000501,00000000-0000-4000-8000-000000000502"
(Vec.string_concat ","
(Add.collect_action_block_uuids (Vec.of_array [| root; duplicate |]))));
test "CLI parity add action validates targets metadata and status" (fun () ->
let base_opts =
{
Add.target_id = None;
target_uuid = None;
target_page_name = Some "Home";
pos = None;
status = None;
tags_edn = None;
properties_edn = None;
content = Some "Hello from add";
blocks_edn = None;
blocks_file = None;
}
in
expect_error_code "ambiguous target" "invalid-options"
(Add.build_add_block_action
{ base_opts with target_id = Some 42L }
Vec.empty
(Cli_primitive.create_repo "demo"));
expect_error_code "invalid target uuid" "invalid-options"
(Add.build_add_block_action
{ base_opts with target_page_name = None; target_uuid = Some "bad" }
Vec.empty
(Cli_primitive.create_repo "demo"));
expect_error_code "sibling requires block target" "invalid-options"
(Add.build_add_block_action
{ base_opts with pos = Some Block.Sibling }
Vec.empty
(Cli_primitive.create_repo "demo"));
expect_error_code "blocks cannot combine metadata" "invalid-options"
(Add.build_add_block_action
{
base_opts with
blocks_edn = Some "[\"Block\"]";
tags_edn = Some "[\"Project\"]";
}
Vec.empty
(Cli_primitive.create_repo "demo"));
expect_error_code "invalid status" "invalid-options"
(Add.build_add_block_action
{ base_opts with status = Some "wat" }
Vec.empty
(Cli_primitive.create_repo "demo"));
let action =
expect_ok "add action"
(Add.build_add_block_action
{
base_opts with
status = Some "in progress";
tags_edn =
Some
"[\"Project\" :logseq.class/Tag #uuid \
\"00000000-0000-4000-8000-000000000601\" 701]";
properties_edn = Some "{\"Correct Answer\" \"42\" 801 true}";
}
Vec.empty
(Cli_primitive.create_repo "demo"))
in
expect_equal "repo" "logseq_db_demo" (repo_text action.repo);
expect_equal "graph" "demo" (graph_text action.graph);
expect_bool "default position" true (action.pos = Block.Last_child);
expect_equal "normalized status" "logseq.property/status.doing"
(keyword_text (expect_some "status" action.status));
expect_int "tag selectors" 4 (Vec.length action.tags);
expect_int "property assignments" 2 (Vec.length action.properties);
expect_int "block count" 1 (Vec.length action.blocks);
expect_equal "content block" "Hello from add"
(expect_some "block title" (Vec.peek_front action.blocks).title);
ignore (expect_some "generated uuid" (Vec.peek_front action.blocks).uuid));
test "CLI parity add metadata ops apply status tags and properties by uuid"
(fun () ->
let ops =
Add.metadata_ops
(Vec.of_array
[|
"00000000-0000-4000-8000-000000000701";
"00000000-0000-4000-8000-000000000702";
|])
(Some (Edn_util.keyword_t "logseq.property/status.todo"))
(Vec.of_array
[|
Entity.of_value
(Edn_util.map_vec
(Vec.singleton
(Edn_util.keyword "db/id", Edn_util.int64 901L)));
Entity.of_value
(Edn_util.map_vec
(Vec.singleton
(Edn_util.keyword "db/id", Edn_util.int64 901L)));
|])
(Vec.of_array
[|
{
Property.key =
Property.Key_ident
(Edn_util.keyword_t "user.property/root-answer");
value = Edn_util.string "root";
};
|])
in
expect_int "metadata ops count" 3 (Vec.length ops);
let output =
Vec.string_concat "\n" (Vec.map Melange_edn_melange.to_edn_string ops)
in
expect_named_contains "status op" output ":logseq.property/status.todo";
expect_named_contains "tag op" output ":block/tags 901";
expect_named_contains "property op" output
":user.property/root-answer \"root\"");
test_promise
"CLI parity add reports target-not-found when concurrent delete removes \
insert target" (fun () ->
let pull_count = ref 0 in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then (
incr pull_count;
match !pull_count with
| 1 ->
"[\"^ \
\",\"~:db/id\",627,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000627\"]"
| _ -> "null")
else if
Js.String.includes ~search:"thread-api/apply-outliner-ops" body
then "null"
else "null")
in
with_server server (fun base_url ->
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let action =
expect_ok "add action"
(Add.build_add_block_action
{
Add.target_id = Some 627L;
target_uuid = None;
target_page_name = None;
pos = Some Block.First_child;
status = None;
tags_edn = None;
properties_edn = None;
content = Some "Child";
blocks_edn = None;
blocks_file = None;
}
Vec.empty
(Cli_primitive.create_repo "demo"))
in
let* result =
effect_to_promise
(Add.execute_add_block action
(config_with_output cfg Output.Mode.Human)
Output.Mode.Human)
in
expect_bool "add result is error" true (Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "add concurrent target delete code"
"target-not-found"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected add error");
expect_int "target was rechecked" 3 !pull_count;
Js.Promise.resolve pass));
test "CLI parity update build action validates property and tag edn"
(fun () ->
let base_opts =
{
Update.id = Some 207L;
uuid = None;
target_id = None;
target_uuid = None;
target_page = None;
pos = None;
status = None;
content = None;
update_tags_edn = None;
update_properties_edn = None;
remove_tags_edn = None;
remove_properties_edn = None;
blocks_edn = None;
blocks_file = None;
}
in
expect_error_code "update properties vector" "invalid-options"
(Update.build_action
{ base_opts with update_properties_edn = Some "[\"p1\"]" }
(Cli_primitive.create_repo "demo"));
expect_error_code "update tags map" "invalid-options"
(Update.build_action
{ base_opts with update_tags_edn = Some "{\"t1\" true}" }
(Cli_primitive.create_repo "demo"));
expect_error_code "missing source" "missing-source"
(Update.build_action
{
base_opts with
id = None;
update_properties_edn = Some "{\"p1\" 1}";
}
(Cli_primitive.create_repo "demo"));
let action =
expect_ok "valid update"
(Update.build_action
{
base_opts with
status = Some "todo";
content = Some "Updated";
target_page = Some "Archive";
update_tags_edn = Some "[\"Project\"]";
update_properties_edn = Some "{\"p1\" \"val\"}";
remove_tags_edn = Some "[701]";
remove_properties_edn = Some "[\"old-prop\"]";
}
(Cli_primitive.create_repo "demo"))
in
expect_equal "repo" "logseq_db_demo" (repo_text action.repo);
expect_equal "graph" "demo" (graph_text action.graph);
expect_int64 "source id" 207L (expect_some "id" action.id);
expect_equal "content" "Updated" (expect_some "content" action.content);
expect_equal "target page" "Archive"
(expect_some "target page" action.target_page);
expect_bool "default move position" true
(action.pos = Some Block.First_child);
expect_equal "source label" "207"
(expect_some "source label" action.source_label);
expect_equal "target label" "page:Archive"
(expect_some "target label" action.target_label);
expect_int "update tags" 1 (Vec.length action.update_tags);
expect_int "remove tags" 1 (Vec.length action.remove_tags);
expect_int "update properties include status" 2
(Vec.length action.update_properties);
expect_int "remove properties" 1 (Vec.length action.remove_properties));
test "CLI parity update tag query uses datascript find syntax" (fun () ->
let query =
Melange_edn_melange.to_edn_string (Update.tag_query Update.tag_selector)
in
expect_named_contains "find" query ":find";
expect_named_contains "tag class" query "logseq.class/Tag");
test "CLI parity sync config key parser accepts ws-url and http-base only"
(fun () ->
expect_bool "ws-url" true
(Sync.config_key_of_string "ws-url" = Some Sync.Ws_url);
expect_bool "http-base" true
(Sync.config_key_of_string "http-base" = Some Sync.Http_base);
expect_equal "ws-url string" "ws-url"
(Sync.string_of_config_key Sync.Ws_url);
expect_none "bad sync key" (Sync.config_key_of_string "graph"));
test "CLI parity sync build validates repo selectors and e2ee options"
(fun () ->
let globals =
Global_opts.create ~graph:(Cli_primitive.create_graph "demo") ()
in
expect_error_code "sync status requires repo" "missing-repo"
(Sync.build (config ()) (Global_opts.create ()) Sync.Parsed_status);
expect_error_code "sync download requires graph" "missing-graph"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_download { progress = None; e2ee_password = None }));
let default_download =
expect_ok "sync download default"
(Sync.build (config ()) globals
(Sync.Parsed_download { progress = None; e2ee_password = None }))
in
(match default_download with
| Sync.Sync_download action ->
expect_equal "download repo" "logseq_db_demo" (repo_text action.repo);
expect_equal "download graph" "demo" (graph_text action.graph);
expect_bool "download allow missing" true action.allow_missing_graph;
expect_bool "download require missing" true
action.require_missing_graph;
expect_bool "download progress default" false action.progress;
expect_bool "download progress not explicit" false
action.progress_explicit
| _ -> fail_test "expected Sync_download action");
let explicit_download =
expect_ok "sync download explicit"
(Sync.build (config ()) globals
(Sync.Parsed_download
{ progress = Some false; e2ee_password = Some "pw" }))
in
(match explicit_download with
| Sync.Sync_download action ->
expect_bool "download progress false" false action.progress;
expect_bool "download progress explicit" true action.progress_explicit;
expect_equal "download password" "pw"
(expect_some "download e2ee" action.e2ee_password)
| _ -> fail_test "expected explicit Sync_download action");
let start =
expect_ok "sync start"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_start { e2ee_password = Some "pw" }))
in
(match start with
| Sync.Sync_start action ->
expect_equal "start password" "pw"
(expect_some "start e2ee" action.e2ee_password)
| _ -> fail_test "expected Sync_start action");
let upload =
expect_ok "sync upload"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_upload { e2ee_password = Some "pw" }))
in
(match upload with
| Sync.Sync_upload action ->
expect_equal "upload password" "pw"
(expect_some "upload e2ee" action.e2ee_password)
| _ -> fail_test "expected Sync_upload action");
let ensure_keys =
expect_ok "sync ensure keys"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_ensure_keys
{ e2ee_password = Some "pw"; upload_keys = true }))
in
match ensure_keys with
| Sync.Sync_ensure_keys action ->
expect_equal "ensure keys password" "pw"
(expect_some "ensure keys e2ee" action.e2ee_password);
expect_bool "ensure keys upload" true action.upload_keys
| _ -> fail_test "expected Sync_ensure_keys action");
test "CLI parity sync build validates config and asset download actions"
(fun () ->
expect_error_code "config set missing key" "invalid-options"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_config_set { key = None; value = None }));
expect_error_code "config set missing value" "invalid-options"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_config_set { key = Some Sync.Ws_url; value = None }));
expect_error_code "config get missing key" "invalid-options"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_config_get { key = None }));
expect_error_code "config unset missing key" "invalid-options"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_config_unset { key = None }));
let set_action =
expect_ok "config set"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_config_set
{ key = Some Sync.Ws_url; value = Some "ws://example/%s" }))
in
(match set_action with
| Sync.Sync_config_set { key; value } ->
expect_bool "config set key" true (key = Sync.Ws_url);
expect_equal "config set value" "ws://example/%s" value
| _ -> fail_test "expected Sync_config_set action");
expect_error_code "grant access missing graph id" "invalid-options"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_grant_access
{ graph_id = None; email = Some "user@example.com" }));
expect_error_code "grant access missing email" "invalid-options"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_grant_access
{
graph_id = Some "11111111-1111-4111-8111-111111111111";
email = None;
}));
let by_id =
expect_ok "asset download id"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_asset_download { id = Some 123L; uuid = None }))
in
(match by_id with
| Sync.Sync_asset_download action ->
expect_equal "asset repo" "logseq_db_demo" (repo_text action.repo);
expect_equal "asset graph" "demo" (graph_text action.graph);
expect_int64 "asset id" 123L (expect_some "asset id" action.id);
expect_none "asset uuid absent" action.uuid
| _ -> fail_test "expected Sync_asset_download id action");
let asset_uuid = "11111111-1111-4111-8111-111111111111" in
let by_uuid =
expect_ok "asset download uuid"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_asset_download { id = None; uuid = Some asset_uuid }))
in
(match by_uuid with
| Sync.Sync_asset_download action ->
expect_equal "asset uuid" asset_uuid
(expect_some "asset uuid" action.uuid);
expect_none "asset id absent" action.id
| _ -> fail_test "expected Sync_asset_download uuid action");
expect_error_code "asset download requires repo" "missing-repo"
(Sync.build (config ()) (Global_opts.create ())
(Sync.Parsed_asset_download { id = Some 123L; uuid = None }));
expect_error_code "asset download requires selector" "invalid-options"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_asset_download { id = None; uuid = None }));
expect_error_code "asset download rejects conflict" "invalid-options"
(Sync.build (config ~repo:"demo" ()) (Global_opts.create ())
(Sync.Parsed_asset_download
{ id = Some 123L; uuid = Some asset_uuid })));
test_promise
"CLI parity sync asset download requests missing local remote asset"
(fun () ->
let root = temp_dir "logseq-cli-sync-asset-" in
let request_called = ref false in
let asset_uuid = "00000000-0000-4000-8000-000000000abc" in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then (
expect_named_contains "uuid lookup" body asset_uuid;
"[\"^ \
\",\"~:db/id\",123,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000abc\",\"~:block/tags\",[[\"^ \
\",\"~:db/ident\",\"~:logseq.class/Asset\"]],\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \
\"]]")
else if Js.String.includes ~search:"thread-api/db-sync-status" body
then
"[\"^ \
\",\"~:repo\",\"logseq_db_demo\",\"~:graph-id\",\"graph-id\",\"~:ws-state\",\"~:open\"]"
else if
Js.String.includes
~search:"thread-api/db-sync-request-asset-download" body
then (
request_called := true;
expect_named_contains "download request uuid" body asset_uuid;
"[\"^ \",\"~:result\",true]")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Sync.Sync_asset_download
{
repo;
graph = Cli_config.repo_to_graph repo;
id = None;
uuid = Some asset_uuid;
}
in
let cfg =
{
(config ~repo:"demo" ~root_dir:root ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Sync.execute action cfg Output.Mode.Human)
in
remove_tree root;
expect_bool "asset download ok" false (Cli_result.is_error result);
let data =
expect_some "asset download data" (Cli_result.data_value result)
in
expect_equal "asset uuid" asset_uuid
(expect_some "asset uuid" (Edn_util.get_string data "asset-uuid"));
expect_equal "asset type" "png"
(expect_some "asset type" (Edn_util.get_string data "asset-type"));
expect_bool "download requested" true
(expect_some "download requested"
(Edn_util.get_bool data "download-requested?"));
expect_equal "checksum missing" "missing"
(expect_some "checksum status"
(Option.bind
(Edn_util.get data "checksum-status")
Edn_util.as_keyword));
expect_bool "request called" true !request_called;
Js.Promise.resolve pass));
test_promise
"CLI parity sync asset download requires active sync before requesting"
(fun () ->
let root = temp_dir "logseq-cli-sync-asset-stopped-" in
let request_called = ref false in
let asset_uuid = "00000000-0000-4000-8000-000000000abc" in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then
"[\"^ \
\",\"~:db/id\",123,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000abc\",\"~:block/tags\",[[\"^ \
\",\"~:db/ident\",\"~:logseq.class/Asset\"]],\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \
\"]]"
else if Js.String.includes ~search:"thread-api/db-sync-status" body
then
"[\"^ \
\",\"~:repo\",\"logseq_db_demo\",\"~:graph-id\",\"graph-id\",\"~:ws-state\",\"~:stopped\"]"
else if
Js.String.includes
~search:"thread-api/db-sync-request-asset-download" body
then (
request_called := true;
"[\"^ \",\"~:result\",true]")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Sync.Sync_asset_download
{
repo;
graph = Cli_config.repo_to_graph repo;
id = None;
uuid = Some asset_uuid;
}
in
let cfg =
{
(config ~repo:"demo" ~root_dir:root ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Sync.execute action cfg Output.Mode.Human)
in
remove_tree root;
expect_bool "asset download error" true (Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal "sync not started code" "sync-not-started"
(Error.code_to_string err.Error.code);
expect_equal "sync start hint"
"Run logseq sync start --graph demo first."
(expect_some "sync hint" err.hint)
| None -> fail_test "expected sync not started error");
expect_bool "request not called" false !request_called;
Js.Promise.resolve pass));
test_promise "CLI parity sync asset download validates remote asset metadata"
(fun () ->
let asset_uuid = "00000000-0000-4000-8000-000000000abc" in
let valid_tags =
"\"~:block/tags\",[[\"^ \",\"~:db/ident\",\"~:logseq.class/Asset\"]]"
in
let base_fields =
"\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\","
^ valid_tags
^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \
\"]"
in
let asset fields = "[\"^ \"," ^ fields ^ "]" in
let run_case label asset_transit expected_code =
let root = temp_dir ("logseq-cli-sync-asset-" ^ label ^ "-") in
let request_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/pull" body then
asset_transit
else if
Js.String.includes
~search:"thread-api/db-sync-request-asset-download" body
then (
request_called := true;
"[\"^ \",\"~:result\",true]")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Sync.Sync_asset_download
{
repo;
graph = Cli_config.repo_to_graph repo;
id = None;
uuid = Some asset_uuid;
}
in
let cfg =
{
(config ~repo:"demo" ~root_dir:root ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Sync.execute action cfg Output.Mode.Human)
in
remove_tree root;
expect_bool (label ^ " error") true (Cli_result.is_error result);
(match result.Cli_result.error with
| Some err ->
expect_equal (label ^ " code") expected_code
(Error.code_to_string err.Error.code)
| None -> fail_test (label ^ ": expected error"));
expect_bool (label ^ " no request") false !request_called;
Js.Promise.resolve pass)
in
let* () = run_case "missing" "null" "asset-not-found" in
let* () =
run_case "non-asset"
(asset
("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid
^ "\",\"~:block/tags\",[],\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \
\"]"))
"not-asset"
in
let* () =
run_case "missing-uuid"
(asset
("\"~:db/id\",123," ^ valid_tags
^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \
\"]"))
"asset-uuid-missing"
in
let* () =
run_case "missing-type"
(asset
("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\","
^ valid_tags
^ ",\"~:logseq.property.asset/checksum\",\"sha\",\"~:logseq.property.asset/remote-metadata\",[\"^ \
\"]"))
"asset-type-missing"
in
let* () =
run_case "missing-checksum"
(asset
("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\","
^ valid_tags
^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/remote-metadata\",[\"^ \
\"]"))
"asset-checksum-missing"
in
let* () =
run_case "missing-remote"
(asset
("\"~:db/id\",123,\"~:block/uuid\",\"~u" ^ asset_uuid ^ "\","
^ valid_tags
^ ",\"~:logseq.property.asset/type\",\"png\",\"~:logseq.property.asset/checksum\",\"sha\""
))
"asset-not-remote"
in
let* () =
run_case "external"
(asset
(base_fields
^ ",\"~:logseq.property.asset/external-url\",\"https://example.com/a.png\""
))
"external-asset"
in
Js.Promise.resolve pass);
test
"CLI parity query validation rejects db/id datom clauses and non-vector \
queries" (fun () ->
let valid =
edn_of_string "[:find ?b :in $ ?id :where [?b :block/title ?title]]"
in
ignore (expect_ok "valid query" (Query.validate_query valid));
let invalid = edn_of_string "[:find ?b :where [?b :db/id ?id]]" in
expect_error_code "db/id datom" ":invalid-query"
(Query.validate_query invalid);
expect_error_code "non vector query" ":invalid-options"
(Query.validate_query (Edn_util.map_vec Vec.empty)));
test "CLI parity datascript query primitive serializes query clauses"
(fun () ->
let title_clause =
Edn_util.vector_t
[
Edn_util.symbol "?b";
Edn_util.keyword "block/title";
Edn_util.symbol "?title";
]
in
let page_clause =
Edn_util.vector_t
[
Edn_util.symbol "?b";
Edn_util.keyword "block/page";
Edn_util.symbol "?page";
]
in
let predicate_clause =
Edn_util.list_t
[
Edn_util.symbol "clojure.string/includes?";
Edn_util.symbol "?title";
Edn_util.symbol "?query";
]
in
let edn query =
Melange_edn_melange.to_edn_string
(Edn_util.any (Cli_primitive.datascript_query_to_edn query))
in
let without_inputs =
Cli_primitive.make_datascript_query
~find:(Vec.singleton (Edn_util.symbol "?b"))
~where:(Vec.singleton (Cli_primitive.V title_clause))
()
in
expect_equal "datascript query without inputs"
"[:find ?b :where [?b :block/title ?title]]" (edn without_inputs);
let with_inputs =
Cli_primitive.make_datascript_query
~find:(Vec.singleton (Edn_util.symbol "?b"))
~in_:
(Vec.of_array
[|
Melange_edn_melange.symbol "$";
Melange_edn_melange.symbol "?title";
|])
~where:(Vec.singleton (Cli_primitive.V title_clause))
()
in
expect_equal "datascript query with inputs"
"[:find ?b :in $ ?title :where [?b :block/title ?title]]"
(edn with_inputs);
let with_empty_inputs =
Cli_primitive.make_datascript_query
~find:(Vec.singleton (Edn_util.symbol "?b"))
~in_:Vec.empty
~where:(Vec.singleton (Cli_primitive.V title_clause))
()
in
expect_equal "datascript query with empty inputs"
"[:find ?b :where [?b :block/title ?title]]" (edn with_empty_inputs);
let with_multiple_clauses =
Cli_primitive.make_datascript_query
~find:(Vec.singleton (Edn_util.symbol "?b"))
~where:
(Vec.of_array
[| Cli_primitive.V title_clause; Cli_primitive.V page_clause |])
()
in
expect_equal "datascript query with multiple where clauses"
"[:find ?b :where [?b :block/title ?title] [?b :block/page ?page]]"
(edn with_multiple_clauses);
let with_list_clause =
Cli_primitive.make_datascript_query
~find:
(Vec.of_array [| Edn_util.symbol "?b"; Edn_util.symbol "?title" |])
~where:
(Vec.of_array
[|
Cli_primitive.V title_clause; Cli_primitive.L predicate_clause;
|])
()
in
expect_equal "datascript query with list where clause"
"[:find ?b ?title :where [?b :block/title ?title] \
(clojure.string/includes? ?title ?query)]"
(edn with_list_clause));
test
"CLI parity query input normalization fills defaults and validates \
required inputs" (fun () ->
let entry =
{
Query.name = "custom";
source = Query.Custom;
doc = None;
inputs =
Vec.of_array
[|
({ name = "?required"; optional = false; default = None }
: Query.input_spec);
({
name = "?optional";
optional = true;
default = Some (Edn_util.string "fallback");
}
: Query.input_spec);
|];
query = Edn_util.vector_vec Vec.empty;
}
in
expect_error_code "missing required input" ":invalid-options"
(Query.normalize_inputs (Some entry) Vec.empty);
let inputs =
expect_ok "normalize inputs"
(Query.normalize_inputs (Some entry)
(Vec.singleton (Edn_util.string "value")))
in
expect_int "input count" 2 (Vec.length inputs);
expect_equal "default input" "fallback"
(expect_some "default" (Edn_util.as_string (Vec.nth inputs 1)));
let padded =
{
entry with
Query.name = "padded";
inputs =
Vec.of_array
[|
({ name = "required"; optional = false; default = None }
: Query.input_spec);
({ name = "?optional"; optional = true; default = None }
: Query.input_spec);
|];
}
in
let padded_inputs =
expect_ok "pad optional input"
(Query.normalize_inputs (Some padded)
(Vec.singleton (Edn_util.string "value")))
in
expect_bool "optional input pads nil" true
(Edn_util.is_null (Vec.nth padded_inputs 1));
let task_search =
expect_some "task-search entry"
(Query.find_query (config ()) "task-search")
in
let task_inputs =
expect_ok "task-search defaults"
(Query.normalize_inputs (Some task_search)
(Vec.singleton (Edn_util.string "doing")))
in
expect_equal "task status keyword" "logseq.property/status.doing"
(expect_some "status keyword"
(Edn_util.as_keyword (Vec.nth task_inputs 0)));
expect_equal "task title default" ""
(expect_some "title default"
(Edn_util.as_string (Vec.nth task_inputs 1)));
expect_int "task recent-days default" 0
(expect_some "recent-days default"
(Edn_util.as_int (Vec.nth task_inputs 2)));
ignore
(expect_some "task now-ms default"
(Edn_util.as_int64 (Vec.nth task_inputs 3))));
test "CLI parity query build action parses query inputs and named queries"
(fun () ->
let run =
expect_ok "query run"
(Query.build (config ~repo:"demo" ()) (Global_opts.create ())
(Query.Parsed_run
{
query_edn =
Some
"[:find ?e :in $ ?title :where [?e :block/title ?title]]";
name = None;
inputs_edn = Some "[\"Hello\"]";
}))
in
(match run with
| Query.Run { repo; graph; query; inputs; name } ->
expect_equal "query repo" "logseq_db_demo"
(Cli_primitive.string_of_repo repo);
expect_equal "query graph" "demo"
(Cli_primitive.string_of_graph graph);
ignore (expect_ok "built query valid" (Query.validate_query query));
expect_int "query inputs length" 1 (Vec.length inputs);
expect_equal "query input" "Hello"
(expect_some "query input string"
(Edn_util.as_string (Vec.peek_front inputs)));
expect_none "query name none" name
| Query.List -> fail_test "expected query run");
expect_error_code "query invalid edn" "invalid-options"
(Query.build (config ~repo:"demo" ()) (Global_opts.create ())
(Query.Parsed_run
{ query_edn = Some "[:find ?e"; name = None; inputs_edn = None }));
expect_error_code "query invalid inputs edn" "invalid-options"
(Query.build (config ~repo:"demo" ()) (Global_opts.create ())
(Query.Parsed_run
{
query_edn = Some "[:find ?e :where [?e :block/title \"Hello\"]]";
name = None;
inputs_edn = Some "[\"Hello";
}));
let non_vector_inputs =
Query.build (config ~repo:"demo" ()) (Global_opts.create ())
(Query.Parsed_run
{
query_edn = None;
name = Some "recent-updated";
inputs_edn = Some "1";
})
in
expect_error_code "query non-vector inputs" "invalid-options"
non_vector_inputs;
(match non_vector_inputs with
| Error err ->
expect_equal "query non-vector inputs message"
"inputs must be a vector" err.Error.message
| Ok _ -> fail_test "query non-vector inputs: expected error");
expect_error_code "query rejects db/id datom" "invalid-query"
(Query.build (config ~repo:"demo" ()) (Global_opts.create ())
(Query.Parsed_run
{
query_edn =
Some
"[:find (pull ?v [*]) :where [?b :db/id 400] [?b \
:user.property/owner ?v]]";
name = None;
inputs_edn = None;
}));
expect_error_code "query requires repo" "missing-repo"
(Query.build (config ()) (Global_opts.create ())
(Query.Parsed_run
{
query_edn = Some "[:find ?e :where [?e :block/title]]";
name = None;
inputs_edn = None;
}));
let raw_config =
Edn_util.map
[
( Edn_util.keyword "custom-queries",
Edn_util.map
[
( Edn_util.string "my-query",
Edn_util.map
[
(Edn_util.keyword "doc", Edn_util.string "Custom query");
( Edn_util.keyword "inputs",
Edn_util.vector_vec
(Vec.singleton (Edn_util.string "title")) );
( Edn_util.keyword "query",
edn_of_string
"[:find ?e :in $ ?title :where [?e :block/title \
?title]]" );
] );
] );
]
in
let named =
expect_ok "named query"
(Query.build
(config ~repo:"demo" ~raw_file_config:raw_config ())
(Global_opts.create ())
(Query.Parsed_run
{
query_edn = None;
name = Some ":my-query";
inputs_edn = Some "[\"Alpha\"]";
}))
in
(match named with
| Query.Run { inputs; name; _ } ->
expect_equal "named query normalized" "my-query"
(expect_some "named query name" name);
expect_equal "named query input" "Alpha"
(expect_some "named query input"
(Edn_util.as_string (Vec.peek_front inputs)))
| Query.List -> fail_test "expected named query run");
expect_error_code "unknown named query" "unknown-query"
(Query.build
(config ~repo:"demo"
~raw_file_config:(Edn_util.map_vec Vec.empty)
())
(Global_opts.create ())
(Query.Parsed_run
{ query_edn = None; name = Some "missing"; inputs_edn = None }));
expect_error_code "query name conflict" "invalid-options"
(Query.build
(config ~repo:"demo" ~raw_file_config:raw_config ())
(Global_opts.create ())
(Query.Parsed_run
{
query_edn = Some "[:find ?e]";
name = Some "my-query";
inputs_edn = None;
})));
test_promise "CLI parity query execute appends DSL rules for percent input"
(fun () ->
let raw_config =
Edn_util.map
[
( Edn_util.keyword "custom-queries",
Edn_util.map
[
( Edn_util.string "task",
Edn_util.map
[
( Edn_util.keyword "inputs",
Edn_util.vector
[ Edn_util.string "task statuses set" ] );
( Edn_util.keyword "query",
edn_of_string
"[:find (pull ?b [*]) :in $ ?status % :where (task \
?b ?status)]" );
] );
] );
]
in
let cfg = config ~repo:"demo" ~raw_file_config:raw_config () in
let action =
expect_ok "task query build"
(Query.build cfg (Global_opts.create ())
(Query.Parsed_run
{
query_edn = None;
name = Some "task";
inputs_edn = Some "[\"Todo\"]";
}))
in
let captured_body = ref None in
let server =
invoke_server (fun body ->
captured_body := Some body;
"[]")
in
with_server server (fun base_url ->
let cfg = { cfg with Cli_config.base_url = Some base_url } in
let* result =
effect_to_promise
(execute_with_output Query.execute action cfg Output.Mode.Human)
in
expect_bool "query execute ok" false (Cli_result.is_error result);
let body = expect_some "query request body" !captured_body in
expect_named_contains "query method" body "thread-api/q";
expect_named_contains "query input" body "Todo";
expect_named_contains "rules parent" body "parent";
expect_named_contains "rules class extends" body "class-extends";
expect_named_contains "rules task" body "task";
Js.Promise.resolve pass));
test "CLI parity query list merges built-in and custom query metadata"
(fun () ->
let raw_config =
Edn_util.map
[
( Edn_util.keyword "custom-queries",
Edn_util.map
[
(Edn_util.string "custom-q", edn_of_string "[:find ?e]");
(Edn_util.string "block-search", edn_of_string "[:find ?b]");
] );
]
in
let queries =
Query.list_queries (config ~raw_file_config:raw_config ())
in
let names =
Vec.map (fun (entry : Query.query_entry) -> entry.name) queries
in
expect_bool "task-search built-in listed" true
(Vec.mem "task-search" names);
expect_bool "list-status built-in listed" true
(Vec.mem "list-status" names);
expect_bool "list-priority built-in listed" true
(Vec.mem "list-priority" names);
expect_bool "custom query listed" true (Vec.mem "custom-q" names);
let block_search =
expect_some "block-search override"
(Vec.find_opt
(fun (entry : Query.query_entry) -> entry.name = "block-search")
queries)
in
expect_bool "block-search custom source" true
(block_search.source = Query.Custom);
let list_status =
expect_some "list-status"
(Vec.find_opt
(fun (entry : Query.query_entry) -> entry.name = "list-status")
queries)
in
expect_int "list-status inputs" 0 (Vec.length list_status.inputs);
let status_query = Melange_edn_melange.to_edn_string list_status.query in
expect_named_contains "list-status query status keyword" status_query
":logseq.property/status";
let list_priority =
expect_some "list-priority"
(Vec.find_opt
(fun (entry : Query.query_entry) -> entry.name = "list-priority")
queries)
in
expect_int "list-priority inputs" 0 (Vec.length list_priority.inputs);
expect_named_contains "list-priority query priority keyword"
(Melange_edn_melange.to_edn_string list_priority.query)
":logseq.property/priority");
test "CLI parity debug selector requires exactly one id uuid or ident"
(fun () ->
expect_error_code "missing selector" ":invalid-options"
(Debug.resolve_selector { id = None; uuid = None; ident = None });
let by_id =
expect_ok "debug id"
(Debug.resolve_selector { id = Some 9L; uuid = None; ident = None })
in
(match by_id with Debug.By_id 9L -> pass | _ -> fail_test "expected id");
expect_error_code "ambiguous selector" ":invalid-options"
(Debug.resolve_selector
{
id = Some 9L;
uuid = Some "00000000-0000-4000-8000-000000000001";
ident = None;
});
expect_error_code "bad uuid" ":invalid-options"
(Debug.resolve_selector
{ id = None; uuid = Some "not-a-uuid"; ident = None }));
test "CLI parity debug build action resolves id uuid ident and repo"
(fun () ->
let action =
expect_ok "debug id build"
(Debug.build (config ~repo:"demo" ()) (Global_opts.create ())
(Debug.Parsed_pull { id = Some 42L; uuid = None; ident = None }))
in
(match action with
| Debug.Debug_pull { repo; graph; lookup; selector } ->
expect_equal "debug repo" "logseq_db_demo"
(Cli_primitive.string_of_repo repo);
expect_equal "debug graph" "demo"
(Cli_primitive.string_of_graph graph);
(match lookup with
| Debug.By_id 42L -> pass
| _ -> fail_test "expected debug id lookup");
expect_equal "debug selector" "[*]"
(Melange_edn_melange.to_edn_string selector));
let ident =
expect_ok "debug ident option"
(Debug.parse_ident_option ":logseq.class/Tag")
in
let ident_action =
expect_ok "debug ident build"
(Debug.build (config ~repo:"demo" ()) (Global_opts.create ())
(Debug.Parsed_pull { id = None; uuid = None; ident = Some ident }))
in
(match ident_action with
| Debug.Debug_pull { lookup = Debug.By_ident parsed_ident; _ } ->
expect_equal "debug ident" "logseq.class/Tag"
(Edn_util.keyword_to_string parsed_ident)
| _ -> fail_test "expected ident lookup");
expect_error_code "debug invalid ident" "invalid-options"
(Debug.parse_ident_option "logseq.class/Tag");
expect_error_code "debug requires repo" "missing-repo"
(Debug.build (config ()) (Global_opts.create ())
(Debug.Parsed_pull { id = Some 1L; uuid = None; ident = None })));
test "CLI parity doctor build and filesystem checks report stable statuses"
(fun () ->
let missing_path = "/tmp/logseq-cli-missing-db-worker-node.js" in
let missing =
Doctor.check_db_worker_script
(Doctor.Doctor { script_path = Some missing_path })
in
expect_equal "doctor missing script id" "db-worker-script"
(Edn_util.keyword_to_string missing.id);
expect_bool "doctor missing status" true (missing.status = Doctor.Error);
expect_equal "doctor missing code" "doctor-script-missing"
(Error.code_to_string (expect_some "missing script code" missing.code));
expect_equal "doctor missing path" missing_path
(expect_some "missing script path" missing.path);
let root = temp_dir "logseq-cli-parity-doctor-" in
let script_path = Node.Path.join [| root; "db-worker-node.js" |] in
try
write_file script_path "console.log('ok');\n";
let readable =
Doctor.check_db_worker_script
(Doctor.Doctor { script_path = Some script_path })
in
expect_bool "doctor readable script" true (readable.status = Doctor.Ok);
expect_none "doctor readable script code" readable.code;
let nested_root = Node.Path.join [| root; "nested-root" |] in
let root_check =
Doctor.check_root_dir (config ~root_dir:nested_root ())
in
expect_equal "doctor root id" "root-dir"
(Edn_util.keyword_to_string root_check.id);
expect_bool "doctor root ok" true (root_check.status = Doctor.Ok);
expect_bool "doctor root created" true
(Cli_unix.file_exists nested_root);
(match
Doctor.build (config ()) (Global_opts.create ())
(Doctor.Parsed_doctor { dev_script = true })
with
| Ok (Doctor.Doctor { script_path = Some path }) ->
expect_named_contains "doctor dev script" path "db-worker-node.js"
| Ok (Doctor.Doctor { script_path = None }) ->
fail_test "doctor dev script: expected script path"
| Error err -> fail_test ("doctor build: " ^ err.Error.message));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity command metadata keeps expected command ids and paths"
(fun () ->
expect_equal "graph list id" "graph-list"
(Command_id.to_string Command_id.Graph_list);
expect_bool "graph create write" true
(Command_id.is_write Command_id.Graph_create);
expect_bool "graph list read" false
(Command_id.is_write Command_id.Graph_list);
expect_bool "show requires graph" true
(Command_id.requires_graph Command_id.Show);
expect_bool "login does not require auth" false
(Command_id.requires_auth Command_id.Login));
test "CLI parity command lookup compares path contents" (fun () ->
let registry = (Cli.make_app_context ()).Cli.registry in
let path = Vec.push_front (Vec.singleton "list") "graph" in
let command =
expect_some "graph list command"
(Command_registry.find_by_path path registry)
in
expect_equal "graph list command id" "graph-list"
(Command_id.to_string command.id));
test "CLI parity example lookup compares selector contents" (fun () ->
let selector = Vec.push_front (Vec.singleton "list") "graph" in
let action =
expect_ok "graph list examples"
(Example.resolve_selector Command_registry.empty selector)
in
expect_equal "graph list selector" "graph list" action.selector;
expect_named_contains "graph list example"
(Vec.peek_front action.examples)
"logseq graph list");
test "CLI parity example selector returns matched commands and examples"
(fun () ->
let registry =
Command_registry.make
(Vec.of_array
[|
{
Command_registry.id = Command_id.Graph_list;
path = Vec.of_array [| "graph"; "list" |];
doc = "List graphs";
long_doc = None;
examples = Vec.singleton "logseq graph list";
options = Vec.empty;
category = Command_registry.Graph_management;
requires_graph = false;
requires_auth = false;
write_command = false;
human_table_headers_order = Vec.empty;
};
{
id = Command_id.Graph_create;
path = Vec.of_array [| "graph"; "create" |];
doc = "Create graph";
long_doc = None;
examples = Vec.singleton "logseq graph create --graph demo";
options = Vec.empty;
category = Graph_management;
requires_graph = true;
requires_auth = false;
write_command = true;
human_table_headers_order = Vec.empty;
};
|])
in
let action =
expect_ok "graph examples"
(Example.resolve_selector registry (Vec.singleton "graph"))
in
expect_equal "selector" "graph" action.selector;
expect_int "matched commands" 2 (Vec.length action.matched_commands);
expect_int "examples" 2 (Vec.length action.examples);
expect_error_code "unknown selector" ":unknown-command"
(Example.resolve_selector registry (Vec.singleton "sync")));
test "CLI parity example selector reports missing and large example counts"
(fun () ->
let registry =
Command_registry.make
(Vec.of_array
[|
{
Command_registry.id = Command_id.Upsert_page;
path = Vec.of_array [| "upsert"; "page" |];
doc = "Upsert page";
long_doc = None;
examples =
Vec.singleton "logseq upsert page --graph demo --page Home";
options = Vec.empty;
category = Graph_inspect_and_edit;
requires_graph = true;
requires_auth = false;
write_command = true;
human_table_headers_order = Vec.empty;
};
{
id = Command_id.Upsert_tag;
path = Vec.of_array [| "upsert"; "tag" |];
doc = "Upsert tag";
long_doc = None;
examples = Vec.empty;
options = Vec.empty;
category = Graph_inspect_and_edit;
requires_graph = true;
requires_auth = false;
write_command = true;
human_table_headers_order = Vec.empty;
};
|])
in
expect_error_code "missing examples" "missing-examples"
(Example.resolve_selector registry (Vec.singleton "upsert"));
let large_registry =
Command_registry.make
(Vec.of_array
[|
{
Command_registry.id = Command_id.Upsert_page;
path = Vec.of_array [| "upsert"; "page" |];
doc = "Upsert page";
long_doc = None;
examples =
Vec.init 1234 (fun index ->
"logseq upsert page --graph demo --page Page-"
^ string_of_int index);
options = Vec.empty;
category = Graph_inspect_and_edit;
requires_graph = true;
requires_auth = false;
write_command = true;
human_table_headers_order = Vec.empty;
};
|])
in
let action =
expect_ok "large example count"
(Example.resolve_selector large_registry
(Vec.of_array [| "upsert"; "page" |]))
in
expect_equal "large example message"
"Found 1,234 examples for selector upsert page" action.message);
test "CLI parity task status normalization resolves available statuses"
(fun () ->
let values =
Vec.of_array
[|
Edn_util.map
[
( Edn_util.keyword "ident",
Edn_util.keyword "logseq.property/status.todo" );
(Edn_util.keyword "value", Edn_util.string "Todo");
];
Edn_util.map
[
( Edn_util.keyword "ident",
Edn_util.keyword "logseq.property/status.done" );
(Edn_util.keyword "value", Edn_util.string "Done");
];
|]
in
let statuses = Task_status.normalize_available_statuses values in
expect_int "status count" 2 (Vec.length statuses);
expect_bool "resolve todo" true
(Option.is_some (Task_status.resolve_status_ident "todo" statuses));
expect_named_contains "invalid status message"
(Task_status.invalid_status_message "bad" statuses)
"Available values");
test
"CLI parity config graph repo conversion strips one leading db prefix only"
(fun () ->
expect_equal "repo to graph" "demo"
(Cli_primitive.string_of_graph
(Cli_config.repo_to_graph
(Cli_primitive.create_repo "logseq_db_demo")));
expect_equal "nested prefix graph" "logseq_db_other"
(Cli_primitive.string_of_graph
(Cli_config.repo_to_graph
(Cli_primitive.create_repo "logseq_db_logseq_db_other"))));
test
"CLI parity parse covers graph backup import export and sync requirements"
(fun () ->
let backup =
expect_parse_ok "backup restore"
[|
"graph";
"backup";
"restore";
"--src";
"demo-nightly";
"--dst";
"demo-restored";
|]
in
(match backup.command with
| Cli_request.Graph (Graph.Parsed_backup_restore opts) ->
expect_equal "backup src" "demo-nightly" opts.src;
expect_equal "backup dst" "demo-restored"
(Cli_primitive.string_of_graph opts.dst)
| _ -> fail_test "expected backup restore");
let export =
expect_parse_ok "graph export"
[|
"graph";
"export";
"--type";
"edn";
"--file";
"/tmp/export.edn";
"--edn-options";
"{:export-type :graph :include-timestamps? true}";
"--pretty-print";
|]
in
(match export.command with
| Cli_request.Graph (Graph.Parsed_export opts) ->
expect_bool "export edn" true (opts.export_type = Graph.Edn);
expect_equal "export file" "/tmp/export.edn"
(expect_some "file" opts.file);
expect_bool "pretty" true opts.pretty_print;
expect_some "edn options" opts.edn_options |> ignore
| _ -> fail_test "expected graph export");
let export_aliases =
expect_parse_ok "graph export aliases"
[|
"graph";
"export";
"-t";
"edn";
"--file";
"/tmp/export.edn";
"-e";
"{:export-type :graph}";
"-p";
|]
in
(match export_aliases.command with
| Cli_request.Graph (Graph.Parsed_export opts) ->
expect_bool "export alias edn" true (opts.export_type = Graph.Edn);
expect_bool "export alias pretty" true opts.pretty_print;
expect_some "export alias edn options" opts.edn_options |> ignore
| _ -> fail_test "expected graph export");
Vec.iter
(fun flag ->
expect_parse_error_code
("retired export option " ^ flag)
":invalid-options"
[|
"graph";
"export";
"--type";
"edn";
"--file";
"/tmp/export.edn";
flag;
|])
(Vec.of_array [| "--include-timestamps"; "--exclude-built-in-pages" |]);
expect_parse_error_code "retired export option --exclude-namespaces"
":invalid-options"
[|
"graph";
"export";
"--type";
"edn";
"--file";
"/tmp/export.edn";
"--exclude-namespaces";
"journal";
|];
expect_parse_error_code "missing backup src" ":invalid-options"
[| "graph"; "backup"; "restore"; "--dst"; "demo" |];
expect_parse_error_code "bad sync config key" ":invalid-options"
[| "sync"; "config"; "get"; "--key"; "graph" |]);
test "CLI parity graph build validates graph selection and export contracts"
(fun () ->
let globals =
Global_opts.create ~graph:(Cli_primitive.create_graph "demo") ()
in
expect_error_code "graph create requires explicit graph" "missing-graph"
(Graph.build (config ()) (Global_opts.create ())
(Graph.Parsed_create { enable_sync = false; e2ee_password = None }));
expect_error_code "graph create password requires sync" "invalid-options"
(Graph.build (config ()) globals
(Graph.Parsed_create
{ enable_sync = false; e2ee_password = Some "pw" }));
Vec.iter
(fun graph_name ->
expect_error_code
("reject graph name " ^ graph_name)
"invalid-options"
(Graph.build (config ())
(Global_opts.create
~graph:(Cli_primitive.create_graph graph_name)
())
(Graph.Parsed_create
{ enable_sync = false; e2ee_password = None })))
(Vec.of_array [| ""; " "; "."; " .. " |]);
let create =
expect_ok "graph create build"
(Graph.build (config ()) globals
(Graph.Parsed_create
{ enable_sync = true; e2ee_password = Some "pw" }))
in
(match create with
| Graph.Graph_create { graph; repo; opts } ->
expect_equal "create graph" "demo" (graph_text graph);
expect_equal "create repo" "logseq_db_demo" (repo_text repo);
expect_bool "create sync" true opts.enable_sync;
expect_equal "create password" "pw"
(expect_some "create password" opts.e2ee_password)
| _ -> fail_test "expected Graph_create action");
expect_error_code "export edn requires file" "invalid-options"
(Graph.build (config ~graph:"demo" ()) (Global_opts.create ())
(Graph.Parsed_export
{
export_type = Graph.Edn;
file = None;
edn_options = None;
pretty_print = false;
include_timestamps = false;
exclude_built_in_pages = false;
exclude_namespaces = Vec.empty;
}));
expect_error_code "sqlite rejects edn options" "invalid-options"
(Graph.build (config ~graph:"demo" ()) (Global_opts.create ())
(Graph.Parsed_export
{
export_type = Graph.Sqlite;
file = Some "/tmp/demo.sqlite";
edn_options = Some (Edn_util.map_vec Vec.empty);
pretty_print = false;
include_timestamps = false;
exclude_built_in_pages = false;
exclude_namespaces = Vec.empty;
}));
expect_error_code "export edn options must be map" "invalid-options"
(Graph.build (config ~graph:"demo" ()) (Global_opts.create ())
(Graph.Parsed_export
{
export_type = Graph.Edn;
file = Some "/tmp/demo.edn";
edn_options = Some (Edn_util.vector_vec Vec.empty);
pretty_print = false;
include_timestamps = false;
exclude_built_in_pages = false;
exclude_namespaces = Vec.empty;
}));
let export =
expect_ok "export edn build"
(Graph.build (config ~graph:"demo" ()) (Global_opts.create ())
(Graph.Parsed_export
{
export_type = Graph.Edn;
file = Some "/tmp/demo.edn";
edn_options =
Some
(edn_of_string
"{:export-type :graph :include-timestamps? true}");
pretty_print = true;
include_timestamps = false;
exclude_built_in_pages = false;
exclude_namespaces = Vec.empty;
}))
in
match export with
| Graph.Graph_export { graph; repo; opts } ->
expect_equal "export graph" "demo" (graph_text graph);
expect_equal "export repo" "logseq_db_demo" (repo_text repo);
expect_bool "export type" true (opts.export_type = Graph.Edn);
expect_equal "export file" "/tmp/demo.edn"
(expect_some "export file" opts.file);
expect_bool "export pretty" true opts.pretty_print;
expect_some "export edn options" opts.edn_options |> ignore
| _ -> fail_test "expected Graph_export action");
test_promise "CLI parity graph validate result reports ok and error states"
(fun () ->
let run_validate response_transit assert_result =
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/validate-db" body then
response_transit
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Graph.Graph_validate
{ graph = Cli_config.repo_to_graph repo; repo; fix = false }
in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Graph.execute action cfg Output.Mode.Human)
in
assert_result result;
Js.Promise.resolve pass)
in
let* () =
run_validate
"[\"^ \",\"~:errors\",[[\"^ \",\"~:entity\",[\"^ \
\",\"~:db/id\",1],\"^0\",[\"^ \",\"~:foo\",[\"bad\"]]]]]"
(fun invalid ->
expect_bool "invalid graph status" true
(Cli_result.is_error invalid);
match invalid.Cli_result.error with
| Some err ->
expect_equal "validation error code" "graph-validation-failed"
(Error.code_to_string err.Error.code);
expect_named_contains "validation error count" err.message
"Found 1 entity with errors:"
| None -> fail_test "expected graph validation error")
in
let* () =
run_validate "[\"^ \",\"~:errors\",null,\"~:datoms\",10]" (fun valid ->
expect_bool "valid graph status" false (Cli_result.is_error valid);
let data =
expect_some "validation data" (Cli_result.data_value valid)
in
expect_bool "validation wraps result" true
(Option.is_some (Edn_util.get data "result")))
in
Js.Promise.resolve pass);
test_promise "CLI parity graph info queries kv rows with thread api q"
(fun () ->
let q_called = ref false in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/q" body then (
q_called := true;
expect_named_contains "graph info repo" body "logseq_db_demo";
expect_named_contains "graph info query namespace" body
"logseq.kv";
"[[\"~:logseq.kv/schema-version\",7],[\"~:logseq.kv/graph-created-at\",40000],[\"~:logseq.kv/db-type\",\"~:sqlite\"]]")
else "null")
in
with_server server (fun base_url ->
let repo = Cli_primitive.create_repo "demo" in
let action =
Graph.Graph_info { graph = Cli_config.repo_to_graph repo; repo }
in
let cfg =
{
(config ~repo:"demo" ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Graph.execute action cfg Output.Mode.Edn)
in
expect_bool "graph info ok" false (Cli_result.is_error result);
expect_bool "q called" true !q_called;
let data =
expect_some "graph info data" (Cli_result.data_value result)
in
expect_equal "graph info graph" "demo"
(expect_some "graph" (Edn_util.get_string data "graph"));
expect_int "schema version" 7
(expect_some "schema"
(Edn_util.get_int data "logseq.kv/schema-version"));
expect_bool "kv map present" true
(Option.is_some (Edn_util.get data "kv"));
Js.Promise.resolve pass));
test "CLI parity graph build normalizes backup and import actions" (fun () ->
let selected = config ~graph:"demo" () in
let backup =
expect_ok "backup create"
(Graph.build selected (Global_opts.create ())
(Graph.Parsed_backup_create { name = Some " nightly " }))
in
(match backup with
| Graph.Graph_backup_create { graph; repo; name; backup_name } ->
expect_equal "backup graph" "demo" (graph_text graph);
expect_equal "backup repo" "logseq_db_demo" (repo_text repo);
expect_equal "backup label" "nightly" (expect_some "name" name);
expect_named_contains "backup generated name"
(expect_some "backup name" backup_name)
"demo-nightly-"
| _ -> fail_test "expected Graph_backup_create action");
let restore =
expect_ok "backup restore"
(Graph.build selected (Global_opts.create ())
(Graph.Parsed_backup_restore
{
src = " nightly ";
dst = Cli_primitive.create_graph " restored ";
}))
in
(match restore with
| Graph.Graph_backup_restore
{ source_repo; source_graph; dst_repo; dst_graph; src; dst } ->
expect_equal "restore source repo" "logseq_db_demo"
(repo_text source_repo);
expect_equal "restore source graph" "demo" (graph_text source_graph);
expect_equal "restore dst repo" "logseq_db_restored"
(repo_text dst_repo);
expect_equal "restore dst graph" "restored" (graph_text dst_graph);
expect_equal "restore src trim" "nightly" src;
expect_equal "restore dst trim" "restored" dst
| _ -> fail_test "expected Graph_backup_restore action");
expect_error_code "backup restore blank dst" "missing-dst"
(Graph.build selected (Global_opts.create ())
(Graph.Parsed_backup_restore
{ src = "nightly"; dst = Cli_primitive.create_graph " " }));
let sqlite_import =
expect_ok "sqlite import"
(Graph.build selected (Global_opts.create ())
(Graph.Parsed_import
{
import_type = Graph.Import_sqlite;
input = "/tmp/demo.sqlite";
}))
in
(match sqlite_import with
| Graph.Graph_import { graph; repo; opts; require_missing_graph } ->
expect_equal "sqlite import graph" "demo" (graph_text graph);
expect_equal "sqlite import repo" "logseq_db_demo" (repo_text repo);
expect_bool "sqlite import type" true
(opts.import_type = Graph.Import_sqlite);
expect_bool "sqlite requires missing" true require_missing_graph
| _ -> fail_test "expected Graph_import sqlite action");
let edn_import =
expect_ok "edn import"
(Graph.build selected (Global_opts.create ())
(Graph.Parsed_import
{ import_type = Graph.Import_edn; input = "/tmp/demo.edn" }))
in
match edn_import with
| Graph.Graph_import { opts; require_missing_graph; _ } ->
expect_bool "edn import type" true
(opts.import_type = Graph.Import_edn);
expect_bool "edn does not require missing" false require_missing_graph
| _ -> fail_test "expected Graph_import edn action");
test_promise
"CLI parity graph backup create invokes worker and writes metadata"
(fun () ->
let root = temp_dir "logseq-cli-backup-create-" in
let invoke_calls = ref Vec.empty in
let server =
invoke_server (fun body ->
invoke_calls := Vec.push_front !invoke_calls body;
if Js.String.includes ~search:"thread-api/backup-db-sqlite" body
then (
let tmp_path = invoke_arg_string body 1 in
mkdir_p (Filename.dirname tmp_path);
write_file tmp_path "sqlite-copy";
"[\"^ \",\"~:ok\",true]")
else "null")
in
with_server server (fun base_url ->
let graph = Cli_primitive.create_graph "demo" in
let repo = Cli_primitive.create_repo "demo" in
let backup_name = "demo-nightly-20260101T000000Z" in
let action =
Graph.Graph_backup_create
{
graph;
repo;
name = Some "nightly";
backup_name = Some backup_name;
}
in
let cfg =
{
(config ~repo:"demo" ~root_dir:root ()) with
Cli_config.base_url = Some base_url;
}
in
let* result =
effect_to_promise
(execute_with_output Graph.execute action cfg Output.Mode.Edn)
in
let backup_dir =
Node.Path.join [| root; "graphs"; "demo"; "backup"; backup_name |]
in
let db_path = Node.Path.join [| backup_dir; "db.sqlite" |] in
let metadata_path = Node.Path.join [| backup_dir; "metadata.edn" |] in
expect_bool "backup create ok" false (Cli_result.is_error result);
expect_int "backup invoke count" 1 (Vec.length !invoke_calls);
expect_equal "backup method repo" "logseq_db_demo"
(invoke_arg_string (Vec.peek_front !invoke_calls) 0);
expect_equal "final sqlite payload" "sqlite-copy" (read_file db_path);
let data =
expect_some "backup create data" (Cli_result.data_value result)
in
expect_equal "backup result name" backup_name
(expect_some "backup-name" (Edn_util.get_string data "backup-name"));
expect_equal "backup result path" db_path
(expect_some "path" (Edn_util.get_string data "path"));
let metadata = edn_of_string (read_file metadata_path) in
expect_equal "metadata source" "cli"
(expect_some "metadata source"
(Edn_util.get_string metadata "source"));
expect_equal "metadata name" backup_name
(expect_some "metadata name" (Edn_util.get_string metadata "name"));
remove_tree root;
Js.Promise.resolve pass));
test_promise
"CLI parity graph backup create cleans reserved target after worker failure"
(fun () ->
let root = temp_dir "logseq-cli-backup-create-cleanup-" in
let backup_name = "demo-failure" in
let backup_dir =
Node.Path.join [| root; "graphs"; "demo"; "backup"; backup_name |]
in
let server =
invoke_server (fun body ->
if Js.String.includes ~search:"thread-api/backup-db-sqlite" body
then failwith "snapshot failed"
else "null")
in
Js.Promise.make (fun ~resolve ~reject ->
server_listen server 0 "127.0.0.1" (fun[@u] () ->
let port = (server_address server)##port in
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
let resolve_after_close () =
server_close server (fun[@u] () -> (resolve pass [@u]))
in
let reject_after_close message =
server_close server (fun[@u] () ->
(reject (Failure message) [@u]))
in
let close_and_fail message =
remove_tree root;
reject_after_close message
in
let action =
Graph.Graph_backup_create
{
graph = Cli_primitive.create_graph "demo";
repo = Cli_primitive.create_repo "demo";
name = None;
backup_name = Some backup_name;
}
in
let cfg =
{
(config ~repo:"demo" ~root_dir:root ()) with
Cli_config.base_url = Some base_url;
}
in
Cli_effect.on_any
(execute_with_output Graph.execute action cfg Output.Mode.Human)
(fun result ->
if not (Cli_result.is_error result) then
close_and_fail "expected backup create error result"
else
let target_exists = Node.Fs.existsSync backup_dir in
if target_exists then
close_and_fail
"reserved backup target removed: expected false, got \
true"
else (
remove_tree root;
resolve_after_close ()))
(fun exn ->
let target_exists = Node.Fs.existsSync backup_dir in
if target_exists then
close_and_fail
"reserved backup target removed: expected false, got true"
else (
remove_tree root;
reject_after_close (Printexc.to_string exn))))));
test_promise
"CLI parity graph backup list restore and remove use scoped backup dirs"
(fun () ->
let root = temp_dir "logseq-cli-backup-flow-" in
let demo_backup =
Node.Path.join
[| root; "graphs"; "demo"; "backup"; "demo-nightly"; "db.sqlite" |]
in
let other_backup =
Node.Path.join
[| root; "graphs"; "other"; "backup"; "other-nightly"; "db.sqlite" |]
in
mkdir_p (Filename.dirname demo_backup);
mkdir_p (Filename.dirname other_backup);
write_file demo_backup "demo";
write_file other_backup "other";
let calls = ref Vec.empty in
let server =
invoke_server (fun body ->
calls := Vec.push_front !calls body;
if Js.String.includes ~search:"thread-api/import-db-binary" body
then "[\"^ \",\"~:ok\",true]"
else "null")
in
with_server server (fun base_url ->
let graph = Cli_primitive.create_graph "demo" in
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ~root_dir:root ()) with
Cli_config.base_url = Some base_url;
}
in
let* list_result =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_backup_list { graph; repo })
cfg Output.Mode.Edn)
in
let backups =
expect_some "backups"
(Option.bind
(Option.bind (Cli_result.data_value list_result) (fun data ->
Edn_util.get data "backups"))
Edn_util.as_seq)
in
expect_int "only current graph backups" 1 (Vec.length backups);
expect_equal "backup name" "demo-nightly"
(expect_some "backup name"
(Edn_util.get_string (Vec.peek_front backups) "name"));
let* missing_restore =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_backup_restore
{
source_repo = repo;
source_graph = graph;
dst_repo = Cli_primitive.create_repo "restored";
dst_graph = Cli_primitive.create_graph "restored";
src = "missing";
dst = "restored";
})
cfg Output.Mode.Human)
in
expect_bool "missing restore is error" true
(Cli_result.is_error missing_restore);
(match missing_restore.Cli_result.error with
| Some err ->
expect_equal "missing restore code" "backup-not-found"
(Error.code_to_string err.Error.code)
| None -> fail_test "expected missing restore error");
let* restore_result =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_backup_restore
{
source_repo = repo;
source_graph = graph;
dst_repo = Cli_primitive.create_repo "restored";
dst_graph = Cli_primitive.create_graph "restored";
src = "demo-nightly";
dst = "restored";
})
cfg Output.Mode.Edn)
in
expect_bool "restore ok" false (Cli_result.is_error restore_result);
expect_bool "restore invokes import" true
(Vec.exists
(fun body ->
Js.String.includes ~search:"thread-api/import-db-binary" body)
!calls);
let* missing_remove =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_backup_remove
{ graph; repo; src = "missing-nightly" })
cfg Output.Mode.Human)
in
expect_bool "missing remove is error" true
(Cli_result.is_error missing_remove);
let* remove_result =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_backup_remove
{ graph; repo; src = "demo-nightly" })
cfg Output.Mode.Human)
in
expect_bool "remove ok" false (Cli_result.is_error remove_result);
expect_bool "backup dir removed" false
(Node.Fs.existsSync (Filename.dirname demo_backup));
remove_tree root;
Js.Promise.resolve pass));
test_promise
"CLI parity graph export and import execute through transport and files"
(fun () ->
let root = temp_dir "logseq-cli-graph-io-" in
let edn_export = Node.Path.join [| root; "export.edn" |] in
let import_edn = Node.Path.join [| root; "import.edn" |] in
let import_sqlite = Node.Path.join [| root; "import.sqlite" |] in
write_file import_edn "{:page \"Import Page\"}";
write_file import_sqlite "sqlite";
let calls = ref Vec.empty in
let server =
invoke_server (fun body ->
calls := Vec.push_front !calls body;
if Js.String.includes ~search:"thread-api/backup-db-sqlite" body
then (
let path = invoke_arg_string body 1 in
mkdir_p (Filename.dirname path);
write_file path "sqlite-copy";
"[\"^ \",\"~:ok\",true]")
else if Js.String.includes ~search:"thread-api/export-edn" body then
"[\"^ \",\"~:exported\",true]"
else if Js.String.includes ~search:"thread-api/import-edn" body then
"[\"^ \",\"~:ok\",true]"
else if
Js.String.includes ~search:"thread-api/import-db-binary" body
then "[\"^ \",\"~:ok\",true]"
else "null")
in
with_server server (fun base_url ->
let graph = Cli_primitive.create_graph "demo" in
let repo = Cli_primitive.create_repo "demo" in
let cfg =
{
(config ~repo:"demo" ~root_dir:root ()) with
Cli_config.base_url = Some base_url;
}
in
let edn_opts =
{
Graph.export_type = Graph.Edn;
file = Some edn_export;
edn_options =
Some
(edn_of_string
"{:export-type :graph :include-timestamps? true}");
pretty_print = true;
include_timestamps = false;
exclude_built_in_pages = false;
exclude_namespaces = Vec.empty;
}
in
let sqlite_opts =
{
Graph.export_type = Graph.Sqlite;
file = None;
edn_options = None;
pretty_print = false;
include_timestamps = false;
exclude_built_in_pages = false;
exclude_namespaces = Vec.empty;
}
in
let* edn_result =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_export { graph; repo; opts = edn_opts })
cfg Output.Mode.Edn)
in
expect_bool "edn export ok" false (Cli_result.is_error edn_result);
expect_named_contains "export file content" (read_file edn_export)
":exported true";
expect_named_contains "pretty export writes multiline edn"
(read_file edn_export) "\n";
let* sqlite_result =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_export { graph; repo; opts = sqlite_opts })
cfg Output.Mode.Human)
in
expect_bool "sqlite export ok" false
(Cli_result.is_error sqlite_result);
let sqlite_path = invoke_arg_string (Vec.peek_front !calls) 1 in
expect_named_contains "default sqlite export path" sqlite_path
(Node.Path.join [| root; "graphs"; "demo"; "export"; "demo_" |]);
expect_bool "default sqlite export suffix" true
(Js.String.endsWith ~suffix:".sqlite" sqlite_path);
let* import_edn_result =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_import
{
graph;
repo;
opts =
{
Graph.import_type = Graph.Import_edn;
input = import_edn;
};
require_missing_graph = false;
})
cfg Output.Mode.Edn)
in
expect_bool "edn import ok" false
(Cli_result.is_error import_edn_result);
let* import_sqlite_result =
effect_to_promise
(execute_with_output Graph.execute
(Graph.Graph_import
{
graph;
repo;
opts =
{
Graph.import_type = Graph.Import_sqlite;
input = import_sqlite;
};
require_missing_graph = true;
})
cfg Output.Mode.Edn)
in
expect_bool "sqlite import ok" false
(Cli_result.is_error import_sqlite_result);
let joined = Vec.string_concat "\n" !calls in
expect_named_contains "export edn invoked" joined
"thread-api/export-edn";
expect_named_contains "sqlite export invoked" joined
"thread-api/backup-db-sqlite";
expect_named_contains "import edn invoked" joined
"thread-api/import-edn";
expect_named_contains "import sqlite invoked" joined
"thread-api/import-db-binary";
remove_tree root;
Js.Promise.resolve pass));
test "CLI parity top-level graph constraints reject existing sqlite imports"
(fun () ->
let root = temp_dir "logseq-cli-graph-constraints-" in
let graph_dir = Node.Path.join [| root; "graphs"; "demo" |] in
let sqlite_path = Node.Path.join [| root; "input.sqlite" |] in
mkdir_p graph_dir;
write_file sqlite_path "sqlite";
let output =
run_cli_lifecycle
[|
"--root-dir";
root;
"--graph";
"demo";
"graph";
"import";
"--type";
"sqlite";
"--input";
sqlite_path;
|]
in
expect_int "existing sqlite import exit" 1 output.exit_code;
expect_named_contains "existing sqlite import code"
(stdout_text "existing sqlite import stdout" output)
"graph-exists";
remove_tree root);
test "CLI parity errors are printed to stdout with non-zero exit" (fun () ->
let result = spawn_cli [| "list"; "page"; "--limit"; "nope" |] in
ignore (expect_exit_non_zero "bad list limit" result);
expect_named_contains "error code" result##stdout "invalid-options";
expect_named_contains "error message" result##stdout "Expected integer");
test "CLI parity main version output includes build metadata" (fun () ->
let output = run_cli_lifecycle [| "--version" |] in
expect_int "version exit" 0 output.exit_code;
let stdout = stdout_text "version stdout" output in
expect_named_contains "version build time" stdout "Build time: ";
expect_named_contains "version revision" stdout "Revision: ";
expect_bool "profile disabled" true (Vec.is_empty output.stderr));
test "CLI parity main uses a later duplicate option with a value" (fun () ->
let output =
run_cli_lifecycle [| "--output"; "--output"; "json"; "--version" |]
in
expect_int "duplicate output version exit" 0 output.exit_code;
let stdout = stdout_text "duplicate output version stdout" output in
expect_valid_json "duplicate output version json" stdout;
expect_named_contains "duplicate output version status" stdout
"\"status\":\"ok\"");
test "CLI parity version action output includes build metadata" (fun () ->
let result =
effect_result "version action"
(execute_with_output Cli_action.execute Cli_action.Version (config ())
Output.Mode.Human)
in
let output = Format_types.format_result result (config ()) in
expect_named_contains "version action build time" output "Build time: ";
expect_named_contains "version action revision" output "Revision: ");
test "CLI parity main structured help respects output modes" (fun () ->
let json_output = run_cli_lifecycle [| "--output"; "json"; "--help" |] in
expect_int "json help exit" 0 json_output.exit_code;
let json_stdout = stdout_text "json help stdout" json_output in
expect_valid_json "json help output" json_stdout;
expect_named_contains "json help status" json_stdout "\"status\":\"ok\"";
expect_named_contains "json help message" json_stdout "Usage: logseq";
let edn_output = run_cli_lifecycle [| "--output"; "edn"; "--help" |] in
expect_int "edn help exit" 0 edn_output.exit_code;
let edn_stdout = stdout_text "edn help stdout" edn_output in
expect_valid_edn "edn help output" edn_stdout;
expect_named_contains "edn help status" edn_stdout ":status :ok";
expect_named_contains "edn help message" edn_stdout "Usage: logseq");
test "CLI parity main structured parse errors respect output modes" (fun () ->
let json_output = run_cli_lifecycle [| "--output"; "json"; "wat" |] in
expect_int "json parse exit" 1 json_output.exit_code;
let json_stdout = stdout_text "json parse stdout" json_output in
expect_valid_json "json parse error output" json_stdout;
expect_named_contains "json parse error status" json_stdout
"\"status\":\"error\"";
expect_named_contains "json parse error message" json_stdout "\"message\"";
let edn_output = run_cli_lifecycle [| "--output"; "edn"; "wat" |] in
expect_int "edn parse exit" 1 edn_output.exit_code;
let edn_stdout = stdout_text "edn parse stdout" edn_output in
expect_valid_edn "edn parse error output" edn_stdout;
expect_named_contains "edn parse error status" edn_stdout ":status :error";
expect_named_contains "edn parse error message" edn_stdout ":message");
test "CLI parity main profile lines are emitted for version" (fun () ->
let output = run_cli_lifecycle [| "--profile"; "--version" |] in
expect_int "profile version exit" 0 output.exit_code;
let stdout = stdout_text "profile version stdout" output in
expect_named_contains "profile version build time" stdout "Build time: ";
let profile = Vec.string_concat "\n" output.stderr in
expect_named_contains "profile version headline" profile
"command=version status=ok";
expect_named_contains "profile version stages" profile "stages";
expect_named_contains "profile version parse stage" profile
"cli.parse-args";
expect_named_contains "profile version total stage" profile "cli.total");
test "CLI parity main profile lines are disabled by default" (fun () ->
let output = run_cli_lifecycle [| "--help" |] in
expect_int "help exit" 0 output.exit_code;
expect_bool "no profile lines" true (Vec.is_empty output.stderr));
test "CLI parity command help includes primary and secondary command groups"
(fun () ->
let output = run_cli [| "--help" |] in
expect_named_contains "graph command" output "graph list";
expect_named_contains "upsert command" output "upsert block";
expect_named_contains "sync command" output "sync upload";
expect_named_contains "skill command" output "skill show";
expect_named_not_contains "retired qmd" output "qmd");
test "CLI parity group help surfaces subcommands without option suffix"
(fun () ->
let list_help = run_cli [| "list" |] in
expect_named_contains "list usage" list_help
"Usage: logseq list <subcommand> [options]";
expect_named_contains "list page command" list_help "list page";
expect_named_contains "list asset command" list_help "list asset";
expect_named_not_contains "list command options suffix" list_help
"list page [options]";
let backup_help = run_cli [| "graph"; "backup" |] in
expect_named_contains "backup usage" backup_help
"Usage: logseq graph backup <subcommand> [options]";
expect_named_contains "backup list command" backup_help
"graph backup list";
expect_named_contains "backup restore command" backup_help
"graph backup restore";
let query_help = run_cli [| "query"; "-h" |] in
expect_named_contains "query option" query_help "--query";
expect_named_contains "query name" query_help "--name";
expect_named_contains "query inputs" query_help "--inputs");
test "CLI parity command help includes registry examples" (fun () ->
let remove_help = run_cli [| "remove"; "block"; "--help" |] in
expect_named_contains "remove block usage" remove_help
"Usage: logseq remove block";
expect_named_contains "remove block options" remove_help
"Command options:";
expect_named_contains "remove block examples" remove_help "Examples:";
let upsert_help = run_cli [| "upsert"; "block"; "--help" |] in
expect_named_contains "upsert block usage" upsert_help
"Usage: logseq upsert block";
expect_named_contains "upsert block examples" upsert_help "Examples:";
let example_help = run_cli [| "example"; "upsert"; "--help" |] in
expect_named_contains "example upsert usage" example_help
"Usage: logseq example upsert";
expect_named_not_contains "example upsert group has no examples"
example_help "Examples:");
test
"CLI parity structured example output keeps selector matched commands and \
message" (fun () ->
let result = spawn_cli [| "--output"; "json"; "example"; "upsert" |] in
ignore (expect_exit_zero "example upsert json" result);
expect_named_contains "selector" result##stdout "\"selector\":\"upsert\"";
expect_named_contains "matched command" result##stdout "upsert block";
expect_named_contains "message" result##stdout "Found");
test "CLI parity show validation rejects ambiguous targets" (fun () ->
expect_error_code "show id uuid" "invalid-options"
(Show.validate_parsed
(Show.Parsed_show
{
id_raw = Some "1";
uuid = Some "00000000-0000-4000-8000-000000000001";
page = None;
page_hierarchy = false;
linked_references = None;
ref_id_footer = None;
level = None;
stdin_id = None;
}));
let result =
spawn_cli [| "--graph"; "demo"; "show"; "--id"; "1"; "--page"; "Home" |]
in
ignore (expect_exit_non_zero "show ambiguous cli" result);
expect_named_contains "show ambiguous message" result##stdout
"only one of --id, --uuid, or --page is allowed");
test "CLI parity completion command parses shell argument and validation"
(fun () ->
let positional =
expect_parse_ok "completion positional" [| "completion"; "zsh" |]
in
(match positional.command with
| Cli_request.Completion
(Completion.Parsed_completion { shell = Some Cli_primitive.Zsh }) ->
pass
| _ -> fail_test "expected zsh completion request");
let option =
expect_parse_ok "completion option"
[| "completion"; "--shell"; "bash" |]
in
(match option.command with
| Cli_request.Completion
(Completion.Parsed_completion { shell = Some Cli_primitive.Bash }) ->
pass
| _ -> fail_test "expected bash completion request");
expect_parse_error_code "completion missing shell" ":invalid-options"
[| "completion" |];
expect_parse_error_code "completion unsupported shell" ":invalid-options"
[| "completion"; "fish" |]);
test "CLI parity path-specific short aliases resolve to command specific meanings"
(fun () ->
let validate =
expect_parse_ok "graph validate -f" [| "graph"; "validate"; "-f" |]
in
(match validate.command with
| Cli_request.Graph (Graph.Parsed_validate { fix }) ->
expect_bool "validate -f is fix" true fix
| _ -> fail_test "expected graph validate");
let export =
expect_parse_ok "graph export -f"
[| "graph"; "export"; "-t"; "edn"; "-f"; "/tmp/export.edn" |]
in
(match export.command with
| Cli_request.Graph (Graph.Parsed_export opts) ->
expect_equal "export -f is file" "/tmp/export.edn"
(expect_some "export file" opts.file)
| _ -> fail_test "expected graph export");
let export_eq =
expect_parse_ok "graph export -f= form"
[| "graph"; "export"; "-t"; "edn"; "-f=/tmp/export.edn" |]
in
(match export_eq.command with
| Cli_request.Graph (Graph.Parsed_export opts) ->
expect_equal "export -f= is file" "/tmp/export.edn"
(expect_some "export file eq" opts.file)
| _ -> fail_test "expected graph export");
let page = expect_parse_ok "list page -e" [| "list"; "page"; "-e" |] in
(match page.command with
| Cli_request.List (List_command.Parsed_page opts) ->
expect_bool "list page -e is expand" true opts.expand
| _ -> fail_test "expected list page");
expect_parse_error_code "graph validate rejects --fields"
":invalid-options"
[| "graph"; "validate"; "--fields"; "id" |]);
test "CLI parity completion generation includes top nested and value cases"
(fun () ->
let registry = (Cli.make_app_context ()).Cli.registry in
let zsh = Completion.generate Cli_primitive.Zsh registry in
expect_named_contains "zsh header" zsh "#compdef logseq";
expect_named_contains "zsh graph group" zsh "_logseq_graph()";
expect_named_contains "zsh nested backup" zsh "_logseq_graph_backup()";
expect_named_contains "zsh graph completions" zsh "_logseq_graphs";
expect_named_contains "zsh graph compadd" zsh "compadd -a graphs";
expect_named_contains "zsh graph short alias" zsh
"'(-g --graph)'{-g=,--graph=}'[Graph name]:graph:{_logseq_graphs}'";
expect_named_contains "zsh output choices" zsh
"'(-o --output)'{-o=,--output=}'[Output format]:mode:(human json edn)'";
expect_named_contains "zsh nested leaf function" zsh "_logseq_graph_list()";
expect_named_contains "zsh show id option" zsh "'--id=[Entity id]::id:'";
expect_named_contains "zsh show level option" zsh
"'--level=[Tree depth]:n:'";
expect_named_contains "zsh query group dispatcher keeps own options" zsh
"'--query=[Datascript query]:edn:'";
expect_named_contains "zsh comma separated fields completion" zsh
"'--fields=[Comma-separated fields to include]:fields:{_values -s , \
fields id title ident uuid created-at updated-at}'";
expect_named_contains "zsh export file short alias" zsh
"'(-f --file)'{-f=,--file=}'[Output file]:path:_files'";
expect_named_contains "zsh list expand short alias" zsh
"'(-e --expand)'{-e,--expand}'[Expand page data]'";
let bash = Completion.generate Cli_primitive.Bash registry in
expect_named_contains "bash header" bash
"# Auto-generated by `logseq completion bash`";
expect_named_contains "bash graph command" bash " graph ";
expect_named_contains "bash upsert command" bash " upsert";
expect_named_contains "bash search command" bash " search ";
expect_named_contains "bash nested backup case" bash "graph:backup";
expect_named_contains "bash graph names" bash "_logseq_json_names_bash");
test "CLI parity completion generation keeps current dynamic helper surface"
(fun () ->
let registry = (Cli.make_app_context ()).Cli.registry in
let zsh = Completion.generate Cli_primitive.Zsh registry in
expect_named_contains "zsh graph helper" zsh "_logseq_graphs";
expect_named_not_contains "zsh page helper removed" zsh "_logseq_pages";
expect_named_not_contains "zsh query helper removed" zsh "_logseq_queries";
expect_named_not_contains "zsh current graph helper removed" zsh
"_logseq_current_graph";
let bash = Completion.generate Cli_primitive.Bash registry in
expect_named_not_contains "bash page helper removed" bash
"_logseq_pages_bash";
expect_named_not_contains "bash query helper removed" bash
"_logseq_queries_bash";
expect_named_contains "bash json name helper" bash
"_logseq_json_names_bash";
expect_named_not_contains "bash current graph helper removed" bash
"_logseq_current_graph_bash");
test "CLI parity zsh completion keeps current global option contracts"
(fun () ->
let registry = (Cli.make_app_context ()).Cli.registry in
let zsh = Completion.generate Cli_primitive.Zsh registry in
expect_named_contains "zsh top dispatcher" zsh "_logseq()";
expect_named_contains "zsh graph helper" zsh "_logseq_graphs";
expect_named_contains "zsh global output option" zsh
"'(-o --output)'{-o=,--output=}'[Output format]:mode:(human json edn)'";
expect_named_contains "zsh graph export edn options" zsh "--edn-options";
expect_named_contains "zsh graph create sync option" zsh "--enable-sync";
expect_named_contains "zsh search content option" zsh "--content";
expect_named_not_contains "zsh content short alias removed" zsh "-c[";
expect_named_not_contains "zsh command boolean negation removed" zsh
"--no-include-built-in";
expect_named_not_contains "zsh global boolean negation" zsh "--no-verbose";
expect_named_not_contains "zsh multi value helper removed" zsh
"_logseq_multi_values");
test "CLI parity bash completion keeps current global option contracts"
(fun () ->
let registry = (Cli.make_app_context ()).Cli.registry in
let bash = Completion.generate Cli_primitive.Bash registry in
expect_named_contains "bash global opts helper" bash "_logseq_opts_for";
expect_named_contains "bash global options" bash
"--help -h --version --config --graph -g --root-dir --timeout-ms \
--output -o --verbose -v --profile";
expect_named_not_contains "bash graph export edn options removed" bash
"--edn-options";
expect_named_not_contains "bash search content option removed" bash
"--content -c";
expect_named_not_contains "bash command boolean negation removed" bash
"--no-include-built-in";
expect_named_not_contains "bash global boolean negation" bash
"--no-verbose";
expect_named_not_contains "bash multi value helper removed" bash
"_logseq_multi_values_bash";
expect_named_not_contains "bash enum whitespace helper removed" bash
"_logseq_enum_values_bash");
test "CLI parity command registry fills catalog options for known paths"
(fun () ->
let registry = (Cli.make_app_context ()).Cli.registry in
let has_name name (option : Command_registry.option_meta) =
Vec.mem name option.names
in
let option_by_name name options = Vec.find_opt (has_name name) options in
let assert_timestamp_headers_last
(command : Command_registry.command_meta) =
let headers = command.human_table_headers_order in
if Vec.mem "created-at" headers || Vec.mem "updated-at" headers then
let reversed = Vec.rev headers in
if
Vec.length reversed >= 2
&& Vec.nth reversed 0 = "updated-at"
&& Vec.nth reversed 1 = "created-at"
then pass
else
fail_test
(Printf.sprintf
"%s: expected created-at,updated-at suffix, got %s"
(Vec.string_concat " " command.path)
(Vec.string_concat "," headers))
in
Vec.iter assert_timestamp_headers_last registry.commands;
let output_option =
expect_some "global output option"
(option_by_name "--output" Command_registry.global_options)
in
expect_bool "global output has -o alias" true
(has_name "-o" output_option);
expect_equal "global output default" "human"
(expect_some "output default" output_option.default);
expect_string_vec "global output choices"
[| "human"; "json"; "edn" |]
output_option.choices;
let graph_option =
expect_some "global graph option"
(option_by_name "--graph" Command_registry.global_options)
in
expect_bool "global graph has -g alias" true (has_name "-g" graph_option);
let profile_option =
expect_some "global profile option"
(option_by_name "--profile" Command_registry.global_options)
in
expect_bool "profile is flag" true
(profile_option.arity = Command_registry.Flag);
let completion =
expect_some "completion command"
(Command_registry.find_by_path (Vec.singleton "completion") registry)
in
expect_bool "completion shell choices" true
(Vec.exists
(fun (option : Command_registry.option_meta) ->
Vec.to_array option.names = [| "--shell" |]
&& Vec.to_array option.choices = [| "zsh"; "bash" |])
completion.Command_registry.options);
let graph_create =
expect_some "graph create"
(Command_registry.find_by_path
(Vec.of_array [| "graph"; "create" |])
registry)
in
expect_bool "graph create enable sync" true
(Option.is_some (option_by_name "--enable-sync" graph_create.options));
expect_bool "graph create password" true
(Option.is_some (option_by_name "--e2ee-password" graph_create.options));
let graph_export =
expect_some "graph export"
(Command_registry.find_by_path
(Vec.of_array [| "graph"; "export" |])
registry)
in
let export_type =
expect_some "export type option"
(option_by_name "--type" graph_export.options)
in
expect_string_vec "export type choices" [| "edn"; "sqlite" |]
export_type.choices;
expect_bool "export edn options" true
(Option.is_some (option_by_name "--edn-options" graph_export.options));
expect_bool "export edn options alias" true
(Option.is_some (option_by_name "-e" graph_export.options));
expect_bool "export pretty print" true
(Option.is_some (option_by_name "--pretty-print" graph_export.options));
expect_bool "export pretty print alias" true
(Option.is_some (option_by_name "-p" graph_export.options));
expect_bool "export timestamps retired" true
(Option.is_none
(option_by_name "--include-timestamps" graph_export.options));
expect_bool "export built-ins retired" true
(Option.is_none
(option_by_name "--exclude-built-in-pages" graph_export.options));
expect_bool "export namespaces retired" true
(Option.is_none
(option_by_name "--exclude-namespaces" graph_export.options));
let graph_import =
expect_some "graph import"
(Command_registry.find_by_path
(Vec.of_array [| "graph"; "import" |])
registry)
in
let import_input =
expect_some "import input option"
(option_by_name "--input" graph_import.options)
in
expect_bool "import input required" true import_input.required;
let list_page =
expect_some "list page"
(Command_registry.find_by_path
(Vec.of_array [| "list"; "page" |])
registry)
in
let page_sort =
expect_some "page sort option"
(option_by_name "--sort" list_page.options)
in
expect_bool "page sort has title" true (Vec.mem "title" page_sort.choices);
let page_order =
expect_some "page order option"
(option_by_name "--order" list_page.options)
in
expect_string_vec "page order choices" [| "asc"; "desc" |]
page_order.choices;
let list_tag =
expect_some "list tag"
(Command_registry.find_by_path
(Vec.of_array [| "list"; "tag" |])
registry)
in
let tag_fields =
expect_some "tag fields option"
(option_by_name "--fields" list_tag.options)
in
expect_bool "tag fields include title" true
(Vec.mem "title" tag_fields.choices);
expect_bool "tag fields include uuid" true
(Vec.mem "uuid" tag_fields.choices);
let list_task =
expect_some "list task"
(Command_registry.find_by_path
(Vec.of_array [| "list"; "task" |])
registry)
in
expect_equal "list task human header order"
"id,title,status,priority,scheduled,deadline,created-at,updated-at"
(Vec.string_concat "," list_task.human_table_headers_order);
let show =
expect_some "show command"
(Command_registry.find_by_path (Vec.singleton "show") registry)
in
expect_bool "show page option" true
(Option.is_some (option_by_name "--page" show.options));
let upsert_block =
expect_some "upsert block"
(Command_registry.find_by_path
(Vec.of_array [| "upsert"; "block" |])
registry)
in
let block_pos =
expect_some "block pos option"
(option_by_name "--pos" upsert_block.options)
in
expect_string_vec "block pos choices"
[| "first-child"; "last-child"; "sibling" |]
block_pos.choices;
let task =
expect_some "upsert task"
(Command_registry.find_by_path
(Vec.of_array [| "upsert"; "task" |])
registry)
in
expect_bool "task status option" true
(Vec.exists
(fun (option : Command_registry.option_meta) ->
Vec.to_array option.names = [| "--status" |])
task.options);
expect_bool "task clear status option" true
(Vec.exists
(fun (option : Command_registry.option_meta) ->
Vec.to_array option.names = [| "--no-status" |])
task.options);
let property =
expect_some "upsert property"
(Command_registry.find_by_path
(Vec.of_array [| "upsert"; "property" |])
registry)
in
expect_bool "property type choices" true
(Vec.exists
(fun (option : Command_registry.option_meta) ->
Vec.to_array option.names = [| "--type" |]
&& Vec.mem "default" option.choices
&& Vec.mem "checkbox" option.choices)
property.options);
let query =
expect_some "query command"
(Command_registry.find_by_path (Vec.singleton "query") registry)
in
let query_list =
expect_some "query list command"
(Command_registry.find_by_path
(Vec.of_array [| "query"; "list" |])
registry)
in
expect_bool "query examples metadata" true
(not (Vec.is_empty query.examples));
expect_bool "query name option" true
(Option.is_some (option_by_name "--name" query.options));
expect_bool "query list examples metadata" true
(not (Vec.is_empty query_list.examples));
let search_block =
expect_some "search block"
(Command_registry.find_by_path
(Vec.of_array [| "search"; "block" |])
registry)
in
let search_content =
expect_some "search content option"
(option_by_name "--content" search_block.options)
in
expect_equal "search content doc" "Content search text" search_content.doc;
expect_equal "search block human header order" "id,ident,title"
(Vec.string_concat "," search_block.human_table_headers_order));
test "CLI parity command registry help renders command and option details"
(fun () ->
let registry = (Cli.make_app_context ()).Cli.registry in
let top_help = Command_registry.render_help registry in
expect_named_contains "top commands heading" top_help
"Available commands:";
expect_named_contains "skill show command" top_help "skill show";
expect_named_contains "completion command" top_help "completion";
let upsert_help =
Command_registry.render_help
~group:(Vec.of_array [| "upsert"; "task" |])
registry
in
expect_named_contains "task usage" upsert_help
"Usage: logseq upsert task [options]";
expect_named_contains "task status option" upsert_help "--status <status>";
expect_named_contains "task no status option" upsert_help "--no-status");
test "CLI parity skill install target resolves local global and missing home"
(fun () ->
let local =
expect_ok "local skill target"
(Skill.resolve_install_target ~global:false ~cwd:"/tmp/work"
~home_dir:(Some "/Users/demo"))
in
expect_equal "local scope" "local" (keyword_text local.scope);
expect_equal "local path" "/tmp/work/.agents/skills/logseq-cli/SKILL.md"
local.path;
expect_equal "local update command" "logseq skill install"
local.update_command;
let global =
expect_ok "global skill target"
(Skill.resolve_install_target ~global:true ~cwd:"/tmp/work"
~home_dir:(Some "/Users/demo"))
in
expect_equal "global scope" "global" (keyword_text global.scope);
expect_equal "global path"
"/Users/demo/.agents/skills/logseq-cli/SKILL.md" global.path;
expect_error_code "missing home" ":skill-home-dir-unavailable"
(Skill.resolve_install_target ~global:true ~cwd:"/tmp/work"
~home_dir:None));
test "CLI parity installed skill targets omit unavailable global target"
(fun () ->
let targets =
Skill.installed_skill_targets ~cwd:"/tmp/work"
~home_dir:(Some "/Users/demo")
in
expect_int "two targets" 2 (Vec.length targets);
expect_equal "local target" "/tmp/work/.agents/skills/logseq-cli/SKILL.md"
(Vec.nth targets 0).Skill.path;
expect_equal "global target"
"/Users/demo/.agents/skills/logseq-cli/SKILL.md"
(Vec.nth targets 1).path;
let local_only =
Skill.installed_skill_targets ~cwd:"/tmp/work" ~home_dir:None
in
expect_int "local only" 1 (Vec.length local_only);
expect_equal "local only scope" "local"
(keyword_text (Vec.peek_front local_only).scope));
test "CLI parity skill show and install read source and preserve neighbors"
(fun () ->
let root = temp_dir "logseq-cli-parity-skill-" in
let source = Node.Path.join [| root; "source"; "SKILL.md" |] in
let destination_dir =
Node.Path.join [| root; "work"; ".agents"; "skills"; "logseq-cli" |]
in
let destination_file = Node.Path.join [| destination_dir; "SKILL.md" |] in
let neighbor =
Node.Path.join
[| root; "work"; ".agents"; "skills"; "other"; "SKILL.md" |]
in
try
mkdir_p (Node.Path.dirname source);
mkdir_p (Node.Path.dirname neighbor);
write_file source "# skill\nfrom source";
write_file neighbor "keep me";
let show =
effect_result "skill show"
(execute_with_output Skill.execute
(Skill.Skill_show { source_path = Some source })
(config ~root_dir:root ()) Output.Mode.Human)
in
expect_equal "show output" "# skill\nfrom source"
(Format_types.format_result show (config ~root_dir:root ()));
let install =
effect_result "skill install"
(execute_with_output Skill.execute
(Skill.Skill_install
{
global = false;
source_path = Some source;
destination_dir = Some destination_dir;
destination_file = Some destination_file;
})
(config ~root_dir:root ()) Output.Mode.Json)
in
let json_config =
config ~root_dir:root
~output_format:(Output.Mode.Packed Output.Mode.Json) ()
in
let output = Format_types.format_result install json_config in
expect_named_contains "installed path" output destination_file;
expect_equal "installed content" "# skill\nfrom source"
(read_file destination_file);
expect_equal "neighbor preserved" "keep me" (read_file neighbor);
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity example selector uses registry examples before defaults"
(fun () ->
let registry =
Command_registry.make
(Vec.of_array
[|
{
Command_registry.id = Command_id.Graph_list;
path = Vec.of_array [| "graph"; "list" |];
doc = "List graphs";
long_doc = None;
examples = Vec.singleton "custom graph list example";
options = Vec.empty;
category = Command_registry.Graph_management;
requires_graph = false;
requires_auth = false;
write_command = false;
human_table_headers_order = Vec.empty;
};
|])
in
let action =
expect_ok "registry graph examples"
(Example.resolve_selector registry (Vec.singleton "graph"))
in
expect_equal "registry example" "custom graph list example"
(Vec.peek_front action.examples);
let defaults =
expect_ok "default examples"
(Example.resolve_selector Command_registry.empty
(Vec.of_array [| "graph"; "list" |]))
in
expect_equal "default selector" "graph list" defaults.selector;
expect_named_contains "default graph list example"
(Vec.peek_front defaults.examples)
"logseq graph list");
test "CLI parity config update writes sanitized patch and removes null fields"
(fun () ->
let root = temp_dir "logseq-cli-parity-config-update-" in
let cfg_path = Node.Path.join [| root; "cli.edn" |] in
try
write_file cfg_path
"{:graph \"old\" :ws-url \"wss://old.example/%s\" :auth-token \
\"secret\" :retries 2 :e2ee-password \"password\"}\n";
let resolved =
resolve_config (Global_opts.create ~config_path:cfg_path ())
in
ignore
(expect_ok "update config"
(effect_result "update config"
(Cli_config.update_config resolved
(Edn_util.map
[
(Edn_util.keyword "graph", Edn_util.string "new");
(Edn_util.keyword "ws-url", Edn_util.nil);
]))));
let parsed = edn_of_string (read_file cfg_path) in
expect_equal "updated graph" "new"
(expect_some "graph" (Edn_util.get_string parsed "graph"));
expect_none "removed ws-url" (Edn_util.get parsed "ws-url");
expect_none "sanitized auth-token" (Edn_util.get parsed "auth-token");
expect_none "sanitized retries" (Edn_util.get parsed "retries");
expect_none "sanitized e2ee-password"
(Edn_util.get parsed "e2ee-password");
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity agent name resolves config hostname and invalid blanks"
(fun () ->
let with_raw raw =
{ (config ()) with Cli_config.raw_file_config = Some raw }
in
expect_equal "configured agent" "bridge-a"
(expect_ok "configured agent"
(Agent.resolve_agent_name
(with_raw
(Edn_util.map
[
( Edn_util.keyword "agent-name",
Edn_util.string " bridge-a " );
]))
(Some "fallback-host")));
expect_equal "hostname fallback" "fallback-host"
(expect_ok "hostname fallback"
(Agent.resolve_agent_name (config ()) (Some "fallback-host")));
expect_error_code "blank configured agent" ":agent-name-invalid"
(Agent.resolve_agent_name
(with_raw
(Edn_util.map
[ (Edn_util.keyword "agent-name", Edn_util.string " ") ]))
(Some "fallback-host"));
let platform_hostname =
expect_ok "missing hostname falls back to platform hostname"
(Agent.resolve_agent_name (config ()) (Some ""))
in
expect_bool "platform hostname non-empty" true
(String.trim platform_hostname <> ""));
test "CLI parity agent task routing returns explicit non-routable reasons"
(fun () ->
(match
Agent.routable_task_decision (agent_task_entity ())
~agent_name:"build-host"
with
| Agent.Routable -> pass
| Not_routable reason ->
fail_test ("expected routable, got " ^ routable_reason_text reason));
let expect_reason name expected entity =
match Agent.routable_task_decision entity ~agent_name:"build-host" with
| Agent.Not_routable reason ->
expect_equal name expected (routable_reason_text reason)
| Routable -> fail_test (name ^ ": expected non-routable")
in
expect_reason "missing uuid" "missing-stable-uuid"
(agent_task_entity ~uuid:None ());
expect_reason "missing task tag" "missing-task-tag"
(agent_task_entity ~tags:[||] ());
expect_reason "done status" "not-todo"
(agent_task_entity ~status:(Some "done") ());
expect_reason "assignee mismatch" "assignee-mismatch"
(agent_task_entity ~assignees:[| "other-host" |] ());
expect_reason "already routed" "already-routed"
(agent_task_entity ~session_id:"codex-1" ()));
test "CLI parity agent prompt template validates required and unknown vars"
(fun () ->
let template body =
{
Agent.kind = Agent.Task;
body;
required_vars = Vec.of_array [| "graph"; "task-block-tree" |];
allowed_vars =
Vec.of_array [| "graph"; "agent-name"; "task-block-tree" |];
}
in
ignore
(expect_ok "valid template"
(Agent.validate_prompt_template
(template "{{graph}}\n{{task-block-tree}}\n{{agent-name}}")));
expect_error_code "blank template" ":missing-template-code-block"
(Agent.validate_prompt_template (template " "));
expect_error_code "unknown var" ":unknown-template-vars"
(Agent.validate_prompt_template
(template "{{graph}}\n{{task-block-tree}}\n{{other}}"));
expect_error_code "missing var" ":missing-template-vars"
(Agent.validate_prompt_template (template "{{graph}}"));
let documented_template =
template "{{graph}}\n{{task-block-tree}}\n'{{documented-only}}'"
in
ignore
(expect_ok "quoted docs var ignored"
(Agent.validate_prompt_template documented_template)));
test "CLI parity parse covers additional command option surfaces" (fun () ->
let list_node =
expect_parse_ok "list node"
[|
"list";
"node";
"--tags";
"project,work";
"--properties";
"owner,status";
"--sort";
"updated-at";
|]
in
(match list_node.command with
| Cli_request.List (List_command.Parsed_node opts) ->
expect_int "node tags" 2 (Vec.length opts.tags);
expect_int "node properties" 2 (Vec.length opts.properties);
expect_equal "node sort" "updated-at"
(expect_some "node sort" opts.common.sort)
| _ -> fail_test "expected list node");
let query_run =
expect_parse_ok "query run"
[|
"query";
"--query";
"[:find ?e :in $ ?title :where [?e :block/title ?title]]";
"--inputs";
"[\"Hello\"]";
"--graph";
"demo";
"--output";
"json";
|]
in
(match query_run.command with
| Cli_request.Query
(Query.Parsed_run
{ query_edn = Some query; inputs_edn = Some inputs; name = None })
-> (
expect_equal "query parse text"
"[:find ?e :in $ ?title :where [?e :block/title ?title]]" query;
expect_equal "query parse inputs" "[\"Hello\"]" inputs;
match query_run.globals.output_format with
| Some (Output.Mode.Packed Output.Mode.Json) -> pass
| _ -> fail_test "expected json output mode")
| _ -> fail_test "expected query run");
let query_list = expect_parse_ok "query list" [| "query"; "list" |] in
(match query_list.command with
| Cli_request.Query Query.Parsed_list -> pass
| _ -> fail_test "expected query list");
let task =
expect_parse_ok "upsert task"
[|
"upsert";
"task";
"--page";
"Inbox";
"--content";
"Ship release";
"--status";
"todo";
"--priority";
"high";
"--no-deadline";
|]
in
(match task.command with
| Cli_request.Upsert (Upsert.Parsed_task opts) ->
expect_equal "task page" "Inbox" (expect_some "page" opts.page);
expect_equal "task content" "Ship release"
(expect_some "content" opts.content);
expect_equal "task status" "todo" (expect_some "status" opts.status);
expect_bool "task no deadline" true opts.no_deadline
| _ -> fail_test "expected upsert task");
let property =
expect_parse_ok "upsert property"
[|
"upsert";
"property";
"--name";
"status";
"--type";
"default";
"--cardinality";
"many";
"--hide";
"false";
"--public";
"true";
|]
in
(match property.command with
| Cli_request.Upsert (Upsert.Parsed_property opts) ->
expect_equal "property name" "status" (expect_some "name" opts.name);
expect_bool "property kind" true (opts.kind = Some Property.Default);
expect_bool "property cardinality" true
(opts.cardinality = Some Property.Many);
expect_bool "hide false" false (expect_some "hide" opts.hide);
expect_bool "public true" true (expect_some "public" opts.public)
| _ -> fail_test "expected upsert property");
let tag =
expect_parse_ok "upsert tag schema properties"
[|
"upsert";
"tag";
"--name";
"project";
"--add-properties";
"[\"status\"]";
"--remove-properties";
"[:user.property/owner]";
|]
in
(match tag.command with
| Cli_request.Upsert (Upsert.Parsed_tag opts) ->
expect_equal "tag name" "project" (expect_some "name" opts.name);
expect_equal "add properties" "[\"status\"]"
(expect_some "add-properties" opts.add_properties_edn);
expect_equal "remove properties" "[:user.property/owner]"
(expect_some "remove-properties" opts.remove_properties_edn)
| _ -> fail_test "expected upsert tag");
let sync_download =
expect_parse_ok "sync download"
[| "sync"; "download"; "--progress"; "--e2ee-password"; "secret" |]
in
(match sync_download.command with
| Cli_request.Sync (Sync.Parsed_download opts) ->
expect_bool "progress" true (expect_some "progress" opts.progress);
expect_equal "download password" "secret"
(expect_some "password" opts.e2ee_password)
| _ -> fail_test "expected sync download");
let sync_asset =
expect_parse_ok "sync asset"
[|
"sync";
"asset";
"download";
"--uuid";
"00000000-0000-4000-8000-000000000001";
|]
in
(match sync_asset.command with
| Cli_request.Sync (Sync.Parsed_asset_download opts) ->
expect_equal "asset uuid" "00000000-0000-4000-8000-000000000001"
(expect_some "asset uuid" opts.uuid)
| _ -> fail_test "expected sync asset download");
let grant =
expect_parse_ok "sync grant"
[|
"sync";
"grant-access";
"--graph-id";
"00000000-0000-4000-8000-000000000001";
"--email";
"user@example.com";
|]
in
(match grant.command with
| Cli_request.Sync (Sync.Parsed_grant_access opts) ->
expect_equal "grant graph id" "00000000-0000-4000-8000-000000000001"
(expect_some "graph id" opts.graph_id);
expect_equal "grant email" "user@example.com"
(expect_some "email" opts.email)
| _ -> fail_test "expected sync grant access");
let graph_create =
expect_parse_ok "graph create sync"
[| "graph"; "create"; "--enable-sync"; "--e2ee-password"; "secret" |]
in
(match graph_create.command with
| Cli_request.Graph (Graph.Parsed_create opts) ->
expect_bool "enable sync" true opts.enable_sync;
expect_equal "graph password" "secret"
(expect_some "graph password" opts.e2ee_password)
| _ -> fail_test "expected graph create");
expect_parse_error_code "unknown agent subcommand" ":unknown-command"
[| "agent"; "bridge"; "list" |];
expect_parse_error_code "unknown option" ":invalid-options"
[| "server"; "cleanup"; "--graph"; "demo"; "--unknown"; "x" |]);
test "CLI parity transport reads and writes edn sqlite and db files"
(fun () ->
let root = temp_dir "logseq-cli-parity-transport-io-" in
let edn_path = Node.Path.join [| root; "data.edn" |] in
let edn_utf8_path = Node.Path.join [| root; "utf8.edn" |] in
let edn_bytes_path = Node.Path.join [| root; "bytes.edn" |] in
let sqlite_path = Node.Path.join [| root; "graph.sqlite" |] in
let db_path = Node.Path.join [| root; "graph.db" |] in
let title = unicode_text [| 0x4e2d; 0x6587; 0x6807; 0x9898 |] in
let imported_title = unicode_text [| 0x5bfc; 0x5165; 0x4e2d; 0x6587 |] in
try
ignore
(expect_ok "write edn"
(effect_result "write edn"
(Transport.write_output ~format:(Edn_util.keyword_t "edn")
~path:edn_path
~data:
(Edn_util.map
[
(Edn_util.keyword "title", Edn_util.string "中文标题");
(Edn_util.keyword "count", Edn_util.int 2);
]))));
expect_named_contains "edn file preserves utf8" (read_file edn_path)
title;
let edn_value =
expect_ok "read edn"
(effect_result "read edn"
(Transport.read_input ~format:(Edn_util.keyword_t "edn")
~path:edn_path))
in
expect_equal "edn title" title
(expect_some "title" (Edn_util.get_string edn_value "title"));
write_file edn_utf8_path ("{:title \"" ^ imported_title ^ "\"}");
let edn_utf8_value =
expect_ok "read utf8 edn"
(effect_result "read utf8 edn"
(Transport.read_input ~format:(Edn_util.keyword_t "edn")
~path:edn_utf8_path))
in
expect_equal "utf8 edn title" imported_title
(expect_some "utf8 title"
(Edn_util.get_string edn_utf8_value "title"));
ignore
(expect_ok "write bytes edn"
(effect_result "write bytes edn"
(Transport.write_output ~format:(Edn_util.keyword_t "edn")
~path:edn_bytes_path
~data:
(Edn_util.map
[
( Edn_util.keyword "payload",
Edn_util.bytes (Bytes.of_string "payload") );
]))));
expect_named_contains "edn bytes use transit tag"
(read_file edn_bytes_path) "#transit/bytes";
ignore
(expect_ok "write sqlite"
(effect_result "write sqlite"
(Transport.write_output
~format:(Edn_util.keyword_t "sqlite")
~path:sqlite_path
~data:(Edn_util.string "sqlite-bytes"))));
let sqlite_value =
expect_ok "read sqlite"
(effect_result "read sqlite"
(Transport.read_input
~format:(Edn_util.keyword_t "sqlite")
~path:sqlite_path))
in
expect_equal "sqlite bytes" "sqlite-bytes"
(Bytes.to_string
(expect_some "sqlite bytes" (Edn_util.as_bytes sqlite_value)));
expect_named_contains "read sqlite uses transit bytes tag"
(Melange_edn_melange.to_edn_string sqlite_value)
"#transit/bytes";
ignore
(expect_ok "write db"
(effect_result "write db"
(Transport.write_output ~format:(Edn_util.keyword_t "db")
~path:db_path
~data:(Edn_util.bytes (Bytes.of_string "db")))));
let db_value =
expect_ok "read db"
(effect_result "read db"
(Transport.read_input ~format:(Edn_util.keyword_t "db")
~path:db_path))
in
expect_equal "db bytes" "db"
(Bytes.to_string
(expect_some "db bytes" (Edn_util.as_bytes db_value)));
expect_named_contains "read db uses transit bytes tag"
(Melange_edn_melange.to_edn_string db_value)
"#transit/bytes";
expect_error_code "bad write format" ":unsupported-output-format"
(effect_result "bad write format"
(Transport.write_output
~format:(Edn_util.keyword_t "json")
~path:(Node.Path.join [| root; "data.json" |])
~data:(Edn_util.string "nope")));
expect_error_code "bad read format" ":unsupported-input-format"
(effect_result "bad read format"
(Transport.read_input
~format:(Edn_util.keyword_t "json")
~path:edn_path));
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test_promise
"CLI parity transport thread-api invoke sends transit args and decodes \
resultTransit" (fun () ->
let request_count = ref 0 in
let large_int = 1_773_666_723_828L in
let server =
invoke_server (fun body ->
incr request_count;
expect_named_contains "invoke method" body "thread-api/q";
expect_named_contains "invoke repo" body "logseq_db_alpha";
expect_named_contains "invoke args transit field" body "argsTransit";
let args =
expect_some "invoke args" (Edn_util.as_seq (invoke_args body))
in
let query =
expect_some "invoke query" (Edn_util.as_seq (Vec.nth args 1))
in
expect_int64 "invoke large int" large_int
(expect_some "invoke large int value"
(Edn_util.as_int64 (Vec.nth query 5)));
(match Vec.nth query 6 with
| Melange_edn_melange.Any (Melange_edn_melange.Bigint value) ->
expect_equal "invoke bigint" "900719925474099312345" value
| _ -> fail_test "invoke bigint: expected Bigint");
(match Vec.nth query 7 with
| Melange_edn_melange.Any (Melange_edn_melange.Decimal value) ->
expect_equal "invoke decimal" "1234567890.123456789" value
| _ -> fail_test "invoke decimal: expected Decimal");
"\"ok\"")
in
with_server server (fun base_url ->
let invoke_config =
{
Transport.base_url;
timeout_span = Time.span_of_ms 1_000L;
profile_session = None;
}
in
let* result =
effect_to_promise
(Transport.thread_api_q invoke_config
~repo:(Cli_primitive.create_repo "logseq_db_alpha")
~query:
(Edn_util.vector_t
[
Edn_util.keyword "find";
Edn_util.symbol "?title";
Edn_util.keyword "where";
Edn_util.vector
[
Edn_util.symbol "?b";
Edn_util.keyword "block/title";
Edn_util.symbol "?title";
];
Edn_util.keyword "created-at";
Edn_util.int64 large_int;
Edn_util.any
(Melange_edn_melange.bigint "900719925474099312345");
Edn_util.any
(Melange_edn_melange.decimal "1234567890.123456789");
]))
in
expect_equal "decoded invoke result" "ok"
(expect_some "invoke result string" (Edn_util.as_string result));
expect_int "one invoke request" 1 !request_count;
Js.Promise.resolve pass));
test_promise
"CLI parity transport keeps prefixed symbol-looking strings as strings"
(fun () ->
let server =
invoke_server (fun body ->
let args =
expect_some "invoke args" (Edn_util.as_seq (invoke_args body))
in
let query =
expect_some "invoke query" (Edn_util.as_seq (Vec.nth args 1))
in
expect_equal "string stays string" "~$?title"
(expect_some "string value"
(Edn_util.as_string (Vec.nth query 1)));
"\"ok\"")
in
with_server server (fun base_url ->
let invoke_config =
{
Transport.base_url;
timeout_span = Time.span_of_ms 1_000L;
profile_session = None;
}
in
let* result =
effect_to_promise
(Transport.thread_api_q invoke_config
~repo:(Cli_primitive.create_repo "logseq_db_alpha")
~query:
(Edn_util.vector_t
[ Edn_util.keyword "find"; Edn_util.string "~$?title" ]))
in
expect_equal "decoded invoke result" "ok"
(expect_some "invoke result string" (Edn_util.as_string result));
Js.Promise.resolve pass));
test_promise
"CLI parity transport thread-api invoke decodes cached keyword vectors"
(fun () ->
let server =
invoke_server (fun body ->
expect_named_contains "invoke method" body "thread-api/q";
"[\"~:block/uuid\",[\"^0\",\"x\"]]")
in
with_server server (fun base_url ->
let invoke_config =
{
Transport.base_url;
timeout_span = Time.span_of_ms 1_000L;
profile_session = None;
}
in
let* result =
effect_to_promise
(Transport.thread_api_q invoke_config
~repo:(Cli_primitive.create_repo "logseq_db_alpha")
~query:
(Edn_util.vector_t_vec
(Vec.singleton (Edn_util.keyword "find"))))
in
match Edn_util.as_seq result with
| Some values when Vec.length values = 2 -> (
let key = Vec.nth values 0 in
let nested = Vec.nth values 1 in
expect_equal "decoded key" "block/uuid"
(expect_some "keyword" (Edn_util.as_keyword key));
match Edn_util.as_seq nested with
| Some nested_values when Vec.length nested_values = 2 ->
let nested_key = Vec.nth nested_values 0 in
let value = Vec.nth nested_values 1 in
expect_equal "decoded nested key" "block/uuid"
(expect_some "nested keyword"
(Edn_util.as_keyword nested_key));
expect_equal "decoded nested value" "x"
(expect_some "nested value" (Edn_util.as_string value));
Js.Promise.resolve pass
| _ -> fail_promise "expected nested vector")
| _ -> fail_promise "expected decoded vector"));
test_promise
"CLI parity transport connect-events decodes rtc-log and closes \
subscription" (fun () ->
let received = ref Vec.empty in
let closed = ref false in
let server =
create_server (fun[@u] req res ->
if req_method req = "GET" && req_url req = "/v1/events" then (
res_write_head res 200
(Js.Dict.fromArray
[|
("Content-Type", "text/event-stream");
("Cache-Control", "no-cache");
("Connection", "keep-alive");
|]);
res_write res
"data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \
\\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u00000000-0000-4000-8000-000000000001\\\",\\\"~:message\\\",\\\"downloaded \
1 block\\\"]]\"}\n\n";
req_on_end req "close" (fun[@u] () -> closed := true))
else write_json res 404 (error_response "not found"))
in
with_server server (fun base_url ->
let invoke_config =
{
Transport.base_url;
timeout_span = Time.span_of_ms 1_000L;
profile_session = None;
}
in
let* subscription =
effect_to_promise
(Transport.connect_events invoke_config (fun event_type payload ->
received :=
Vec.push_front !received
(event_type, Melange_edn_melange.to_edn_string payload);
Cli_effect.pure ()))
in
let* () = sleep_ms 50 in
let* () = effect_to_promise (subscription.Transport.close ()) in
let* () = sleep_ms 50 in
let event_type, payload =
match Vec.peek_back_opt !received with
| Some event -> event
| None ->
fail_test "received event: expected Some value";
assert false
in
expect_equal "event type" "rtc-log"
(Edn_util.keyword_to_string event_type);
expect_named_contains "event payload" payload "downloaded 1 block";
expect_bool "subscription closed" true !closed;
Js.Promise.resolve pass));
test "CLI parity server lock paths use canonical graph directory names"
(fun () ->
expect_equal "plain lock path"
"/tmp/logseq-root/graphs/demo/db-worker.lock"
(Server_runtime.lock_path ~root_dir:"/tmp/logseq-root"
(Cli_primitive.create_repo "logseq_db_demo"));
expect_equal "encoded lock path"
"/tmp/logseq-root/graphs/foo~2Fbar/db-worker.lock"
(Server_runtime.lock_path ~root_dir:"/tmp/logseq-root"
(Cli_primitive.create_repo "logseq_db_foo/bar")));
test_promise
"CLI parity server start launches db-worker-node with parent executable"
(fun () ->
let root = temp_dir "logseq-cli-server-parent-exec-" in
let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in
let graphs_dir = Node.Path.join [| root; "graphs" |] in
let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in
let lock_path = Node.Path.join [| graph_dir; "db-worker.lock" |] in
let server_list_path = Node.Path.join [| root; "server-list" |] in
let server =
create_server (fun[@u] req res ->
if req_method req = "GET" && req_url req = "/healthz" then
write_json res 200
(Printf.sprintf
"{\"repo\":\"logseq_db_demo\",\"status\":\"ready\",\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}"
(Cli_unix.getpid ()) root)
else write_json res 404 (error_response "not found"))
in
try
mkdir_p graph_dir;
write_file worker_script "console.log('db-worker');\n";
start_spawn_capture ();
set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script;
Js.Promise.make (fun ~resolve ~reject ->
server_listen server 0 "127.0.0.1" (fun[@u] () ->
let port = (server_address server)##port in
set_spawn_capture_on_spawn ~lock_path ~server_list_path ~port;
let config = config ~root_dir:root ~repo:"demo" () in
let finish_ok () =
server_close server (fun[@u] () -> (resolve pass [@u]))
in
let finish_error message =
server_close server (fun[@u] () ->
(reject (Failure message) [@u]))
in
Cli_effect.on_any
(Server_runtime.start_server config
(Cli_primitive.create_repo "logseq_db_demo")
~create_empty_db:false)
(fun result ->
let calls = spawn_capture_calls () in
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
match result with
| Error err ->
finish_error
("server start failed: " ^ err.Error.message)
| Ok _ -> (
let expected_command = Node.Process.argv.(0) in
try
expect_named_contains "spawn command" calls
("\"command\":\"" ^ expected_command ^ "\"");
expect_named_contains "spawn worker arg" calls
worker_script;
finish_ok ()
with exn -> finish_error (Printexc.to_string exn)))
(fun exn ->
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
finish_error (Printexc.to_string exn))))
with exn ->
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
server_close server (fun[@u] () -> ());
fail_promise (Printexc.to_string exn));
test_promise "CLI parity server start reuses starting db-worker-node"
(fun () ->
let root = temp_dir "logseq-cli-server-starting-existing-" in
let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in
let graphs_dir = Node.Path.join [| root; "graphs" |] in
let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in
let lock_path = Node.Path.join [| graph_dir; "db-worker.lock" |] in
let server_list_path = Node.Path.join [| root; "server-list" |] in
let health_calls = ref 0 in
let server =
create_server (fun[@u] req res ->
if req_method req = "GET" && req_url req = "/healthz" then (
incr health_calls;
let status_code, status =
if !health_calls = 1 then (503, "starting") else (200, "ready")
in
write_json res status_code
(Printf.sprintf
"{\"repo\":\"logseq_db_demo\",\"status\":%S,\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}"
status (Cli_unix.getpid ()) root))
else write_json res 404 (error_response "not found"))
in
try
mkdir_p graph_dir;
write_file worker_script "console.log('db-worker');\n";
write_file lock_path
(Printf.sprintf
"{\"repo\":\"logseq_db_demo\",\"pid\":%d,\"lock-id\":\"test\",\"owner-source\":\"cli\"}"
(Cli_unix.getpid ()));
start_spawn_capture ();
set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script;
Js.Promise.make (fun ~resolve ~reject ->
server_listen server 0 "127.0.0.1" (fun[@u] () ->
let port = (server_address server)##port in
write_file server_list_path
(string_of_int (Cli_unix.getpid ())
^ " " ^ string_of_int port ^ "\n");
let config = config ~root_dir:root ~repo:"demo" () in
let finish_ok () =
server_close server (fun[@u] () -> (resolve pass [@u]))
in
let finish_error message =
server_close server (fun[@u] () ->
(reject (Failure message) [@u]))
in
Cli_effect.on_any
(Server_runtime.start_server config
(Cli_primitive.create_repo "logseq_db_demo")
~create_empty_db:false)
(fun result ->
let calls = spawn_capture_calls () in
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
match result with
| Error err ->
finish_error
("server start failed: " ^ err.Error.message)
| Ok _ -> (
try
expect_equal "spawn calls" "[]" calls;
expect_bool "health calls include ready retry" true
(!health_calls >= 2);
finish_ok ()
with exn -> finish_error (Printexc.to_string exn)))
(fun exn ->
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
finish_error (Printexc.to_string exn))))
with exn ->
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
server_close server (fun[@u] () -> ());
fail_promise (Printexc.to_string exn));
test_promise "CLI parity ensure server retries final health discovery"
(fun () ->
let root = temp_dir "logseq-cli-ensure-final-health-" in
let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in
let graphs_dir = Node.Path.join [| root; "graphs" |] in
let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in
let lock_path = Node.Path.join [| graph_dir; "db-worker.lock" |] in
let server_list_path = Node.Path.join [| root; "server-list" |] in
let health_calls = ref 0 in
let server =
create_server (fun[@u] req res ->
if req_method req = "GET" && req_url req = "/healthz" then (
incr health_calls;
match !health_calls with
| 1 ->
write_json res 503
(Printf.sprintf
"{\"repo\":\"logseq_db_demo\",\"status\":\"starting\",\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}"
(Cli_unix.getpid ()) root)
| 3 -> write_json res 500 (error_response "busy")
| _ ->
write_json res 200
(Printf.sprintf
"{\"repo\":\"logseq_db_demo\",\"status\":\"ready\",\"host\":\"127.0.0.1\",\"pid\":%d,\"owner-source\":\"cli\",\"root-dir\":%S,\"revision\":\"test-revision\"}"
(Cli_unix.getpid ()) root))
else write_json res 404 (error_response "not found"))
in
try
mkdir_p graph_dir;
write_file worker_script "console.log('db-worker');\n";
start_spawn_capture ();
set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script;
Js.Promise.make (fun ~resolve ~reject ->
server_listen server 0 "127.0.0.1" (fun[@u] () ->
let port = (server_address server)##port in
set_spawn_capture_on_spawn ~lock_path ~server_list_path ~port;
let config = config ~root_dir:root ~repo:"demo" () in
let finish_ok () =
server_close server (fun[@u] () -> (resolve pass [@u]))
in
let finish_error message =
server_close server (fun[@u] () ->
(reject (Failure message) [@u]))
in
Cli_effect.on_any
(Server_runtime.ensure_server config
(Cli_primitive.create_repo "logseq_db_demo")
~create_empty_db:false)
(fun result ->
let calls = spawn_capture_calls () in
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
match result with
| Error err ->
finish_error
("ensure server failed: " ^ err.Error.message)
| Ok _ -> (
try
expect_named_contains "spawned worker" calls
worker_script;
expect_bool "final health retried" true
(!health_calls >= 4);
finish_ok ()
with exn -> finish_error (Printexc.to_string exn)))
(fun exn ->
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
finish_error (Printexc.to_string exn))))
with exn ->
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
server_close server (fun[@u] () -> ());
fail_promise (Printexc.to_string exn));
test_promise
"CLI parity server start orphan timeout reports db-worker-node command"
(fun () ->
let root = temp_dir "logseq-cli-server-orphan-command-" in
let worker_script = Node.Path.join [| root; "db-worker-node.js" |] in
let graphs_dir = Node.Path.join [| root; "graphs" |] in
let graph_dir = Node.Path.join [| graphs_dir; "demo" |] in
try
mkdir_p graph_dir;
write_file worker_script "console.log('db-worker');\n";
start_spawn_capture ();
set_env "LOGSEQ_DB_WORKER_NODE_SCRIPT" worker_script;
let config = config ~root_dir:root ~repo:"demo" () in
effect_to_promise
(Server_runtime.start_server config
(Cli_primitive.create_repo "logseq_db_demo")
~create_empty_db:false)
|> Js.Promise.then_ (fun result ->
let calls = spawn_capture_calls () in
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
match result with
| Ok _ -> fail_promise "expected orphan timeout"
| Error err ->
expect_equal "orphan error code" "server-start-timeout-orphan"
(Error.code_to_string err.Error.code);
expect_named_contains "orphan command executable"
err.Error.message Node.Process.argv.(0);
expect_named_contains "orphan command worker" err.Error.message
worker_script;
expect_named_contains "orphan command repo" err.Error.message
"--repo logseq_db_demo";
expect_named_contains "orphan command root" err.Error.message
("--root-dir " ^ root);
expect_named_contains "orphan command owner" err.Error.message
"--owner-source cli";
expect_named_contains "spawn was attempted" calls worker_script;
Js.Promise.resolve pass)
with exn ->
stop_spawn_capture ();
unset_env "LOGSEQ_DB_WORKER_NODE_SCRIPT";
remove_tree root;
fail_promise (Printexc.to_string exn));
test "CLI parity server command build keeps cleanup repo-free contract"
(fun () ->
(match
Server_command.build (config ()) (Global_opts.create ())
Server_command.Parsed_list
with
| Ok Server_command.Server_list -> pass
| Ok _ -> fail_test "server list: expected list action"
| Error err -> fail_test ("server list: " ^ err.Error.message));
(match
Server_command.build (config ()) (Global_opts.create ())
Server_command.Parsed_cleanup
with
| Ok Server_command.Server_cleanup -> pass
| Ok _ -> fail_test "server cleanup: expected cleanup action"
| Error err -> fail_test ("server cleanup: " ^ err.Error.message));
expect_error_code "server start requires repo" "missing-repo"
(Server_command.build (config ()) (Global_opts.create ())
Server_command.Parsed_start);
expect_error_code "server stop requires repo" "missing-repo"
(Server_command.build (config ()) (Global_opts.create ())
Server_command.Parsed_stop);
expect_error_code "server restart requires repo" "missing-repo"
(Server_command.build (config ()) (Global_opts.create ())
Server_command.Parsed_restart);
(match
Server_command.build (config ~repo:"demo" ()) (Global_opts.create ())
Server_command.Parsed_start
with
| Ok (Server_command.Server_start { repo; graph }) ->
expect_equal "server start repo" "logseq_db_demo"
(Cli_primitive.string_of_repo repo);
expect_equal "server start graph" "demo"
(Cli_primitive.string_of_graph graph)
| Ok _ -> fail_test "server start: expected start action"
| Error err -> fail_test ("server start: " ^ err.Error.message));
(match
Server_command.build (config ~repo:"demo" ()) (Global_opts.create ())
Server_command.Parsed_stop
with
| Ok (Server_command.Server_stop { repo; graph }) ->
expect_equal "server stop repo" "logseq_db_demo"
(Cli_primitive.string_of_repo repo);
expect_equal "server stop graph" "demo"
(Cli_primitive.string_of_graph graph)
| Ok _ -> fail_test "server stop: expected stop action"
| Error err -> fail_test ("server stop: " ^ err.Error.message));
(match
Server_command.build (config ~repo:"demo" ()) (Global_opts.create ())
Server_command.Parsed_restart
with
| Ok (Server_command.Server_restart { repo; graph }) ->
expect_equal "server restart repo" "logseq_db_demo"
(Cli_primitive.string_of_repo repo);
expect_equal "server restart graph" "demo"
(Cli_primitive.string_of_graph graph)
| Ok _ -> fail_test "server restart: expected restart action"
| Error err -> fail_test ("server restart: " ^ err.Error.message));
pass);
test
"CLI parity server graph item listing ignores non-graphs and marks old dir \
conflicts" (fun () ->
let root = temp_dir "logseq-cli-parity-graph-items-" in
let graphs = Node.Path.join [| root; "graphs" |] in
try
Vec.iter
(fun dir -> mkdir_p (Node.Path.join [| graphs; dir |]))
(Vec.of_array
[|
"alpha";
"backup";
"foo~2G";
"Unlinked graphs";
"logseq_local_1";
"old++name";
"old~2Fname";
"yy y";
"yy~20y";
"yy%20y";
"bad%ZZname";
|]);
let items =
Server_runtime.list_graph_items (config ~root_dir:root ())
in
let canonical =
Vec.filter
(fun item -> item.Graph_types.kind = Graph_types.Canonical)
items
in
expect_bool "has alpha canonical" true
(Vec.exists
(fun item ->
Option.equal String.equal
(Option.map Cli_primitive.string_of_graph
item.Graph_types.graph_name)
(Some "alpha")
&& item.Graph_types.graph_dir = Some "alpha")
canonical);
expect_bool "ignores backup" false
(Vec.exists
(fun item -> item.Graph_types.graph_dir = Some "backup")
items);
let old_item =
expect_some "old format item"
(Vec.find_opt
(fun item -> item.Graph_types.legacy_dir = Some "old++name")
items)
in
expect_bool "old item kind" true
(old_item.Graph_types.kind = Graph_types.Legacy);
expect_equal "old graph name" "old/name"
(Cli_primitive.string_of_graph
(expect_some "old graph name" old_item.Graph_types.graph_name));
expect_equal "old target dir" "old~2Fname"
(expect_some "target dir" old_item.Graph_types.target_graph_dir);
expect_bool "old conflict" true old_item.Graph_types.conflict;
let percent_encoded_old_dirs =
items
|> Vec.filter (fun item ->
match item.Graph_types.legacy_dir with
| Some ("yy~20y" | "yy%20y") -> true
| _ -> false)
in
expect_int "percent encoded old dir count" 1
(Vec.length percent_encoded_old_dirs);
Vec.iter
(fun item ->
expect_bool "percent encoded item kind" true
(item.Graph_types.kind = Graph_types.Legacy);
expect_equal "percent encoded graph name" "yy y"
(Cli_primitive.string_of_graph
(expect_some "percent encoded graph name"
item.Graph_types.graph_name));
expect_equal "percent encoded target dir" "yy y"
(expect_some "percent encoded target"
item.Graph_types.target_graph_dir);
expect_bool "percent encoded conflict" true
item.Graph_types.conflict)
percent_encoded_old_dirs;
let undecodable =
expect_some "undecodable item"
(Vec.find_opt
(fun item -> item.Graph_types.legacy_dir = Some "bad%ZZname")
items)
in
expect_bool "undecodable kind" true
(undecodable.Graph_types.kind = Graph_types.Legacy_undecodable);
remove_tree root
with exn ->
remove_tree root;
fail_test (Printexc.to_string exn));
test "CLI parity format renders basic success outputs by selected mode"
(fun () ->
let message mode = Cli_result.ok mode (Cli_result.Message "ok") in
expect_equal "human message" "ok"
(Format_types.format_result (message Output.Mode.Human) (config ()));
expect_equal "json message"
"{\"status\":\"ok\",\"data\":{\"message\":\"ok\"}}"
(Format_types.format_result (message Output.Mode.Json)
(config ~output_format:(Output.Mode.Packed Output.Mode.Json) ()));
expect_equal "edn message" "{:status :ok :data {:message \"ok\"}}"
(Format_types.format_result (message Output.Mode.Edn)
(config ~output_format:(Output.Mode.Packed Output.Mode.Edn) ())));
test "CLI parity default human tables keep discovered header order" (fun () ->
let value =
Edn_util.map
[
( Edn_util.keyword "items",
Edn_util.vector
[
Edn_util.map
[
( Edn_util.keyword "block/updated-at",
Edn_util.int64 90000L );
(Edn_util.keyword "block/title", Edn_util.string "Alpha");
( Edn_util.keyword "block/created-at",
Edn_util.int64 40000L );
];
] );
]
in
let output =
Format_types.format_result
(Cli_result.ok Output.Mode.Human (Cli_result.Raw value))
(config ())
in
expect_equal "default table header order" "updated-at,title,created-at"
(headers_from output |> Vec.of_array |> Vec.string_concat ","));
test "CLI parity format graph list marks current graph and old graph dirs"
(fun () ->
let graph_list_data =
Edn_util.map
[
( Edn_util.keyword "graphs",
Edn_util.vector
[
Edn_util.string "alpha";
Edn_util.string "old/name";
Edn_util.string "mystery";
] );
( Edn_util.keyword "graph-items",
Edn_util.vector
[
Edn_util.map
[
(Edn_util.keyword "kind", Edn_util.keyword "canonical");
(Edn_util.keyword "graph-name", Edn_util.string "alpha");
(Edn_util.keyword "graph-dir", Edn_util.string "alpha");
];
Edn_util.map
[
(Edn_util.keyword "kind", Edn_util.keyword "legacy");
( Edn_util.keyword "legacy-dir",
Edn_util.string "old++name" );
( Edn_util.keyword "legacy-graph-name",
Edn_util.string "old/name" );
( Edn_util.keyword "target-graph-dir",
Edn_util.string "old~2Fname" );
(Edn_util.keyword "conflict?", Edn_util.bool false);
];
Edn_util.map
[
( Edn_util.keyword "kind",
Edn_util.keyword "legacy-undecodable" );
(Edn_util.keyword "legacy-dir", Edn_util.string "mystery");
(Edn_util.keyword "reason", Edn_util.keyword "undecodable");
];
] );
]
in
let output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Graph_list Output.Mode.Human
(Cli_result.Raw graph_list_data))
(config ~graph:"old/name" ~root_dir:"/tmp/logseq-root" ())
in
expect_named_contains "current graph marker" output "* old/name";
expect_named_not_contains "old graph marker" output "old/name [legacy]";
expect_named_not_contains "old graph warning" output
"legacy graph directories detected";
expect_named_not_contains "rename guidance" output "old++name");
test "CLI parity format list and search table outputs keep count footer"
(fun () ->
let list_value =
Edn_util.map
[
( Edn_util.keyword "items",
Edn_util.vector
[
Edn_util.map
[
(Edn_util.keyword "db/id", Edn_util.int64 1L);
(Edn_util.keyword "block/title", Edn_util.string "Alpha");
( Edn_util.keyword "block/updated-at",
Edn_util.int64 90000L );
( Edn_util.keyword "block/created-at",
Edn_util.int64 40000L );
];
] );
]
in
let list_output =
Format_types.format_result
~human_table_headers_order:
(Vec.of_array
[| "id"; "ident"; "title"; "uuid"; "created-at"; "updated-at" |])
(Cli_result.ok ~command:Command_id.List_page Output.Mode.Human
(Cli_result.Raw list_value))
(config ())
in
expect_named_contains "list id header" list_output "id";
expect_named_contains "list title" list_output "Alpha";
expect_named_contains "list count" list_output "Count: 1";
let empty_order_output =
Format_types.format_result ~human_table_headers_order:Vec.empty
(Cli_result.ok ~command:Command_id.List_page Output.Mode.Human
(Cli_result.Raw list_value))
(config ())
in
expect_equal "empty header order does not infer columns" "Count: 1"
(String.trim empty_order_output);
let search_value =
Edn_util.map
[
( Edn_util.keyword "items",
Edn_util.vector
[
Edn_util.map
[
(Edn_util.keyword "db/id", Edn_util.int64 3L);
( Edn_util.keyword "block/title",
Edn_util.string "Alpha block" );
];
Edn_util.map
[
(Edn_util.keyword "db/id", Edn_util.int64 7L);
( Edn_util.keyword "block/title",
Edn_util.string "Second line\nIndented" );
];
] );
]
in
let search_output =
Format_types.format_result
~human_table_headers_order:(Vec.of_array [| "id"; "ident"; "title" |])
(Cli_result.ok ~command:Command_id.Search_block Output.Mode.Human
(Cli_result.Raw search_value))
(config ())
in
expect_named_contains "search title" search_output "Alpha block";
expect_named_contains "search multiline" search_output "Indented";
expect_named_contains "search count" search_output "Count: 2");
test "CLI parity format list variants use current generic table contracts"
(fun () ->
let row fields = Edn_util.map_vec (Vec.of_array fields) in
let items values =
Edn_util.map_vec
(Vec.singleton
( Edn_util.keyword "items",
Edn_util.vector_vec (Vec.of_array values) ))
in
let human command value =
Format_types.format_result
~human_table_headers_order:
(let registry = (Cli.make_app_context ()).Cli.registry in
let meta =
expect_some "command metadata"
(Command_registry.find_by_path
(Command_id.to_path command)
registry)
in
meta.human_table_headers_order)
(Cli_result.ok ~command Output.Mode.Human (Cli_result.Raw value))
(config ())
in
let tag_output =
human Command_id.List_tag
(items
[|
row
[|
(Edn_util.keyword "block/title", Edn_util.string "Tag");
(Edn_util.keyword "db/id", Edn_util.int64 42L);
( Edn_util.keyword "db/ident",
Edn_util.keyword "logseq.class/Tag" );
|];
|])
in
expect_named_contains "tag id header" tag_output "id";
expect_named_contains "tag title header" tag_output "title";
expect_named_contains "tag ident value" tag_output "logseq.class/Tag";
let property_output =
human Command_id.List_property
(items
[|
row
[|
(Edn_util.keyword "block/title", Edn_util.string "Prop");
(Edn_util.keyword "db/id", Edn_util.int64 99L);
( Edn_util.keyword "logseq.property/type",
Edn_util.keyword "node" );
( Edn_util.keyword "db/cardinality",
Edn_util.keyword "db.cardinality/many" );
|];
row
[|
(Edn_util.keyword "block/title", Edn_util.string "Untyped");
(Edn_util.keyword "db/id", Edn_util.int64 100L);
|];
|])
in
expect_named_contains "property type header" property_output "type";
expect_named_contains "property cardinality header" property_output
"cardinality";
expect_named_contains "property cardinality value" property_output
"db.cardinality/many";
let task_output =
human Command_id.List_task
(items
[|
row
[|
(Edn_util.keyword "db/id", Edn_util.int64 12L);
(Edn_util.keyword "block/title", Edn_util.string "Alpha task");
( Edn_util.keyword "logseq.property/status",
Edn_util.keyword "logseq.property/status.todo" );
( Edn_util.keyword "logseq.property/priority",
Edn_util.keyword "logseq.property/priority.high" );
|];
|])
in
expect_named_contains "task status column" task_output "status";
expect_named_contains "task priority column" task_output "priority";
let unordered_task_output =
Format_types.format_result
~human_table_headers_order:
(Vec.of_array
[|
"id";
"title";
"status";
"priority";
"scheduled";
"deadline";
"updated-at";
"created-at";
|])
(Cli_result.ok ~command:Command_id.List_task Output.Mode.Human
(Cli_result.Raw
(items
[|
row
[|
( Edn_util.keyword "logseq.property/priority",
Edn_util.keyword "logseq.property/priority.high" );
(Edn_util.keyword "block/title", Edn_util.string "Ship");
( Edn_util.keyword "logseq.property/status",
Edn_util.keyword "logseq.property/status.todo" );
( Edn_util.keyword "block/created-at",
Edn_util.int64 40000L );
( Edn_util.keyword "block/updated-at",
Edn_util.int64 90000L );
(Edn_util.keyword "db/id", Edn_util.int64 12L);
(Edn_util.keyword "block/uuid", Edn_util.string "u1");
|];
|])))
(config ())
in
expect_equal "ordered task headers"
"id,title,status,priority,updated-at,created-at"
(headers_from unordered_task_output
|> Vec.of_array |> Vec.string_concat ",");
let node_output =
human Command_id.List_node
(items
[|
row
[|
(Edn_util.keyword "db/id", Edn_util.int64 1L);
(Edn_util.keyword "block/title", Edn_util.string "Node Page");
(Edn_util.keyword "node/type", Edn_util.string "page");
( Edn_util.keyword "block/uuid",
Edn_util.uuid "11111111-1111-1111-1111-111111111111" );
|];
|])
in
expect_named_contains "node type column" node_output "type";
expect_named_not_contains "node uuid column" node_output "uuid";
let asset_output =
human Command_id.List_asset
(items
[|
row
[|
(Edn_util.keyword "db/id", Edn_util.int64 3L);
(Edn_util.keyword "block/title", Edn_util.string "Asset Node");
( Edn_util.keyword "logseq.property.asset/type",
Edn_util.string "md" );
( Edn_util.keyword "logseq.property.asset/size",
Edn_util.int64 2552L );
|];
|])
in
expect_named_contains "asset type column" asset_output "type";
expect_named_contains "asset size column" asset_output "size";
expect_named_contains "asset raw size" asset_output "2552");
test "CLI parity format list title truncation keeps current display contracts"
(fun () ->
let ellipsis = decode_uri_component "%E2%80%A6" in
let item title =
Edn_util.map_vec
(Vec.of_array
[|
(Edn_util.keyword "db/id", Edn_util.int64 1L);
(Edn_util.keyword "block/title", Edn_util.string title);
|])
in
let value title =
Edn_util.map_vec
(Vec.singleton
( Edn_util.keyword "items",
Edn_util.vector_vec (Vec.singleton (item title)) ))
in
let truncated =
Format_types.format_result
~human_table_headers_order:
(Vec.of_array
[| "id"; "ident"; "title"; "uuid"; "created-at"; "updated-at" |])
(Cli_result.ok ~command:Command_id.List_page Output.Mode.Human
(Cli_result.Raw (value "ABCDEFGH")))
(config ~list_title_max_display_width:6 ())
in
expect_named_contains "single line display truncation" truncated
("ABCDE" ^ ellipsis);
expect_named_not_contains "single line hides full title" truncated
"ABCDEFGH";
let multiline =
Format_types.format_result
~human_table_headers_order:
(Vec.of_array
[| "id"; "ident"; "title"; "uuid"; "created-at"; "updated-at" |])
(Cli_result.ok ~command:Command_id.List_page Output.Mode.Human
(Cli_result.Raw (value "Line 1\nLine 2\nLine 3\nLine 4\nLine 5")))
(config ())
in
expect_named_contains "multiline line one" multiline "Line 1";
expect_named_not_contains "multiline line two" multiline "Line 2";
ignore ellipsis;
expect_named_not_contains "multiline line three" multiline "Line 3";
expect_named_not_contains "multiline line four" multiline "Line 4";
expect_named_not_contains "multiline line five" multiline "Line 5");
test "CLI parity list task aligns status after accented latin titles"
(fun () ->
let accented_title =
decode_uri_component
"Diese%20Vorteile%20k%C3%B6nnten%20besonders%20f%C3%BCr%20eine"
in
let item id title =
Edn_util.map
[
(Edn_util.keyword "db/id", Edn_util.int64 id);
(Edn_util.keyword "block/title", Edn_util.string title);
( Edn_util.keyword "logseq.property/status",
Edn_util.keyword "logseq.property/status.todo" );
]
in
let value =
Edn_util.map_vec
(Vec.singleton
( Edn_util.keyword "items",
Edn_util.vector_vec
(Vec.of_array [| item 1L "yyy"; item 2L accented_title |]) ))
in
let output =
Format_types.format_result
~human_table_headers_order:
(Vec.of_array [| "id"; "title"; "status" |])
(Cli_result.ok ~command:Command_id.List_task Output.Mode.Human
(Cli_result.Raw value))
(config ())
in
let status_column line token =
match Js.String.indexOf ~search:token line with
| -1 ->
fail_test ("missing " ^ token ^ " in line:\n" ^ line ^ "\n" ^ output);
0
| index -> display_width (String.sub line 0 index)
in
let lines = Vec.of_array (string_split (string_trim_end output) "\n") in
if Vec.length lines = 4 then (
let header = Vec.nth lines 0 in
let short_row = Vec.nth lines 1 in
let accented_row = Vec.nth lines 2 in
let expected = status_column header "status" in
expect_int "short task status column" expected
(status_column short_row "status.todo");
expect_int "accented task status column" expected
(status_column accented_row "status.todo"))
else fail_test ("unexpected list task output:\n" ^ output));
test "CLI parity format upsert summaries use current generic tables"
(fun () ->
let raw_ids ids =
let ids = Vec.of_array ids in
Cli_result.Raw
(Edn_util.map
[
( Edn_util.keyword "result",
Edn_util.vector_vec (Vec.map Edn_util.int64 ids) );
])
in
let upsert_block =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Upsert_block Output.Mode.Human
(raw_ids [| 201L; 202L |]))
(config ())
in
expect_equal "upsert block table" "Value\n201\n202\nCount: 2" upsert_block;
let upsert_page =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Upsert_page Output.Mode.Human
(raw_ids [| 123L |]))
(config ())
in
expect_equal "upsert page table" "Value\n123\nCount: 1" upsert_page);
test "CLI parity format remove summaries use current generic tables"
(fun () ->
let success_data =
Cli_result.Raw
(Edn_util.map_vec
(Vec.singleton
( Edn_util.keyword "result",
Edn_util.map_vec
(Vec.singleton (Edn_util.keyword "ok", Edn_util.bool true))
)))
in
let page_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Remove_page Output.Mode.Human
success_data)
(config ~repo:"demo-repo" ())
in
expect_equal "remove page result table" "result {:ok true}" page_output;
let block_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Remove_block Output.Mode.Human
success_data)
(config ~repo:"demo-repo" ())
in
expect_equal "remove block result table" "result {:ok true}" block_output);
test
"CLI parity format graph sync and auth summaries use current generic output"
(fun () ->
let graph_export =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Graph_export Output.Mode.Human
Cli_result.Empty)
(config ())
in
expect_equal "graph export empty output" "" graph_export;
let sync_status =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Sync_status Output.Mode.Human
(Cli_result.Raw
(Edn_util.map
[
(Edn_util.keyword "repo", Edn_util.string "demo-graph");
(Edn_util.keyword "graph-id", Edn_util.string "graph-uuid");
(Edn_util.keyword "ws-state", Edn_util.keyword "open");
(Edn_util.keyword "pending-local", Edn_util.int64 2345L);
])))
(config ())
in
expect_named_contains "sync status repo" sync_status "demo-graph";
expect_named_contains "sync status pending" sync_status "2345";
let login =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Login Output.Mode.Human
(Cli_result.Raw
(Edn_util.map
[
( Edn_util.keyword "auth-path",
Edn_util.string "/tmp/auth.json" );
( Edn_util.keyword "email",
Edn_util.string "user@example.com" );
( Edn_util.keyword "id-token",
Edn_util.string "secret-token" );
])))
(config ())
in
expect_named_contains "login email" login "user@example.com";
expect_named_contains "login current token field" login "secret-token");
test "CLI parity format graph info keeps old kv redaction contracts"
(fun () ->
let token = "secret-token-value" in
let kv_value =
Edn_util.map
[
(Edn_util.string "logseq.kv/api-token", Edn_util.string token);
(Edn_util.string "logseq.kv/db-type", Edn_util.keyword "sqlite");
]
in
let graph_info_value =
Edn_util.map
[
(Edn_util.keyword "graph", Edn_util.string "demo-graph");
(Edn_util.keyword "kv", kv_value);
]
in
let output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Graph_info Output.Mode.Human
(Cli_result.Raw graph_info_value))
(config ())
in
expect_named_contains "graph info graph row" output "graph";
expect_named_contains "graph info graph value" output "demo-graph";
expect_named_contains "graph info redacts token" output "[REDACTED]";
expect_named_not_contains "graph info hides token" output token;
let json_graph_info_value =
Edn_util.map
[
(Edn_util.keyword "graph", Edn_util.string "demo-graph");
( Edn_util.keyword "kv",
Edn_util.map
[
(Edn_util.string "logseq.kv/api-token", Edn_util.string token);
] );
]
in
let json_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Graph_info Output.Mode.Json
(Cli_result.Raw json_graph_info_value))
(config ~output_format:(Output.Mode.Packed Output.Mode.Json) ())
in
expect_named_contains "graph info json redaction" json_output
"\"logseq.kv/api-token\":\"[REDACTED]\"";
expect_named_not_contains "graph info json hides token" json_output token);
test "CLI parity format server outputs use current owner revision contracts"
(fun () ->
let server =
Edn_util.map
[
(Edn_util.keyword "repo", Edn_util.string "demo-repo");
(Edn_util.keyword "status", Edn_util.keyword "ready");
(Edn_util.keyword "host", Edn_util.string "127.0.0.1");
(Edn_util.keyword "port", Edn_util.int64 1234L);
(Edn_util.keyword "pid", Edn_util.int64 9876L);
(Edn_util.keyword "owner-source", Edn_util.keyword "cli");
(Edn_util.keyword "revision", Edn_util.string "worker-revision");
]
in
let list_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Server_list Output.Mode.Human
(Cli_result.Raw
(Edn_util.map_vec
(Vec.singleton
( Edn_util.keyword "servers",
Edn_util.vector_vec (Vec.singleton server) )))))
(config ())
in
expect_named_contains "server repo header" list_output "repo";
expect_named_contains "server owner header" list_output "owner-source";
expect_named_contains "server revision" list_output "worker-revision";
let cleanup_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Server_cleanup Output.Mode.Human
(Cli_result.Raw
(Edn_util.map_vec
(Vec.of_array
[|
( Edn_util.keyword "cli-revision",
Edn_util.string "cli-rev" );
(Edn_util.keyword "checked", Edn_util.int64 4321L);
(Edn_util.keyword "mismatched", Edn_util.int64 3210L);
( Edn_util.keyword "killed",
Edn_util.vector_vec (Vec.singleton server) );
|]))))
(config ())
in
expect_named_contains "cleanup checked row" cleanup_output "checked";
expect_named_contains "cleanup checked value" cleanup_output "4321");
test "CLI parity format doctor and validation outputs use current contracts"
(fun () ->
let validation_success =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Graph_validate
~context:
(Edn_util.map
[ (Edn_util.keyword "graph", Edn_util.string "foo") ])
Output.Mode.Human
(Cli_result.Raw
(Edn_util.map
[
( Edn_util.keyword "result",
Edn_util.map
[ (Edn_util.keyword "errors", Edn_util.nil) ] );
])))
(config ())
in
expect_equal "graph validation result table" "result {:errors nil}"
validation_success;
let validation_error =
Format_types.format_result
(Cli_result.error ~command:Command_id.Graph_validate Output.Mode.Human
(Error.make Error.Graph_validation_failed
"Found 1 entity with errors:\n({:entity {:db/id 1}})\n"))
(config ())
in
expect_named_contains "graph validation error code" validation_error
"Error (graph-validation-failed):";
expect_named_contains "graph validation error body" validation_error
"Found 1 entity with errors";
let doctor_value =
Edn_util.map
[
(Edn_util.keyword "status", Edn_util.keyword "warning");
( Edn_util.keyword "checks",
Edn_util.vector
[
Edn_util.map
[
(Edn_util.keyword "id", Edn_util.keyword "root-dir");
(Edn_util.keyword "status", Edn_util.keyword "ok");
( Edn_util.keyword "message",
Edn_util.string "Read/write access confirmed" );
];
Edn_util.map
[
( Edn_util.keyword "id",
Edn_util.keyword "server-revision-mismatch" );
(Edn_util.keyword "status", Edn_util.keyword "warning");
( Edn_util.keyword "message",
Edn_util.string
"1 server uses a different revision than this CLI" );
( Edn_util.keyword "servers",
Edn_util.vector
[
Edn_util.map
[
( Edn_util.keyword "graph",
Edn_util.string "team graph" );
];
] );
];
] );
]
in
let doctor_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Doctor Output.Mode.Human
(Cli_result.Raw doctor_value))
(config ())
in
expect_named_contains "doctor status" doctor_output "warning";
expect_named_contains "doctor root check" doctor_output "root-dir";
expect_named_contains "doctor restart target" doctor_output "team graph";
let revision_error =
Format_types.format_result
(Cli_result.error ~command:Command_id.Server_start Output.Mode.Human
(Error.make
~hint:
"Logseq restarted db-worker-node, but the replacement still \
reports a different revision. Check the installed Logseq \
build and retry"
Error.Server_revision_mismatch_after_restart
"db-worker-node revision still does not match after restart"))
(config ())
in
expect_named_contains "revision mismatch error" revision_error
"Error (server-revision-mismatch-after-restart):");
test "CLI parity format query and show outputs keep current raw contracts"
(fun () ->
let query_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Query Output.Mode.Human
(Cli_result.Query_result
(Edn_util.map
[
( Edn_util.keyword "result",
Edn_util.vector
[
Edn_util.int64 1L;
Edn_util.int64 2L;
Edn_util.int64 3L;
] );
])))
(config ())
in
expect_equal "query scalar vector lines" "1\n2\n3" query_output;
let show_output =
Format_types.format_result
(Cli_result.ok ~command:Command_id.Show Output.Mode.Human
(Cli_result.Message "Line 1\nLine 2"))
(config ())
in
expect_equal "show text payload" "Line 1\nLine 2" show_output)
let touch = ()