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>
8294 lines
335 KiB
OCaml
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 = ()
|