open Test_support let () = ignore Cli_parity_test_cases.touch let tree_pipe = decode_uri_component "%E2%94%82" let tree_corner = decode_uri_component "%E2%94%94%E2%94%80%E2%94%80" let tree_middle = decode_uri_component "%E2%94%9C%E2%94%80%E2%94%80" let unicode_text codepoints = Vec.string_concat "" (Vec.map Js.String.fromCodePoint (Vec.of_array codepoints)) let () = test "json output renders transit quoted uuid as string" (fun () -> let uuid = "11111111-1111-4111-8111-111111111111" in let value = Edn_util.any (Melange_edn_melange.tagged "transit/quote" (Edn_util.uuid uuid)) in let output = Format_types.to_json (Cli_result.ok Output.Mode.Json (Cli_result.Query_result value)) in expect_named_contains "quoted uuid string" output uuid; expect_named_not_contains "quoted uuid tag" output "transit/quote"; expect_named_not_contains "uuid tag object" output "\"tag\""); test "json error output includes hint" (fun () -> let err = Error.make ~hint:"Run logseq sync start --graph demo." Error.Sync_not_started "sync is not started for this graph" in let output = Format_types.to_json (Cli_result.error Output.Mode.Json err) in expect_named_contains "json error code" output "\"code\":\"sync-not-started\""; expect_named_contains "json error hint" output "\"hint\":\"Run logseq sync start --graph demo.\"") let () = test "completion accepts --shell bash" (fun () -> let output = run_cli [| "completion"; "--shell"; "bash" |] in expect_named_contains "bash completion" output "Auto-generated by `logseq completion bash`"; expect_named_contains "bash completion function" output "_logseq_json_names_bash"); test "skill show ignores structured output mode" (fun () -> mkdir_p ".agents/skills/logseq-cli"; write_file ".agents/skills/logseq-cli/SKILL.md" "---\nname: logseq-cli\n---\n\n# Logseq CLI\n"; let output = run_cli [| "--output"; "json"; "skill"; "show" |] in expect_named_contains "skill markdown name" output "name: logseq-cli"; expect_named_contains "skill markdown title" output "# Logseq CLI"; expect_named_not_contains "skill markdown status wrapper" output "\"status\""; expect_named_not_contains "skill markdown data wrapper" output "\"data\"") let write_cli_wrapper path = write_file path ("require(" ^ Js.Json.stringify (Js.Json.string (Node.Path.resolve "." entrypoint)) ^ ");\n") let spawn_cli_from entrypoint ?(env = [||]) args = spawn_sync Node.Process.argv.(0) (Array.append [| entrypoint |] args) [%obj { encoding = "utf8"; env = clone_env env }] let doctor_args root = [| "--root-dir"; root; "--output"; "json"; "doctor" |] let () = test "doctor resolves db-worker-node beside CLI entrypoint" (fun () -> let root = temp_dir "logseq-cli-doctor-entrypoint-root-" in let app_dir = temp_dir "logseq-cli-doctor-app-" in let cli_dir = Node.Path.join [| app_dir; "bin" |] in let cli_path = Node.Path.join [| cli_dir; "logseq-cli.js" |] in let worker_path = Node.Path.join [| cli_dir; "db-worker-node.js" |] in try mkdir_p cli_dir; write_cli_wrapper cli_path; write_file worker_path "console.log('db-worker');\n"; let result = spawn_cli_from cli_path (doctor_args root) in remove_tree root; remove_tree app_dir; ignore (expect_exit_zero "doctor entrypoint worker" result); expect_named_contains "doctor entrypoint path" result##stdout worker_path with exn -> remove_tree root; remove_tree app_dir; fail_test (Printexc.to_string exn)); test "doctor resolves db-worker-node beside CLI inside app.asar" (fun () -> let root = temp_dir "logseq-cli-doctor-asar-root-" in let app_dir = temp_dir "logseq-cli-doctor-app-" in let asar_dir = Node.Path.join [| app_dir; "Logseq.app.asar" |] in let cli_path = Node.Path.join [| asar_dir; "logseq-cli.js" |] in let worker_path = Node.Path.join [| asar_dir; "db-worker-node.js" |] in try mkdir_p asar_dir; write_cli_wrapper cli_path; write_file worker_path "console.log('db-worker');\n"; let result = spawn_cli_from cli_path (doctor_args root) in remove_tree root; remove_tree app_dir; ignore (expect_exit_zero "doctor asar worker" result); expect_named_contains "doctor asar path" result##stdout worker_path with exn -> remove_tree root; remove_tree app_dir; fail_test (Printexc.to_string exn)); test "doctor resolves db-worker-node under js beside CLI entrypoint" (fun () -> let root = temp_dir "logseq-cli-doctor-entrypoint-js-root-" in let app_dir = temp_dir "logseq-cli-doctor-app-" in let cli_dir = Node.Path.join [| app_dir; "bin" |] in let cli_js_dir = Node.Path.join [| cli_dir; "js" |] in let cli_path = Node.Path.join [| cli_dir; "logseq-cli.js" |] in let worker_path = Node.Path.join [| cli_js_dir; "db-worker-node.js" |] in try mkdir_p cli_js_dir; write_cli_wrapper cli_path; write_file worker_path "console.log('db-worker');\n"; let result = spawn_cli_from cli_path (doctor_args root) in remove_tree root; remove_tree app_dir; ignore (expect_exit_zero "doctor entrypoint js worker" result); expect_named_contains "doctor entrypoint js path" result##stdout worker_path with exn -> remove_tree root; remove_tree app_dir; fail_test (Printexc.to_string exn)); test "doctor keeps LOGSEQ_DB_WORKER_NODE_SCRIPT before app.asar worker" (fun () -> let root = temp_dir "logseq-cli-doctor-asar-env-root-" in let app_dir = temp_dir "logseq-cli-doctor-app-" in let asar_js_dir = Node.Path.join [| app_dir; "Logseq.app.asar"; "js" |] in let cli_path = Node.Path.join [| asar_js_dir; "logseq-cli.js" |] in let asar_worker_path = Node.Path.join [| asar_js_dir; "db-worker-node.js" |] in let env_worker_path = Node.Path.join [| app_dir; "env-worker.js" |] in try mkdir_p asar_js_dir; write_cli_wrapper cli_path; write_file asar_worker_path "console.log('asar db-worker');\n"; write_file env_worker_path "console.log('env db-worker');\n"; let result = spawn_cli_from cli_path ~env:[| ("LOGSEQ_DB_WORKER_NODE_SCRIPT", env_worker_path) |] (doctor_args root) in remove_tree root; remove_tree app_dir; ignore (expect_exit_zero "doctor env worker" result); expect_named_contains "doctor env path" result##stdout env_worker_path; expect_named_not_contains "doctor env excludes asar path" result##stdout asar_worker_path with exn -> remove_tree root; remove_tree app_dir; fail_test (Printexc.to_string exn)) let () = test "sync download requires an explicit graph" (fun () -> let root = temp_dir "logseq-cli-sync-download-graph-" in try write_file (Node.Path.join [| root; "cli.edn" |]) "{:graph \"fallback-file\"}\n"; let result = spawn_cli ~env:[| ("LOGSEQ_CLI_GRAPH", "fallback-env") |] [| "--root-dir"; root; "sync"; "download" |] in remove_tree root; ignore (expect_exit_non_zero "sync download without --graph" result); expect_named_contains "sync download missing graph" result##stdout "graph name is required" with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test "graph commands require explicit unicode graph names" (fun () -> let root = temp_dir "logseq-cli-graph-unicode-" in let graph = decode_uri_component "%E5%95%8A%E5%95%8A%E5%95%8A%E5%95%8A" in let fallback_graph = "fallback-env" in let encoded_graph_dir = "~E5~95~8A~E5~95~8A~E5~95~8A~E5~95~8A" in let run ?(env = [||]) args = spawn_cli ~env (Array.append [| "--root-dir"; root |] args) in try mkdir_p root; write_file (Node.Path.join [| root; "cli.edn" |]) "{:graph \"fallback-file\"}\n"; let create_without_graph = run ~env:[| ("LOGSEQ_CLI_GRAPH", fallback_graph) |] [| "graph"; "create" |] in ignore (expect_exit_non_zero "graph create without -g" create_without_graph); ignore (expect_named_contains "graph create without -g" create_without_graph##stdout "graph name is required"); if Node.Fs.existsSync (Node.Path.join [| root; "graphs"; fallback_graph |]) then fail_test "graph create without -g created fallback graph"; let create_fallback = run [| "graph"; "create"; "-g"; fallback_graph |] in ignore (expect_exit_zero "fallback graph create" create_fallback); if Node.Fs.existsSync (Node.Path.join [| root; "current-graph" |]) then fail_test "graph create wrote current-graph"; let switch_without_graph = run ~env:[| ("LOGSEQ_CLI_GRAPH", fallback_graph) |] [| "graph"; "switch" |] in ignore (expect_exit_non_zero "graph switch without -g" switch_without_graph); ignore (expect_named_contains "graph switch without -g" switch_without_graph##stdout "graph name is required"); let remove_without_graph = run ~env:[| ("LOGSEQ_CLI_GRAPH", fallback_graph) |] [| "graph"; "remove" |] in ignore (expect_exit_non_zero "graph remove without -g" remove_without_graph); ignore (expect_named_contains "graph remove without -g" remove_without_graph##stdout "graph name is required"); if not (Node.Fs.existsSync (Node.Path.join [| root; "graphs"; fallback_graph |])) then fail_test "graph remove without -g removed fallback graph"; let create = run [| "graph"; "create"; "-g"; graph |] in ignore (expect_exit_zero "graph create" create); if Node.Fs.existsSync (Node.Path.join [| root; "current-graph" |]) then fail_test "graph create wrote current-graph"; ignore (expect_named_contains "graph create" create##stdout graph); let graph_dirs = Node.Fs.readdirSync (Node.Path.join [| root; "graphs" |]) in if not (Array.exists (( = ) encoded_graph_dir) graph_dirs) then fail_test (Printf.sprintf "graph dir: expected %s, got [%s]" encoded_graph_dir (graph_dirs |> Vec.of_array |> Vec.string_concat ", ")); let human_list = run [| "graph"; "list" |] in ignore (expect_exit_zero "human graph list" human_list); ignore (expect_named_contains "human graph list" human_list##stdout graph); let json_list = run [| "--output"; "json"; "graph"; "list" |] in ignore (expect_exit_zero "json graph list" json_list); remove_tree root; expect_graph_in_json_list json_list##stdout graph with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test "graph list ignores current-graph file" (fun () -> let root = temp_dir "logseq-cli-graph-list-current-file-" in let run args = spawn_cli (Array.append [| "--root-dir"; root |] args) in try mkdir_p root; ignore (expect_exit_zero "create alpha" (run [| "graph"; "create"; "-g"; "alpha" |])); ignore (expect_exit_zero "create beta" (run [| "graph"; "create"; "-g"; "beta" |])); write_file (Node.Path.join [| root; "current-graph" |]) "alpha"; let result = run [| "graph"; "list" |] in remove_tree root; ignore (expect_exit_zero "graph list" result); expect_named_not_contains "graph list current file marker" result##stdout "* alpha" with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test "graph switch updates cli.edn graph" (fun () -> let root = temp_dir "logseq-cli-graph-switch-config-" in let run args = spawn_cli (Array.append [| "--root-dir"; root |] args) in try mkdir_p root; ignore (expect_exit_zero "create alpha" (run [| "graph"; "create"; "-g"; "alpha" |])); ignore (expect_exit_zero "create beta" (run [| "graph"; "create"; "-g"; "beta" |])); write_file (Node.Path.join [| root; "cli.edn" |]) "{:http-base \"http://127.0.0.1:65535\"}\n"; let switch = spawn_cli ~env:[| ("LOGSEQ_CLI_BASE_URL", "http://127.0.0.1:65535") |] [| "--root-dir"; root; "--output"; "json"; "--graph"; "beta"; "graph"; "switch"; |] in ignore (expect_exit_zero "graph switch" switch); ignore (expect_named_contains "graph switch message" switch##stdout "\"message\":\"switched to beta\""); let config = read_file (Node.Path.join [| root; "cli.edn" |]) in ignore (expect_named_contains "graph switch config graph" config ":graph \"beta\""); ignore (expect_named_contains "graph switch config preserves http-base" config ":http-base \"http://127.0.0.1:65535\""); let list = run [| "graph"; "list" |] in remove_tree root; ignore (expect_exit_zero "graph list" list); expect_named_contains "graph list selected graph" list##stdout "* beta" with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test "json output escapes control characters" (fun () -> let root = temp_dir "logseq-cli-json-output-" in let graph = "json" ^ control_char ^ "graph" in try let result = spawn_cli [| "--root-dir"; root; "--output"; "json"; "graph"; "create"; "--graph"; graph; |] in remove_tree root; ignore (assert_exit_zero "json output graph create" result); expect_valid_json "json output graph create" result##stdout with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test "verbose parsed options line is valid edn" (fun () -> let root = temp_dir "logseq-cli verbose root-" in try let result = spawn_cli [| "--verbose"; "--root-dir"; root; "--graph"; "alpha"; "graph"; "list"; |] in remove_tree root; ignore (assert_exit_zero "verbose graph list" result); match Vec.peek_front_opt (Vec.split_on_char '\n' result##stderr) with | Some line when String.trim line <> "" -> expect_valid_edn "verbose parsed options line" line | _ -> fail_test ("missing verbose stderr line: " ^ result##stderr) with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test "login times out without socket fallback" (fun () -> let root = temp_dir "logseq-cli-login-" in let config_path = Node.Path.join [| root; "cli.edn" |] in try write_file config_path "{:open-browser false :login-timeout-ms 1}\n"; let result = spawn_cli [| "--root-dir"; root; "--config"; config_path; "login" |] in remove_tree root; ignore (expect_exit_non_zero "login without callback" result); ignore (expect_named_not_contains "login stdout socket fallback" result##stdout "sockets are unavailable"); ignore (expect_named_not_contains "login stderr socket fallback" result##stderr "sockets are unavailable"); if String.contains result##stdout 'l' && Js.String.includes ~search:"login callback timed out" result##stdout || String.contains result##stderr 'l' && Js.String.includes ~search:"login callback timed out" result##stderr then pass else fail_test (Printf.sprintf "expected login callback timeout\n%s\n%s" result##stdout result##stderr) with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test_promise "login generates fresh PKCE challenge across CLI processes" (fun () -> let login_once base_url index = let root = temp_dir ("logseq-cli-login-pkce-" ^ string_of_int index ^ "-") in let config_path = Node.Path.join [| root; "cli.edn" |] in let auth_path = Node.Path.join [| root; "auth.json" |] in let state, encoded_state = if index = 1 then (unicode_text [| 0x4e2d |], "%E4%B8%AD") else ("fixed-test-state", "fixed-test-state") in let code = "auth-code-" ^ string_of_int index in write_file config_path ("{:open-browser false\n :login-timeout-ms 5000\n :auth-path " ^ Printf.sprintf "%S" auth_path ^ "\n :oauth-authorize-endpoint " ^ Printf.sprintf "%S" (base_url ^ "/authorize") ^ "\n :oauth-token-endpoint " ^ Printf.sprintf "%S" (base_url ^ "/oauth2/token") ^ "\n :oauth-client-id \"test-client\"\n :oauth-state \"" ^ state ^ "\"}\n"); let login = run_cli_p [| "--root-dir"; root; "--config"; config_path; "--output"; "json"; "login"; |] in let callback_url = Printf.sprintf "http://localhost:8765/auth/callback?state=%s&code=%s" encoded_state code in let* () = fetch_with_retry 100 callback_url in let* result = login in remove_tree root; ignore (expect_cli_exit_zero "login pkce" result); let authorize_url = json_data_string result.stdout "authorize-url" in if query_param authorize_url "state" <> Some encoded_state then fail_promise ("unexpected state: " ^ authorize_url) else match query_param authorize_url "code_challenge" with | Some challenge -> Js.Promise.resolve challenge | None -> fail_promise ("missing code_challenge: " ^ authorize_url) in with_server (oauth_server ()) (fun base_url -> let* first = login_once base_url 1 in let* second = login_once base_url 2 in if first <> second then Js.Promise.resolve pass else fail_promise ("expected fresh PKCE challenge across logins, got " ^ first))); test_promise "logout deletes auth and exits without callback" (fun () -> let root = temp_dir "logseq-cli-logout-flow-" in let config_path = Node.Path.join [| root; "cli.edn" |] in let auth_path = Node.Path.join [| root; "auth.json" |] in let state = "fixed-logout-state" in let write_auth () = write_file auth_path "{\"provider\":\"cognito\",\"refresh-token\":\"refresh-token\"}\n" in let logout_once base_url = write_auth (); write_file config_path (Printf.sprintf "{:open-browser false\n\ \ :logout-timeout-ms 5000\n\ \ :auth-path %S\n\ \ :http-base %S\n\ \ :oauth-client-id \"test-client\"\n\ \ :oauth-logout-state %S}\n" auth_path base_url state); let logout = run_cli_p [| "--root-dir"; root; "--config"; config_path; "--output"; "json"; "logout"; |] in let* result = logout in ignore (expect_cli_exit_zero "logout" result); if Node.Fs.existsSync auth_path then fail_promise "logout did not delete auth file" else let logout_url = json_data_string result.stdout "logout-url" in let deleted = json_data_bool result.stdout "deleted" in let opened = json_data_bool result.stdout "opened" in let completed = json_data_bool result.stdout "logout-completed" in if not deleted then fail_promise ("expected deleted=true: " ^ result.stdout) else if opened then fail_promise ("expected opened=false: " ^ result.stdout) else if not completed then fail_promise ("expected logout-completed=true: " ^ result.stdout) else if not (Js.String.startsWith ~prefix:(base_url ^ "/logout?") logout_url) then fail_promise ("unexpected logout-url: " ^ logout_url) else if query_param logout_url "client_id" <> Some "test-client" then fail_promise ("missing client_id: " ^ logout_url) else if query_param logout_url "response_type" <> Some "code" then fail_promise ("missing response_type: " ^ logout_url) else if query_param logout_url "scope" <> Some "email%20openid%20phone" then fail_promise ("missing scope: " ^ logout_url) else if query_param logout_url "state" <> Some state then fail_promise ("missing state: " ^ logout_url) else match query_param logout_url "redirect_uri" with | Some uri when uri = "http%3A%2F%2Flocalhost%3A8765%2Fauth%2Fcallback" -> Js.Promise.resolve pass | _ -> fail_promise ("missing redirect_uri: " ^ logout_url) in with_server (oauth_server ()) (fun base_url -> let* result = logout_once base_url in remove_tree root; Js.Promise.resolve result)); test "example without selector returns all examples as json" (fun () -> let result = spawn_cli [| "--output"; "json"; "example" |] in ignore (expect_exit_zero "example all json" result); ignore (expect_named_contains "example all json status" result##stdout "\"status\":\"ok\""); ignore (expect_named_contains "example all json selector" result##stdout "\"selector\":\"all\""); ignore (expect_named_contains "example all json graph example" result##stdout "logseq graph list"); ignore (expect_named_contains "example all json sync example" result##stdout "logseq sync config unset sync-enabled"); ignore (expect_named_not_contains "example all json old sync flag" result##stdout "--key sync-enabled"); pass); test "example human output uses readable command and example lists" (fun () -> let result = spawn_cli [| "example" |] in ignore (expect_exit_zero "example all human" result); ignore (expect_named_contains "example human selector" result##stdout "Selector: all"); ignore (expect_named_contains "example human commands heading" result##stdout "Matched commands:"); ignore (expect_named_contains "example human command bullet" result##stdout " - graph list"); ignore (expect_named_contains "example human sync command bullet" result##stdout " - sync config unset"); ignore (expect_named_contains "example human examples heading" result##stdout "Examples:"); ignore (expect_named_contains "example human example bullet" result##stdout " - logseq graph list"); expect_named_contains "example human sync example bullet" result##stdout " - logseq sync config unset sync-enabled"; expect_named_not_contains "example human old sync flag" result##stdout "--key sync-enabled") let () = test_promise "graph info formats kv metadata" (fun () -> let root = temp_dir "logseq-cli-graph-info-" in let request_count = ref 0 in let result_transit = "[\"~#set\",[[\"~:logseq.kv/graph-created-at\",1700000000],[\"~:logseq.kv/schema-version\",77],[\"~:logseq.kv/custom\",\"demo\"]]]" in let server = invoke_server (fun body -> incr request_count; if not (Js.String.includes ~search:"thread-api/q" body) then fail_test ("unexpected invoke method: " ^ body); if not (Js.String.includes ~search:"logseq_db_alpha" body) then fail_test ("missing repo in request: " ^ body); if not (Js.String.includes ~search:"logseq.kv" body) then fail_test ("missing graph info query namespace: " ^ body); result_transit) in with_server server (fun base_url -> let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in let* human = run_cli_p ~env [| "--root-dir"; root; "--graph"; "alpha"; "graph"; "info" |] in ignore (expect_cli_exit_zero "human graph info" human); ignore (expect_named_contains "human graph" human.stdout "graph"); ignore (expect_named_contains "human schema" human.stdout "logseq.kv/schema-version 77"); ignore (expect_named_contains "human created" human.stdout "ago"); ignore (expect_line_starts_with "human custom kv row" human.stdout "logseq.kv/custom"); ignore (expect_named_not_contains "human created fallback" human.stdout "ms ago"); ignore (expect_named_not_contains "human unreasonable created seconds" human.stdout "56 years"); ignore (expect_named_not_contains "human no field header" human.stdout "Field"); ignore (expect_named_not_contains "human no value header" human.stdout "Value"); ignore (expect_named_not_contains "human no kv aggregate" human.stdout "kv {"); ignore (expect_named_not_contains "human missing created" human.stdout "logseq.kv/graph-created-at -"); ignore (expect_named_not_contains "human empty kv" human.stdout "kv {}"); let* json = run_cli_p ~env [| "--root-dir"; root; "--graph"; "alpha"; "--output"; "json"; "graph"; "info"; |] in ignore (expect_cli_exit_zero "json graph info" json); ignore (expect_named_contains "json created" json.stdout "\"logseq.kv/graph-created-at\":1700000000"); ignore (expect_named_contains "json schema" json.stdout "\"logseq.kv/schema-version\":77"); ignore (expect_named_contains "json kv" json.stdout "\"logseq.kv/custom\":\"demo\""); remove_tree root; if !request_count = 2 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected two graph info requests, got %d" !request_count))); test_promise "graph info reuses an existing db-worker from health json" (fun () -> let root = temp_dir "logseq-cli-graph-info-existing-server-" in let request_count = ref 0 in let result_transit = "[\"~#set\",[[\"~:logseq.kv/schema-version\",77]]]" in let port_of_base_url base_url = let colon = String.rindex base_url ':' in String.sub base_url (colon + 1) (String.length base_url - colon - 1) 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] () -> match (req_method req, req_url req) with | "GET", "/healthz" -> write_json res 200 "{\"repo\":\"logseq_db_alpha\",\"status\":\"ready\",\"host\":\"127.0.0.1\",\"pid\":1,\"owner-source\":\"cli\",\"root-dir\":\"/tmp/logseq-cli\",\"revision\":\"test-revision\"}" | "POST", "/v1/invoke" -> incr request_count; if not (Js.String.includes ~search:"thread-api/q" !body) then fail_test ("unexpected invoke method: " ^ !body); if not (Js.String.includes ~search:"logseq_db_alpha" !body) then fail_test ("missing repo in request: " ^ !body); write_json res 200 (json_response result_transit) | _ -> write_json res 404 (error_response "not found"))) in with_server server (fun base_url -> let port = port_of_base_url base_url in write_file (Node.Path.join [| root; "server-list" |]) (string_of_int process_pid ^ " " ^ port ^ "\n"); let* result = run_cli_p ~env: [| ( "LOGSEQ_DB_WORKER_NODE_SCRIPT", Node.Path.join [| root; "missing-db-worker-node.js" |] ); |] [| "--root-dir"; root; "--graph"; "alpha"; "graph"; "info" |] in remove_tree root; ignore (expect_cli_exit_zero "graph info existing server" result); ignore (expect_named_contains "existing server schema" result.stdout "logseq.kv/schema-version 77"); if !request_count = 1 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected one graph info request, got %d" !request_count))); test_promise "list page human output formats timestamps for large result sets" (fun () -> let request_count = ref 0 in let row index = Printf.sprintf "{\"~:db/id\":%d,\"~:block/title\":%s,\"~:db/ident\":%s,\"~:logseq.property/type\":\"node\",\"~:block/created-at\":%d,\"~:block/updated-at\":%d}" (index + 1) (Js.Json.stringify (Js.Json.string (Printf.sprintf "Page %d with enough text for table formatting" (index + 1)))) (Js.Json.stringify (Js.Json.string (Printf.sprintf "~:user.property/Page-%d-performance-check" (index + 1)))) (1700000000000 + index) (1780411164893 + index) in let result_transit = "[" ^ Vec.string_concat "," (Vec.init 700 row) ^ "]" in let server = invoke_server (fun body -> incr request_count; if not (Js.String.includes ~search:"thread-api/cli-list-pages" body) then fail_test ("unexpected invoke method: " ^ body); result_transit) in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "list"; "page" |] in ignore (expect_cli_exit_zero "list page human" output); if !request_count <> 1 then fail_promise (Printf.sprintf "expected one request, got %d" !request_count) else ( ignore (expect_named_not_contains "created timestamp raw" output.stdout "1700000000000"); ignore (expect_named_not_contains "updated timestamp raw" output.stdout "1780411164893"); ignore (expect_named_not_contains "zero years output" output.stdout "zero years"); ignore (expect_named_contains "count footer" output.stdout "Count: 700"); Js.Promise.resolve pass))); test_promise "list asset human output keeps asset columns stable" (fun () -> let request_count = ref 0 in let result_transit = "[{\"~:block/updated-at\":1779715760691,\"~:logseq.property.asset/checksum\":\"abc123\",\"~:logseq.property.asset/size\":4096,\"~:block/title\":\"logo.png\",\"~:block/created-at\":1777063533827,\"~:db/id\":10,\"~:logseq.property.asset/type\":\"png\"}]" in let server = invoke_server (fun body -> incr request_count; match !request_count with | 1 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"logseq.class/Asset" body -> "[\"^ \",\"~:db/id\",77]" | 2 -> result_transit | _ -> fail_test (Printf.sprintf "unexpected request %d: %s" !request_count body); "") in let root = temp_dir "logseq-cli-list-asset-" in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--root-dir"; root; "--graph"; "alpha"; "list"; "asset" |] in remove_tree root; ignore (expect_cli_exit_zero "list asset" output); if !request_count <> 2 then fail_promise (Printf.sprintf "expected two requests, got %d" !request_count) else let headers = headers_from output.stdout in let expected_prefix = [| "id"; "title"; "size"; "type" |] in let expected_suffix = [| "created-at"; "updated-at" |] in if not (string_array_equal (array_prefix headers (Array.length expected_prefix)) expected_prefix) then fail_promise ("unexpected asset header prefix: " ^ (headers |> Vec.of_array |> Vec.string_concat ",")) else if not (string_array_equal (array_suffix headers (Array.length expected_suffix)) expected_suffix) then fail_promise ("unexpected asset header suffix: " ^ (headers |> Vec.of_array |> Vec.string_concat ",")) else if not (Array.exists (( = ) "checksum") headers) then fail_promise ("missing middle asset column:\n" ^ output.stdout) else if Js.String.includes ~search:"node/type" output.stdout then fail_promise ("asset output must not render node/type:\n" ^ output.stdout) else Js.Promise.resolve pass)); test_promise "list task human output uses registry header order" (fun () -> let result_transit = "[{\"~:logseq.property/priority\":\"~:logseq.property/priority.high\",\"~:block/title\":\"Ship\",\"~:logseq.property/status\":\"~:logseq.property/status.todo\",\"~:block/created-at\":40000,\"~:block/updated-at\":90000,\"~:db/id\":12,\"~:block/uuid\":\"u1\"}]" in let server = invoke_server (fun body -> if Js.String.includes ~search:"thread-api/cli-list-tasks" body then result_transit else ( fail_test ("unexpected request: " ^ body); "null")) in let root = temp_dir "logseq-cli-list-task-order-" in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--root-dir"; root; "--graph"; "alpha"; "list"; "task" |] in remove_tree root; ignore (expect_cli_exit_zero "list task" output); let headers = headers_from output.stdout |> Vec.of_array |> Vec.string_concat "," in if headers <> "id,title,status,priority,created-at,updated-at" then fail_promise ("unexpected task headers: " ^ headers) else if Js.String.includes ~search:"logseq.property/status.todo" output.stdout then fail_promise ("task status value kept namespace:\n" ^ output.stdout) else if Js.String.includes ~search:"logseq.property/priority.high" output.stdout then fail_promise ("task priority value kept namespace:\n" ^ output.stdout) else ( ignore (expect_named_contains "task status value" output.stdout "status.todo"); ignore (expect_named_contains "task priority value" output.stdout "priority.high"); Js.Promise.resolve pass))); test "CLI exits quietly on stdout EPIPE" (fun () -> let root = temp_dir "logseq-cli-stdout-epipe-" in let script_path = Node.Path.join [| root; "emit-stdout-epipe.js" |] in let entrypoint_path = Node.Path.resolve "." entrypoint in try write_file script_path ("process.argv = [process.argv[0], " ^ Js.Json.stringify (Js.Json.string entrypoint_path) ^ ", '--version'];\nrequire(" ^ Js.Json.stringify (Js.Json.string entrypoint_path) ^ ");\n\ const err = new Error('write EPIPE');\n\ err.code = 'EPIPE';\n\ process.stdout.emit('error', err);\n"); let output = spawn_cli_from script_path [||] in remove_tree root; ignore (expect_exit_zero "stdout EPIPE" output); expect_named_not_contains "stdout EPIPE stderr" output##stderr "EPIPE"; expect_named_not_contains "stdout EPIPE unhandled" output##stderr "Unhandled 'error' event" with exn -> remove_tree root; fail_test (Printexc.to_string exn)); test_promise "list page human output aligns ragged property rows" (fun () -> let request_count = ref 0 in let title = decode_uri_component "%E4%B8%AD%E6%96%87%E5%9B%BE%E8%B0%B1Alpha" in let truncated_title = decode_uri_component "%E4%B8%AD%E6%96%87%E2%80%A6" in let result_transit = "[" ^ String.concat "," [ Printf.sprintf "{\"~:db/id\":1,\"~:block/title\":\"Plain\",\"~:block/created-at\":1777063533827,\"~:block/updated-at\":1779715760691}"; "{\"~:db/id\":2,\"~:block/title\":\"Property\",\"~:block/created-at\":1733130109047,\"~:block/updated-at\":1779543495725,\"~:db/ident\":\"~:user.property/Assignee-Xf-emnkZ\",\"~:logseq.property/type\":\"node\"}"; Printf.sprintf "{\"~:db/id\":3,\"~:block/title\":%s,\"~:block/created-at\":1700000000000,\"~:block/updated-at\":1700000000000}" (Js.Json.stringify (Js.Json.string title)); ] ^ "]" in let server = invoke_server (fun _body -> incr request_count; result_transit) in let root = temp_dir "logseq-cli-ragged-" in write_file (Node.Path.join [| root; "cli.edn" |]) "{:list-title-max-display-width 6}"; with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--root-dir"; root; "--graph"; "alpha"; "list"; "page" |] in remove_tree root; ignore (expect_cli_exit_zero "list page ragged" output); if !request_count <> 1 then fail_promise (Printf.sprintf "expected one request, got %d" !request_count) else ( ignore (expect_named_contains "db ident" output.stdout "user.property/Assignee-Xf-emnkZ"); ignore (expect_named_not_contains "property type column" output.stdout "logseq.property/type"); ignore (expect_named_not_contains "node type value" output.stdout "node"); ignore (expect_named_contains "display-width truncation" output.stdout truncated_title); ignore (expect_named_not_contains "full title" output.stdout title); ignore (expect_created_at_column_aligned output.stdout); ignore (expect_named_contains "ragged placeholder" output.stdout "-"); Js.Promise.resolve pass))); test_promise "upsert task sends scheduled and deadline as epoch millis" (fun () -> let step = ref 0 in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/q" body -> "[\"~#list\",[\"~:logseq.property/status.todo\"]]" | 2 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"logseq.class/Task" body -> "[\"^ \",\"~:db/id\",77]" | 3 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"taskhome" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\"]]" | 4 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if Js.String.includes ~search:"2026-02-10T08:00:00.000Z" body || Js.String.includes ~search:"2026-02-12T18:00:00.000Z" body then fail_test ("datetime task values must not be ISO strings: " ^ body); if Js.String.includes ~search:"\\\"1770710400000\\\"" body || Js.String.includes ~search:"\\\"1770919200000\\\"" body then fail_test ("datetime task values must be JSON numbers: " ^ body); if not (Js.String.includes ~search:"1770710400000" body && Js.String.includes ~search:"1770919200000" body) then fail_test ("missing epoch millis task values: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "task"; "--page"; "TaskHome"; "--status"; "todo"; "--priority"; "high"; "--scheduled"; "2026-02-10T08:00:00.000Z"; "--deadline"; "2026-02-12T18:00:00.000Z"; |] in ignore (expect_cli_exit_zero "upsert task datetime" output); if !step = 4 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected four invoke requests, got %d" !step))); test_promise "upsert page reports missing update property name" (fun () -> let step = ref 0 in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/pull" body && not (Js.String.includes ~search:"missing-prop" body) -> "[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"atomicpage\"]" | 2 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"missing-prop" body -> "null" | 3 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"missing-prop" body -> "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "page"; "--id"; "42"; "--update-properties"; "{:missing-prop \"value\"}"; |] in if output.code = 0 then fail_promise ("expected missing update property to fail\n" ^ output.stdout) else if not (Js.String.includes ~search:"property-not-found" output.stdout) then fail_promise ("missing property code\nstdout:\n" ^ output.stdout ^ "\nstderr:\n" ^ output.stderr) else if not (Js.String.includes ~search:"missing-prop" output.stdout) then fail_promise ("missing property name\nstdout:\n" ^ output.stdout ^ "\nstderr:\n" ^ output.stderr) else if not (Js.String.includes ~search:"\"option\":\"--update-properties\"" output.stdout) then fail_promise ("missing property option context\nstdout:\n" ^ output.stdout ^ "\nstderr:\n" ^ output.stderr) else if not (Js.String.includes ~search:"\"phase\":\"resolve-options\"" output.stdout) then fail_promise ("missing property phase context\nstdout:\n" ^ output.stdout ^ "\nstderr:\n" ^ output.stderr) else Js.Promise.resolve pass)); test_promise "upsert tag applies schema property outliner ops" (fun () -> let step = ref 0 in let tag_uuid = "22222222-2222-4222-8222-222222222222" in let add_ident = "user.property/status" in let remove_ident = "user.property/owner" in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"project" body -> "[]" | 2 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:"create-page" body) then fail_test ("missing tag create op: " ^ body); "[]" | 3 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"project" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"~u22222222-2222-4222-8222-222222222222\",\"~:block/name\",\"project\",\"~:block/tags\",[[\"^ \ \",\"~:db/ident\",\"~:logseq.class/Tag\"]]]]" | 4 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"status" body -> "[[\"^ \ \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/status\",\"~:logseq.property/type\",\"default\"]]" | 5 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"owner" body -> "[[\"^ \ \",\"~:db/id\",78,\"~:db/ident\",\"~:user.property/owner\",\"~:logseq.property/type\",\"default\"]]" | 6 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:"class-add-property" body) then fail_test ("missing class-add-property op: " ^ body); if not (Js.String.includes ~search:"class-remove-property" body) then fail_test ("missing class-remove-property op: " ^ body); if not (Js.String.includes ~search:tag_uuid body) then fail_test ("missing tag uuid: " ^ body); if not (Js.String.includes ~search:add_ident body) then fail_test ("missing add property ident: " ^ body); if not (Js.String.includes ~search:remove_ident body) then fail_test ("missing remove property ident: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "tag"; "--name"; "Project"; "--add-properties"; "[\"status\"]"; "--remove-properties"; "[\"owner\"]"; |] in ignore (expect_cli_exit_zero "upsert tag schema properties" output); if !step = 6 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected six invoke requests, got %d" !step))); test_promise "upsert tag reports missing schema property name" (fun () -> let step = ref 0 in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"project" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"~u22222222-2222-4222-8222-222222222222\",\"~:block/name\",\"project\",\"~:block/tags\",[[\"^ \ \",\"~:db/ident\",\"~:logseq.class/Tag\"]]]]" | 2 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"missing-prop" body -> "[]" | 3 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"missing-prop" body -> "null" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "tag"; "--name"; "Project"; "--add-properties"; "[\"missing-prop\"]"; |] in if output.code = 0 then fail_promise ("expected missing tag schema property to fail\n" ^ output.stdout) else if not (Js.String.includes ~search:"property-not-found" output.stdout) then fail_promise ("missing property code\nstdout:\n" ^ output.stdout) else if not (Js.String.includes ~search:"missing-prop" output.stdout) then fail_promise ("missing property name\nstdout:\n" ^ output.stdout) else if not (Js.String.includes ~search:"\"option\":\"--add-properties\"" output.stdout) then fail_promise ("missing add-properties context\nstdout:\n" ^ output.stdout) else if !step = 3 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected three invoke requests, got %d" !step))); test_promise "upsert tag reports missing schema property ident" (fun () -> let step = ref 0 in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"project" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"~u22222222-2222-4222-8222-222222222222\",\"~:block/name\",\"project\",\"~:block/tags\",[[\"^ \ \",\"~:db/ident\",\"~:logseq.class/Tag\"]]]]" | 2 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"user.property/missing-prop" body -> "null" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "tag"; "--name"; "Project"; "--add-properties"; "[:user.property/missing-prop]"; |] in if output.code = 0 then fail_promise ("expected missing tag schema property to fail\n" ^ output.stdout) else if not (Js.String.includes ~search:"property-not-found" output.stdout) then fail_promise ("missing property code\nstdout:\n" ^ output.stdout) else if not (Js.String.includes ~search:"missing-prop" output.stdout) then fail_promise ("missing property ident\nstdout:\n" ^ output.stdout) else if not (Js.String.includes ~search:"\"option\":\"--add-properties\"" output.stdout) then fail_promise ("missing add-properties context\nstdout:\n" ^ output.stdout) else if !step = 2 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected two invoke requests, got %d" !step))); test_promise "upsert page resolves unqualified update property keywords by name" (fun () -> let step = ref 0 in let property_ident = "user.property/node-prop" in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/pull" body && not (Js.String.includes ~search:"node-prop" body) -> "[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"nodehome\"]" | 2 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"node-prop" body -> "null" | 3 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"node-prop" body -> "[[\"^ \ \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/node-prop\",\"~:logseq.property/type\",\"default\"]]" | 4 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:property_ident body) then fail_test ("missing resolved property ident: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "page"; "--id"; "42"; "--update-properties"; "{:node-prop \"v\"}"; |] in ignore (expect_cli_exit_zero "upsert page property by name" output); if !step = 4 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected four invoke requests, got %d" !step))); test_promise "upsert block create resolves inline property names per block" (fun () -> let step = ref 0 in let property_ident = "user.property/inline-note" in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"inline note" body -> "[[\"^ \ \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/inline-note\",\"~:logseq.property/type\",\"default\"]]" | 2 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"home" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]" | 3 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if Js.String.includes ~search:property_ident body then fail_test ("inline property should not be inserted with block: " ^ body); "[]" | 4 when Js.String.includes ~search:"thread-api/pull" body -> "[\"^ \",\"~:db/id\",10]" | 5 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:property_ident body) then fail_test ("missing inline property ident: " ^ body); if not (Js.String.includes ~search:"First note" body) then fail_test ("missing inline property value: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "block"; "--target-page"; "Home"; "--blocks"; "[{:block/title \"First inline\" \"Inline Note\" \"First \ note\"}]"; |] in ignore (expect_cli_exit_zero "upsert block inline property" output); if !step = 5 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected five invoke requests, got %d" !step))); test_promise "upsert block create sends page refs from block title" (fun () -> let step = ref 0 in let ref_uuid = "22222222-2222-4222-8222-222222222222" in let captured_apply_body = ref None in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"home" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\",\"~:block/title\",\"Home\"]]" | 2 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"bar_page" body -> "[[\"^ \",\"~:db/id\",88,\"~:block/uuid\",\"" ^ ref_uuid ^ "\",\"~:block/name\",\"bar_page\",\"~:block/title\",\"bar_page\"]]" | 3 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> captured_apply_body := Some body; "[]" | 4 when Js.String.includes ~search:"thread-api/pull" body -> "[\"^ \",\"~:db/id\",10]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "block"; "--target-page"; "Home"; "--content"; "Foo links to [[bar_page]]"; |] in ignore (expect_cli_exit_zero "upsert block title page refs" output); let body = match !captured_apply_body with | Some body -> body | None -> fail_test "missing captured apply body"; failwith "missing captured apply body" in expect_named_contains "block refs attr" body "block/refs"; expect_named_contains "resolved page ref uuid" body ref_uuid; expect_named_contains "resolved page ref title" body "bar_page"; if !step = 4 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected four invoke requests, got %d" !step))); test_promise "upsert block update resolves tag names from tag list" (fun () -> let step = ref 0 in let captured_apply_body = ref None in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"233" body -> "[\"^ \ \",\"~:db/id\",233,\"~:block/uuid\",\"~u00000000-0000-4000-8000-000000000233\"]" | 2 when Js.String.includes ~search:"thread-api/cli-list-tags" body -> "[[\"^ \",\"~:db/id\",200,\"~:block/title\",\"Tag A\"]]" | 3 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> captured_apply_body := Some body; "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "block"; "--id"; "233"; "--update-tags"; "[\"Tag A\"]"; |] in ignore (expect_cli_exit_zero "upsert block tag name" output); let body = match !captured_apply_body with | Some body -> body | None -> fail_test "missing captured apply body"; failwith "missing captured apply body" in expect_named_contains "batch tag set op" body "batch-set-property"; expect_named_contains "resolved tag id" body "200"; if !step = 3 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected three invoke requests, got %d" !step))); test_promise "upsert block create resolves inline block uuid property refs" (fun () -> let step = ref 0 in let ref_uuid = "22222222-2222-4222-8222-222222222222" in let property_ident = "logseq.property.comments/blocks" in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:ref_uuid body -> "[\"^ \ \",\"~:db/id\",88,\"~:block/uuid\",\"22222222-2222-4222-8222-222222222222\"]" | 2 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"home" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]" | 3 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if Js.String.includes ~search:property_ident body then fail_test ("inline ref property should not be inserted with block: " ^ body); "[]" | 4 when Js.String.includes ~search:"thread-api/pull" body -> "[\"^ \",\"~:db/id\",10]" | 5 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:property_ident body) then fail_test ("missing comments blocks property ident: " ^ body); if not (Js.String.includes ~search:"88" body) then fail_test ("missing resolved block ref id: " ^ body); if Js.String.includes ~search:ref_uuid body then fail_test ("unresolved block uuid leaked into property op: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "block"; "--target-page"; "Home"; "--blocks"; "[{:block/title \"Comment area\" \ :logseq.property.comments/blocks [[:block/uuid #uuid \ \"22222222-2222-4222-8222-222222222222\"]]}]"; |] in ignore (expect_cli_exit_zero "upsert block inline ref property" output); if !step = 5 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected five invoke requests, got %d" !step))); test_promise "upsert block create keeps inline tags out of property ops while resolving \ refs" (fun () -> let step = ref 0 in let ref_uuid = "22222222-2222-4222-8222-222222222222" in let property_ident = "logseq.property.comments/blocks" in let tag_ident = "logseq.class/Comments" in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:ref_uuid body -> "[\"^ \ \",\"~:db/id\",88,\"~:block/uuid\",\"22222222-2222-4222-8222-222222222222\"]" | 2 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"home" body -> "[[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]" | 3 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if Js.String.includes ~search:property_ident body then fail_test ("inline ref property should not be inserted with block: " ^ body); if not (Js.String.includes ~search:tag_ident body) then fail_test ("missing inline tag in inserted block: " ^ body); "[]" | 4 when Js.String.includes ~search:"thread-api/pull" body -> "[\"^ \",\"~:db/id\",10]" | 5 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:property_ident body) then fail_test ("missing comments blocks property ident: " ^ body); if Js.String.includes ~search:tag_ident body then fail_test ("block/tags leaked into inline property op: " ^ body); if not (Js.String.includes ~search:"88" body) then fail_test ("missing resolved block ref id: " ^ body); if Js.String.includes ~search:ref_uuid body then fail_test ("unresolved block uuid leaked into property op: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "block"; "--target-page"; "Home"; "--blocks"; "[{:block/title \"Comment area\" :block/tags \ [:logseq.class/Comments] :logseq.property.comments/blocks \ [[:block/uuid #uuid \ \"22222222-2222-4222-8222-222222222222\"]]}]"; |] in ignore (expect_cli_exit_zero "upsert block inline ref tags" output); if !step = 5 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected five invoke requests, got %d" !step))); test_promise "update block resolves string update property names by title" (fun () -> let step = ref 0 in let property_ident = "user.property/reproducible-steps" in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/pull" body && not (Js.String.includes ~search:"reproducible" body) -> "[\"^ \ \",\"~:db/id\",42,\"~:block/uuid\",\"11111111-1111-1111-1111-111111111111\",\"~:block/title\",\"Alpha \ block\"]" | 2 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"reproducible steps" body -> "[[\"^ \ \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/reproducible-steps\",\"~:logseq.property/type\",\"default\"]]" | 3 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:property_ident body) then fail_test ("missing update property ident: " ^ body); if not (Js.String.includes ~search:"Step 1" body) then fail_test ("missing update property value: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "upsert"; "block"; "--id"; "42"; "--update-properties"; "{\"Reproducible steps\" [\"Step 1\"]}"; |] in ignore (expect_cli_exit_zero "update block property by title" output); if !step = 3 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected three invoke requests, got %d" !step))); test_promise "list node resolves unqualified property filters by name" (fun () -> let step = ref 0 in let property_ident = "user.property/node-prop" in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"node-prop" body -> "[[\"^ \ \",\"~:db/id\",77,\"~:db/ident\",\"~:user.property/node-prop\",\"~:logseq.property/type\",\"default\"]]" | 2 when Js.String.includes ~search:"thread-api/cli-list-nodes" body -> if not (Js.String.includes ~search:property_ident body) then fail_test ("missing list property ident: " ^ body); "[]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "list"; "node"; "--properties"; "node-prop"; |] in ignore (expect_cli_exit_zero "list node property by name" output); if !step = 2 then Js.Promise.resolve pass else fail_promise (Printf.sprintf "expected two invoke requests, got %d" !step))); test_promise "upsert asset copies file and sends asset metadata" (fun () -> let root = temp_dir "logseq-cli-upsert-asset-" in let source_path = Node.Path.join [| root; "logo.png" |] in let expected_checksum = "d59386e0ae435e292fbe0ebcdb954b75ed5fb3922091277cb19f798fc5d50718" in write_file source_path "asset"; let step = ref 0 in let server = invoke_server (fun body -> incr step; match !step with | 1 when Js.String.includes ~search:"thread-api/pull" body && Js.String.includes ~search:"logseq.class/Asset" body -> "[\"^ \",\"~:db/id\",77]" | 2 when Js.String.includes ~search:"thread-api/q" body && Js.String.includes ~search:"home" body -> "[\"~#list\",[[\"^ \ \",\"~:db/id\",1,\"~:block/uuid\",\"~u11111111-1111-1111-1111-111111111111\",\"~:block/name\",\"home\"]]]" | 3 when Js.String.includes ~search:"thread-api/apply-outliner-ops" body -> if not (Js.String.includes ~search:expected_checksum body) then fail_test "missing asset checksum"; if not (Js.String.includes ~search:"logseq.property.asset/type" body && Js.String.includes ~search:"png" body) then fail_test "missing asset type metadata"; "[]" | 4 when Js.String.includes ~search:"thread-api/pull" body -> "[\"^ \",\"~:db/id\",10]" | _ -> fail_test (Printf.sprintf "unexpected request at step %d: %s" !step body); "") in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--root-dir"; root; "--graph"; "alpha"; "--output"; "json"; "upsert"; "asset"; "--path"; source_path; "--target-page"; "Home"; "--content"; "Logo"; |] in let finish_with_error message = remove_tree root; fail_promise message in if output.code <> 0 then finish_with_error (Printf.sprintf "expected CLI upsert asset to exit 0, got %d\n%s\n%s" output.code output.stdout output.stderr) else if !step <> 4 then finish_with_error (Printf.sprintf "expected four invoke requests, got %d" !step) else if not (Js.String.includes ~search:"{\"status\":\"ok\",\"data\":{\"result\":[10]}}" output.stdout) then finish_with_error ("expected success json output\n" ^ output.stdout) else let assets_dir = Node.Path.join [| root; "graphs"; "alpha"; "assets" |] in let copied = if Node.Fs.existsSync assets_dir then Node.Fs.readdirSync assets_dir else [||] in if Array.length copied <> 1 then finish_with_error (Printf.sprintf "expected one copied asset, got %d" (Array.length copied)) else if read_file (Node.Path.join [| assets_dir; copied.(0) |]) <> "asset" then finish_with_error "expected copied asset content" else ( remove_tree root; Js.Promise.resolve pass))); test_promise "show human tree preserves multiline guides and linked references" (fun () -> let responses = [| ( "thread-api/pull", "[\"^ \",\"~:db/id\",16764,\"~:block/title\",\"type system to \ strengthen codebase examples\",\"~:block/name\",\"type system to \ strengthen codebase examples\"]" ); ( "thread-api/q", "[[[\"^ \",\"~:db/id\",16766,\"~:block/title\",\"use type system \ to ensure block validated, graph-name validated, \ etc..\",\"~:block/order\",\"a\",\"~:block/parent\",[\"^ \ \",\"~:db/id\",16764]]],[[\"^ \ \",\"~:db/id\",16767,\"~:block/title\",\"module type Block : \ sig\\ntype raw\\ntype validated\\n\\ntype _ t\\n\\nval of_edn : \ Edn.t -> raw t\\nval validate : raw t -> (validated t, string \ list) result\\n\\nval uuid : validated t -> Uuidm.t\\nval db_id : \ validated t -> Db_id.t\\nval title : validated t -> string \ option\\nval journal_day : validated t -> Journal_day.t \ option\\nend \ #Code\",\"~:block/order\",\"b\",\"~:block/parent\",[\"^ \ \",\"~:db/id\",16766]]],[[\"^ \ \",\"~:db/id\",16768,\"~:block/title\",\"split block into raw & \ validated 2 types, all fns need to operate on blocks only accept \ `validated \ block`\",\"~:block/order\",\"c\",\"~:block/parent\",[\"^ \ \",\"~:db/id\",16764]]],[[\"^ \ \",\"~:db/id\",16769,\"~:block/title\",\"-\",\"~:block/order\",\"d\",\"~:block/parent\",[\"^ \ \",\"~:db/id\",16764]]]]" ); ("thread-api/get-block-refs", "[[\"^ \",\"~:db/id\",16763]]"); ( "thread-api/pull", "[\"^ \",\"~:db/id\",16763,\"~:block/title\",\"[[type system to \ strengthen codebase examples]]\",\"~:block/page\",[\"^ \ \",\"~:db/id\",16760,\"~:block/title\",\"May 31st, \ 2026\",\"~:block/name\",\"may 31st, 2026\"]]" ); |] in let index = ref 0 in let server = invoke_server (fun body -> if !index >= Array.length responses then fail_test ("unexpected extra request: " ^ body); let method_, transit = responses.(!index) in incr index; if not (Js.String.includes ~search:method_ body) then fail_test (Printf.sprintf "expected %s, got %s" method_ body); transit) in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "show"; "--id"; "16764" |] in ignore (expect_cli_exit_zero "show human tree" output); if !index <> Array.length responses then fail_promise (Printf.sprintf "handled %d requests, expected %d" !index (Array.length responses)) else ( ignore (expect_named_not_contains "multiline raw prefix" output.stdout "\ntype raw\n"); ignore (expect_named_contains "multiline tree context" output.stdout ("\n " ^ tree_pipe ^ " type raw\n")); ignore (expect_named_contains "blank multiline tree guide" output.stdout ("\n " ^ tree_pipe ^ "\n " ^ tree_pipe ^ " type _ t\n")); ignore (expect_named_contains "linked reference page context" output.stdout ("Linked References (1)\n16760 May 31st, 2026\n16763 " ^ tree_corner ^ " [[type system to strengthen codebase examples]]")); Js.Promise.resolve pass))); test_promise "show human tree aligns guides when ids have different widths" (fun () -> let responses = [| ( "thread-api/pull", "[\"^ \",\"~:db/id\",1,\"~:block/title\",\"Root\"]" ); ( "thread-api/q", "[[[\"^ \ \",\"~:db/id\",9,\"~:block/title\",\"Short\",\"~:block/order\",\"a\",\"~:block/parent\",[\"^ \ \",\"~:db/id\",1]]],[[\"^ \ \",\"~:db/id\",12345,\"~:block/title\",\"Wide\",\"~:block/order\",\"b\",\"~:block/parent\",[\"^ \ \",\"~:db/id\",1]]],[[\"^ \ \",\"~:db/id\",4395,\"~:block/title\",\"Nested\",\"~:block/order\",\"a\",\"~:block/parent\",[\"^ \ \",\"~:db/id\",9]]]]" ); |] in let index = ref 0 in let server = invoke_server (fun body -> if !index >= Array.length responses then fail_test ("unexpected extra request: " ^ body); let method_, transit = responses.(!index) in incr index; if not (Js.String.includes ~search:method_ body) then fail_test (Printf.sprintf "expected %s, got %s" method_ body); transit) in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "show"; "--id"; "1"; "--linked-references=false"; |] in ignore (expect_cli_exit_zero "show human tree aligned ids" output); if !index <> Array.length responses then fail_promise (Printf.sprintf "handled %d requests, expected %d" !index (Array.length responses)) else ( ignore (expect_named_contains "aligned root" output.stdout "1 Root"); ignore (expect_named_contains "aligned short id branch" output.stdout ("9 " ^ tree_middle ^ " Short")); ignore (expect_named_contains "aligned nested guide" output.stdout ("\n4395 " ^ tree_pipe ^ " " ^ tree_corner ^ " Nested\n")); ignore (expect_named_contains "aligned wide id branch" output.stdout ("12345 " ^ tree_corner ^ " Wide")); Js.Promise.resolve pass))); test_promise "show human output writes unicode text from worker strings" (fun () -> let responses = [| ( "thread-api/pull", "[\"^ \",\"~:db/id\",1,\"~:block/title\",\"\\u4e2d\\u6587\"]" ); ("thread-api/q", "[]"); |] in let index = ref 0 in let server = invoke_server (fun body -> if !index >= Array.length responses then fail_test ("unexpected extra request: " ^ body); let method_, transit = responses.(!index) in incr index; if not (Js.String.includes ~search:method_ body) then fail_test (Printf.sprintf "expected %s, got %s" method_ body); transit) in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "show"; "--id"; "1"; "--linked-references=false"; |] in ignore (expect_cli_exit_zero "show human unicode output" output); if !index <> Array.length responses then fail_promise (Printf.sprintf "handled %d requests, expected %d" !index (Array.length responses)) else ( ignore (expect_named_contains "unicode stdout" output.stdout (unicode_text [| 0x4e2d; 0x6587 |])); ignore (expect_named_not_contains "mojibake stdout" output.stdout "\195\164\194\184\194\173"); Js.Promise.resolve pass))); test_promise "show human properties renders task status and property values" (fun () -> let saw_status_selector = ref false in let saw_wildcard_selector = ref false in let index = ref 0 in let static_transits = [| "[\"^ \ \",\"~:db/id\",1,\"~:block/title\",\"Home\",\"~:block/name\",\"home\"]"; ""; "[\"^ \ \",\"~:db/id\",7,\"~:db/ident\",\"~:user.property/owner\",\"~:block/title\",\"Owner\",\"~:logseq.property/type\",\"~:default\"]"; "[\"^ \ \",\"~:db/id\",8,\"~:db/ident\",\"~:user.property/agent-skills\",\"~:block/title\",\"agent-skills\",\"~:logseq.property/type\",\"~:default\"]"; "[\"^ \ \",\"~:db/id\",16,\"~:db/ident\",\"~:user.property/reproducible-steps\",\"~:block/title\",\"Reproducible \ steps\",\"~:logseq.property/type\",\"~:default\"]"; "[\"^ \ \",\"~:db/id\",17,\"~:db/ident\",\"~:user.property/empty-list\",\"~:block/title\",\"empty-list\",\"~:logseq.property/type\",\"~:default\"]"; "[\"^ \ \",\"~:db/id\",15,\"~:db/ident\",\"~:logseq.property/assignee\",\"~:block/title\",\"Assignee\",\"~:logseq.property/type\",\"~:default\"]"; "[\"^ \ \",\"~:db/id\",19,\"~:db/ident\",\"~:logseq.property/reviewer\",\"~:block/title\",\"Reviewer\",\"~:logseq.property/type\",\"~:default\"]"; "[\"^ \",\"~:db/id\",9,\"~:block/title\",\"Alice\"]"; "[\"^ \",\"~:db/id\",10,\"~:block/title\",\"logseq-answer-machine\"]"; "[\"^ \",\"~:db/id\",11,\"~:logseq.property/value\",\"Type `[[assig` \ and only see [[Assignee(legacy)]]\"]"; "[\"^ \",\"~:db/id\",12,\"~:logseq.property/value\",\"Expect to see \ two entries\"]"; "[\"^ \",\"~:db/id\",13,\"~:logseq.property/value\",\"Expected to \ see the built-in property\"]"; "[\"^ \",\"~:db/id\",14,\"~:block/title\",\"czys-Mac-Studio.local\"]"; "[\"^ \",\"~:db/id\",18,\"~:block/title\",\"general-reviewer.local\"]"; |] in let expected_methods = [| "thread-api/pull"; "thread-api/q"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; "thread-api/pull"; |] in let q_transit body = saw_status_selector := Js.String.includes ~search:"~:logseq.property/status" body; saw_wildcard_selector := Js.String.includes ~search:"~$*" body; let child_fields = ref (Vec.of_array [| "\"~:db/id\",2"; "\"~:block/title\",\"Ship CLI\""; "\"~:block/order\",\"a\""; "\"~:block/parent\",[\"^ \",\"~:db/id\",1]"; "\"~:block/tags\",[[\"^ \ \",\"~:db/ident\",\"~:logseq.class/Task\",\"~:block/title\",\"Task\"]]"; |]) in if !saw_status_selector then child_fields := Vec.append !child_fields (Vec.of_array [| "\"~:logseq.property/status\",[\"^ \ \",\"~:db/ident\",\"~:logseq.property/status.todo\",\"~:block/title\",\"Todo\"]"; |]); if !saw_wildcard_selector then child_fields := Vec.append !child_fields (Vec.of_array [| "\"~:user.property/owner\",[\"~:db/id\",9]"; "\"~:user.property/agent-skills\",[[ \"~:db/id\",10 ]]"; "\"~:user.property/reproducible-steps\",[\"~:db/id\",11,[\"~:db/id\",12],[\"~:db/id\",13]]"; "\"~:user.property/empty-list\",[]"; "\"~:logseq.property/assignee\",[[\"~:db/id\",14]]"; "\"~:logseq.property/reviewer\",[[\"~:db/id\",18]]"; |]); "[[[\"^ \"," ^ Vec.string_concat "," !child_fields ^ "]]]" in let server = invoke_server (fun body -> if !index >= Array.length expected_methods then fail_test ("unexpected extra request: " ^ body); let expected_method = expected_methods.(!index) in if not (Js.String.includes ~search:expected_method body) then fail_test (Printf.sprintf "expected %s, got %s" expected_method body); let current = !index in incr index; if current = 1 then q_transit body else static_transits.(current)) in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "show"; "--page"; "home"; "--linked-references=false"; |] in ignore (expect_cli_exit_zero "show human properties" output); if not !saw_status_selector then fail_promise "show tree selector did not request task status" else if not !saw_wildcard_selector then fail_promise "show tree selector did not request custom properties" else if !index <> Array.length expected_methods then fail_promise (Printf.sprintf "handled %d requests, expected %d" !index (Array.length expected_methods)) else ( ignore (expect_named_contains "task status" output.stdout ("2 " ^ tree_corner ^ " Todo Ship CLI #Task")); ignore (expect_named_contains "owner property" output.stdout " Owner: Alice"); ignore (expect_named_contains "single cardinality-many property" output.stdout "agent-skills: logseq-answer-machine"); ignore (expect_named_contains "multi-value property bullets" output.stdout "Reproducible steps:\n\ \ - Type `[[assig` and only see [[Assignee(legacy)]]\n\ \ - Expect to see two entries\n\ \ - Expected to see the built-in property"); ignore (expect_named_not_contains "empty list property" output.stdout "empty-list"); ignore (expect_named_contains "assignee property" output.stdout "Assignee: czys-Mac-Studio.local"); ignore (expect_named_contains "reviewer property" output.stdout "Reviewer: general-reviewer.local"); ignore (expect_named_not_contains "db id leak" output.stdout ":db/id"); Js.Promise.resolve pass))); let sync_graph_id = "11111111-1111-1111-1111-111111111111" in let sync_remote_graph_transit = "[[\"^ \",\"~:graph-name\",\"alpha\",\"~:graph-id\",\"~u" ^ sync_graph_id ^ "\",\"~:graph-e2ee?\",false]]" in let sync_invoke_response download_requests body = if Js.String.includes ~search:"thread-api/set-db-sync-config" body then Some "true" else if Js.String.includes ~search:"thread-api/db-sync-list-remote-graphs" body then Some sync_remote_graph_transit else if Js.String.includes ~search:"thread-api/q" body then Some "0" else if Js.String.includes ~search:"thread-api/db-sync-download-graph-by-id" body then ( incr download_requests; Some "true") else ( fail_test ("unexpected invoke body: " ^ body); None) in test_promise "invoke response decodes JSON escaped resultTransit" (fun () -> 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] () -> if req_method req <> "POST" || req_url req <> "/v1/invoke" then write_json res 404 (error_response "not found") else if not (Js.String.includes ~search:"thread-api/cli-list-pages" !body) then write_json res 400 (error_response "unexpected request") else write_json res 200 "{\"resultTransit\":\"\\u005b\\u005d\"}")) in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--graph"; "alpha"; "--output"; "json"; "list"; "page" |] in ignore (expect_cli_exit_zero "json escaped resultTransit" output); if Js.String.includes ~search:"{\"status\":\"ok\",\"data\":{\"items\":[]}}" output.stdout then Js.Promise.resolve pass else fail_promise ("expected empty list result\n" ^ output.stdout))); let run_sync_progress_case name root_prefix handle_events ?(delay_download_response = false) ?(delay_events_response = false) assert_progress = test_promise name (fun () -> let root = temp_dir root_prefix in let events_requests = ref 0 in let download_requests = ref 0 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] () -> if req_method req = "GET" && req_url req = "/v1/events" then ( incr events_requests; if delay_events_response then set_timeout (fun () -> handle_events res) 100 else handle_events res) else if req_method req = "POST" && req_url req = "/v1/invoke" then try match sync_invoke_response download_requests !body with | Some result_transit when delay_download_response && Js.String.includes ~search: "thread-api/db-sync-download-graph-by-id" !body -> set_timeout (fun () -> write_json res 200 (json_response result_transit)) 100 | Some result_transit -> write_json res 200 (json_response result_transit) | None -> () with exn -> write_json res 400 (error_response (Printexc.to_string exn)) else write_json res 404 (error_response "not found"))) in with_server server (fun base_url -> let* output = run_cli_p ~env:[| ("LOGSEQ_CLI_BASE_URL", base_url) |] [| "--root-dir"; root; "--graph"; "alpha"; "--output"; "json"; "sync"; "download"; "--progress"; |] in remove_tree root; ignore (expect_cli_exit_zero name output); if !events_requests <> 1 then fail_promise (Printf.sprintf "expected one events request, got %d" !events_requests) else if !download_requests <> 1 then fail_promise (Printf.sprintf "expected one download request, got %d" !download_requests) else if not (Js.String.includes ~search:"{\"status\":\"ok\",\"data\":{\"result\":true}}" output.stdout) then fail_promise ("expected success json output\n" ^ output.stdout) else ( assert_progress output.stdout; Js.Promise.resolve pass))) in run_sync_progress_case "sync download prints progress from closed event stream" "logseq-cli-sync-progress-" ~delay_download_response:true (fun res -> res_write_head res 200 (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); res_end res ("data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \ \\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u" ^ sync_graph_id ^ "\\\",\\\"~:message\\\",\\\"downloaded 1 block\\\"]]\"}\n\n")) (fun stdout -> ignore (expect_named_contains "progress output" stdout "downloaded 1 block")); run_sync_progress_case "sync download decodes JSON escaped progress payload" "logseq-cli-sync-progress-escaped-" ~delay_events_response:true (fun res -> let payload = "[\"~:rtc-log\",[\"^ \ \",\"~:type\",\"~:rtc.log/download\",\"~:graph-uuid\",\"~u" ^ sync_graph_id ^ "\",\"~:message\",\"downloaded escaped payload\"]]" in let payload_json = Js.Json.stringify (Js.Json.string payload) in let escaped_payload_json = "\"\\u005b" ^ String.sub payload_json 2 (String.length payload_json - 2) in res_write_head res 200 (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); res_end res ("data: {\"payload\":" ^ escaped_payload_json ^ "}\n\n")) (fun stdout -> ignore (expect_named_contains "escaped progress output" stdout "downloaded escaped payload")); run_sync_progress_case "sync download finishes while event stream stays open" "logseq-cli-sync-progress-stream-" ~delay_download_response:true (fun res -> res_write_head res 200 (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); let large_sync_payload = "[\"~:sync-db-changes\",[\"^ \",\"~:pages\"," ^ "[" ^ Vec.string_concat "," (Vec.init 12000 (fun i -> Printf.sprintf "[\"^ \",\"~:db/id\",%d,\"~:block/title\",\"block %d\"]" i i)) ^ "]]]" in res_write res ("data: " ^ "{\"type\":\"sync-db-changes\",\"payload\":" ^ Js.Json.stringify (Js.Json.string large_sync_payload) ^ "}\n\n"); res_write res ("data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \ \\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u" ^ sync_graph_id ^ "\\\",\\\"~:message\\\",\\\"downloaded streaming block\\\"]]\"}\n\n")) (fun stdout -> ignore (expect_named_contains "streaming progress output" stdout "downloaded streaming block")); run_sync_progress_case "sync download survives event stream disconnect" "logseq-cli-sync-progress-disconnect-" ~delay_download_response:true (fun res -> res_write_head res 200 (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); res_write res ("data: {\"payload\":\"[\\\"~:rtc-log\\\",[\\\"^ \ \\\",\\\"~:type\\\",\\\"~:rtc.log/download\\\",\\\"~:graph-uuid\\\",\\\"~u" ^ sync_graph_id ^ "\\\",\\\"~:message\\\",\\\"download started\\\"]]\"}\n\n"); set_timeout (fun () -> res_destroy res) 20) (fun _stdout -> ()); test_promise "sync upload without password does not require stored e2ee password" (fun () -> let root = temp_dir "logseq-cli-sync-upload-no-password-" 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/get-e2ee-password" body then fail_test ("sync upload unexpectedly requested password: " ^ body); if Js.String.includes ~search:"thread-api/db-sync-upload-graph" body then "{\"~:graph-id\":\"graph-1\"}" else "null") in with_server server (fun base_url -> let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in let* result = run_cli_p ~env [| "--root-dir"; root; "--graph"; "alpha"; "--output"; "json"; "sync"; "upload"; |] in remove_tree root; ignore (expect_cli_exit_zero "sync upload no password" result); if Vec.exists (Js.String.includes ~search:"thread-api/db-sync-upload-graph") !invoke_calls then Js.Promise.resolve pass else fail_promise ("missing db-sync upload call\n" ^ result.stdout))); test_promise "sync upload with password initializes e2ee keys before verifying password" (fun () -> let root = temp_dir "logseq-cli-sync-upload-password-keys-" in let config_path = Node.Path.join [| root; "cli.edn" |] in let invoke_calls = ref Vec.empty in let method_names = ref Vec.empty in let record_method body method_name = if Js.String.includes ~search:method_name body then method_names := Vec.push_back !method_names method_name in let server = invoke_server (fun body -> invoke_calls := Vec.push_front !invoke_calls body; record_method body "thread-api/db-sync-ensure-user-rsa-keys"; record_method body "thread-api/verify-and-save-e2ee-password"; record_method body "thread-api/db-sync-upload-graph"; if Js.String.includes ~search:"thread-api/db-sync-upload-graph" body then "{\"~:graph-id\":\"graph-1\"}" else "null") in with_server server (fun base_url -> write_file config_path "{:refresh-token \"refresh-token\"\n :id-token \"id-token\"}\n"; let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in let* result = run_cli_p ~env [| "--root-dir"; root; "--graph"; "alpha"; "--output"; "json"; "sync"; "upload"; "--e2ee-password"; "pw"; |] in remove_tree root; ignore (expect_cli_exit_zero "sync upload password" result); if Vec.equal String.equal !method_names (Vec.of_array [| "thread-api/db-sync-ensure-user-rsa-keys"; "thread-api/verify-and-save-e2ee-password"; "thread-api/db-sync-upload-graph"; |]) then if Vec.exists (fun body -> Js.String.includes ~search:"thread-api/db-sync-ensure-user-rsa-keys" body && Js.String.includes ~search:"ensure-server" body && Js.String.includes ~search:"pw" body) !invoke_calls then Js.Promise.resolve pass else fail_promise "missing ensure-keys password options" else fail_promise ("unexpected sync upload method order: " ^ Vec.string_concat " -> " !method_names))); test_promise "sync ensure-keys with password verifies password before ensuring local \ keys" (fun () -> let root = temp_dir "logseq-cli-sync-ensure-keys-password-" in let config_path = Node.Path.join [| root; "cli.edn" |] in let method_names = ref Vec.empty in let record_method body method_name = if Js.String.includes ~search:method_name body then method_names := Vec.push_back !method_names method_name in let server = invoke_server (fun body -> record_method body "thread-api/verify-and-save-e2ee-password"; record_method body "thread-api/db-sync-ensure-user-rsa-keys"; "null") in with_server server (fun base_url -> write_file config_path "{:refresh-token \"refresh-token\"\n :id-token \"id-token\"}\n"; let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in let* result = run_cli_p ~env [| "--root-dir"; root; "--output"; "json"; "sync"; "ensure-keys"; "--e2ee-password"; "pw"; |] in remove_tree root; ignore (expect_cli_exit_zero "sync ensure-keys password" result); if Vec.equal String.equal !method_names (Vec.of_array [| "thread-api/verify-and-save-e2ee-password"; "thread-api/db-sync-ensure-user-rsa-keys"; |]) then Js.Promise.resolve pass else fail_promise ("unexpected sync ensure-keys method order: " ^ Vec.string_concat " -> " !method_names))); test_promise "sync ensure-keys resolves auth file before uploading keys" (fun () -> let root = temp_dir "logseq-cli-sync-ensure-keys-auth-" in let auth_path = Node.Path.join [| root; "auth.json" |] in let config_path = Node.Path.join [| root; "cli.edn" |] in let invoke_calls = ref Vec.empty in let server = invoke_server (fun body -> invoke_calls := Vec.push_front !invoke_calls body; "null") in with_server server (fun base_url -> write_file auth_path "{\"provider\":\"cognito\",\"id-token\":\"id-token\",\"access-token\":\"access-token\",\"refresh-token\":\"refresh-token\",\"expires-at\":4102444800000}\n"; write_file config_path (Printf.sprintf "{:auth-path %S}\n" auth_path); let env = [| ("LOGSEQ_CLI_BASE_URL", base_url) |] in let* result = run_cli_p ~env [| "--root-dir"; root; "--output"; "json"; "sync"; "ensure-keys"; "--e2ee-password"; "pw"; "--upload-keys"; |] in remove_tree root; ignore (expect_cli_exit_zero "sync ensure-keys auth" result); if Vec.exists (fun body -> Js.String.includes ~search:"thread-api/sync-app-state" body && Js.String.includes ~search:"auth/id-token" body && Js.String.includes ~search:"id-token" body) !invoke_calls then Js.Promise.resolve pass else fail_promise "missing sync-app-state auth payload")); test_promise "agent bridge keeps codex alive and forwards worker env" (fun () -> let tmp = temp_dir "logseq-cli-agent-" in let config_path = Node.Path.join [| tmp; "cli.edn" |] in let codex_bin = Node.Path.join [| tmp; "fake-codex" |] in let marker_path = Node.Path.join [| tmp; "codex-marker.txt" |] in let worker_script = "/tmp/logseq-cli-test-db-worker-node.js" in let agent_name = "agent-a" ^ control_char ^ "lock" in let fake_codex_js = Node.Path.join [| current_dirname; "fake_codex.js" |] in write_file codex_bin ("#!/bin/sh\nexec " ^ Node.Process.argv.(0) ^ " " ^ fake_codex_js ^ " \"$@\"\n"); chmod_sync codex_bin 0o755; write_file config_path (edn_map [ (edn_keyword "agent-name", edn_string agent_name); (edn_keyword "codex-bin", edn_string codex_bin); ]); let registry_page = "{\"~:db/id\":100,\"~:block/uuid\":\"~uaaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"~:block/name\":\"agentbridge\",\"~:block/title\":\"AgentBridge\"}" in let agent_page = "{\"~:db/id\":101,\"~:block/uuid\":\"~ubbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\",\"~:block/name\":\"agent-a\",\"~:block/title\":\"agent-a\"}" in let master_block = "{\"~:db/id\":110,\"~:block/uuid\":\"~ucccccccc-cccc-4ccc-8ccc-cccccccccccc\",\"~:block/title\":\"AgentBridge \ master \ prompt\",\"~:block/order\":1,\"~:block/_parent\":[{\"~:db/id\":111,\"~:block/title\":\"Graph \ master \ prompt\",\"~:block/order\":1,\"~:block/tags\":[{\"~:db/ident\":\"~:logseq.class/Code-block\"}]}]}" in let responses = [| registry_page; agent_page; agent_page; master_block; registry_page; ""; ""; |] in let post_index = ref 0 in let event_response = ref None 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] () -> if req_method req = "GET" && req_url req = "/v1/events" then ( res_write_head res 200 (Js.Dict.fromArray [| ("Content-Type", "text/event-stream") |]); res_write res "\n"; event_response := Some res) else if req_method req <> "POST" || req_url req <> "/v1/invoke" then write_json res 400 (error_response ("unexpected " ^ req_method req ^ " " ^ req_url req)) else if !post_index >= Array.length responses then write_json res 400 (error_response ("unexpected extra request: " ^ !body)) else let transit = responses.(!post_index) in incr post_index; let result_transit = if transit = "" then "[]" else "[" ^ transit ^ "]" in write_json res 200 (json_response result_transit))) in with_server server (fun base_url -> Js.Promise.make (fun ~resolve ~reject -> let child = spawn_cli_async ~env: [| ("LOGSEQ_CLI_BASE_URL", base_url); ("LOGSEQ_DB_WORKER_NODE_SCRIPT", worker_script); ("AGENT_BRIDGE_TEST_MARKER", marker_path); |] [| "--root-dir"; tmp; "--config"; config_path; "--graph"; "alpha"; "agent"; "bridge"; |] in let stdout = ref "" in let stderr = ref "" in let finished = ref false in stream_set_encoding child##stdout "utf8"; stream_set_encoding child##stderr "utf8"; stream_on_data child##stdout "data" (fun[@u] chunk -> stdout := !stdout ^ chunk); stream_on_data child##stderr "data" (fun[@u] chunk -> stderr := !stderr ^ chunk); child_on_error child "error" (fun[@u] exn -> (reject exn [@u])); let cleanup_and_resolve value = if not !finished then ( finished := true; child_kill child "SIGTERM"; Option.iter (fun res -> res_end res "") !event_response; remove_tree tmp; resolve value [@u]) in let cleanup_and_fail message = if not !finished then ( finished := true; child_kill child "SIGTERM"; Option.iter (fun res -> res_end res "") !event_response; remove_tree tmp; reject_with_assertion reject message) in let remaining_attempts = ref 50 in let interval = ref None in let tick () = if Node.Fs.existsSync marker_path then ( Option.iter clear_interval !interval; let marker = read_file marker_path in let owner_path = Node.Path.join [| tmp; "agent-bridge-locks"; encode_uri_component "alpha" ^ "--" ^ encode_uri_component agent_name ^ ".lock"; "owner.edn"; |] in if marker <> worker_script then cleanup_and_fail ("worker script env was not forwarded: " ^ Js.Json.stringify (Js.Json.string marker)) else if not (Node.Fs.existsSync owner_path) then cleanup_and_fail ("missing agent bridge owner: " ^ owner_path) else ( ignore (expect_valid_edn "agent bridge owner" (read_file owner_path)); if not (Js.String.includes ~search: "Codex master session started: cli-live-session" !stdout) then cleanup_and_fail (Printf.sprintf "master session log missing:\n\ stdout:\n\ %s\n\ stderr:\n\ %s" !stdout !stderr) else cleanup_and_resolve pass)) else ( decr remaining_attempts; if !remaining_attempts <= 0 then ( Option.iter clear_interval !interval; cleanup_and_fail (Printf.sprintf "fake codex did not stay alive long enough to write \ marker\n\ stdout:\n\ %s\n\ stderr:\n\ %s" !stdout !stderr))) in interval := Some (set_interval (fun () -> tick ()) 100); child_on_exit child "exit" (fun[@u] code -> if (not !finished) && not (Node.Fs.existsSync marker_path) then ( Option.iter clear_interval !interval; cleanup_and_fail (Printf.sprintf "agent bridge exited before fake codex marker; code=%d\n\ stdout:\n\ %s\n\ stderr:\n\ %s" code !stdout !stderr)))))); test_promise "fetch timeout exits non-zero with timeout message" (fun () -> Js.Promise.make (fun ~resolve ~reject -> let request_count = ref 0 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] () -> incr request_count; if not (Js.String.includes ~search:"thread-api/cli-list-pages" !body) then write_json res 400 (error_response "unexpected request") else set_timeout (fun () -> write_json res 200 (json_response "[]")) 500)) in server_listen server 0 "127.0.0.1" (fun[@u] () -> let port = (server_address server)##port in let child = spawn_cli_async ~env: [| ( "LOGSEQ_CLI_BASE_URL", Printf.sprintf "http://127.0.0.1:%d" port ); |] [| "--timeout-ms"; "100"; "--graph"; "alpha"; "--output"; "json"; "list"; "page"; |] in let stdout = ref "" in let stderr = ref "" in stream_set_encoding child##stdout "utf8"; stream_set_encoding child##stderr "utf8"; stream_on_data child##stdout "data" (fun[@u] chunk -> stdout := !stdout ^ chunk); stream_on_data child##stderr "data" (fun[@u] chunk -> stderr := !stderr ^ chunk); child_on_error child "error" (fun[@u] exn -> (reject exn [@u])); child_on_exit child "exit" (fun[@u] code -> server_close server (fun[@u] () -> if !request_count <> 1 then reject_with_assertion reject (Printf.sprintf "expected one request, got %d\n%s\n%s" !request_count !stdout !stderr) else if code = 0 then reject_with_assertion reject ("expected CLI list page to time out\n" ^ !stdout) else if String.contains !stdout 'r' && Js.String.includes ~search:"request timeout" !stdout || String.contains !stderr 'r' && Js.String.includes ~search:"request timeout" !stderr then resolve pass [@u] else reject_with_assertion reject (Printf.sprintf "expected timeout message\n%s\n%s" !stdout !stderr)))))) let registered = ()