diff --git a/.gitignore b/.gitignore index 485ed63f81..96bf53815d 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,5 @@ deps/db-sync/data /dist/*.wasm /dist/cljs-runtime/ /.agent-shell/ + +clj-e2e/.clj-kondo/imports \ No newline at end of file diff --git a/cli-e2e/AGENTS.md b/cli-e2e/AGENTS.md index c3ca7fa89b..50aef563ba 100644 --- a/cli-e2e/AGENTS.md +++ b/cli-e2e/AGENTS.md @@ -18,11 +18,13 @@ Shell-first end-to-end tests for logseq CLI. ## Run sync suite - List declared sync case ids: `bb list-sync-cases` -- Run sync cases with build preflight unless `--skip-build` is provided: `bb test-sync` +- Run sync cases with build preflight by default: `bb test-sync` + - Add `--skip-build` only when you intentionally want to reuse existing artifacts - `bb test-sync --help` for options - - `--jobs` is accepted for CLI consistency but sync cases still run serially + - `--jobs` is accepted for CLI consistency only; sync cases still run serially + - Do not expect `bb test-sync --jobs N` to enable parallel execution - Configure sync E2EE password: `--e2ee-password ` (default: `11111`) - - Run only sync MVP case: `bb test-sync --skip-build --case sync-upload-download-mvp` + - Run only sync MVP case: `bb test-sync --case sync-upload-download-mvp` ### Sync suite prerequisites - CLI auth file must exist at `~/logseq/auth.json` (generated by `logseq login`). diff --git a/cli-e2e/scripts/compare_graph_queries.py b/cli-e2e/scripts/compare_graph_queries.py index 183c7001c2..5bc3677d30 100644 --- a/cli-e2e/scripts/compare_graph_queries.py +++ b/cli-e2e/scripts/compare_graph_queries.py @@ -94,60 +94,84 @@ def run_query(cli_path: Path, config_path: Path, data_dir: Path, graph: str, que } -def main() -> None: +def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Compare normalized query payloads between two cli contexts") parser.add_argument("--cli", required=True, help="Path to static/logseq-cli.js") parser.add_argument("--graph", required=True) - parser.add_argument("--query", required=True) + parser.add_argument("--query", required=True, action="append") parser.add_argument("--config-a", required=True) parser.add_argument("--data-dir-a", required=True) parser.add_argument("--config-b", required=True) parser.add_argument("--data-dir-b", required=True) parser.add_argument("--require-result", action="store_true") - args = parser.parse_args() + return parser.parse_args() + + + +def main() -> None: + args = parse_args() cli_path = Path(args.cli).expanduser().resolve() if not cli_path.exists(): fail("cli entry file does not exist", cli=str(cli_path)) - left = run_query( - cli_path, - Path(args.config_a).expanduser().resolve(), - Path(args.data_dir_a).expanduser().resolve(), - args.graph, - args.query, - ) - right = run_query( - cli_path, - Path(args.config_b).expanduser().resolve(), - Path(args.data_dir_b).expanduser().resolve(), - args.graph, - args.query, - ) + queries = args.query + left_config = Path(args.config_a).expanduser().resolve() + left_data_dir = Path(args.data_dir_a).expanduser().resolve() + right_config = Path(args.config_b).expanduser().resolve() + right_data_dir = Path(args.data_dir_b).expanduser().resolve() - left_result = left["result"] - right_result = right["result"] + normalized_results = {} - if args.require_result and (left_result is None or right_result is None): - fail("query result is empty", left_result=left_result, right_result=right_result) - - left_normalized = normalize(left_result) - right_normalized = normalize(right_result) - - if left_normalized != right_normalized: - fail( - "normalized query results differ", - left_result=left_normalized, - right_result=right_normalized, - left_payload=left["payload"], - right_payload=right["payload"], + for query in queries: + left = run_query( + cli_path, + left_config, + left_data_dir, + args.graph, + query, + ) + right = run_query( + cli_path, + right_config, + right_data_dir, + args.graph, + query, ) + left_result = left["result"] + right_result = right["result"] + + if args.require_result and (left_result is None or right_result is None): + fail( + "query result is empty", + query=query, + left_result=left_result, + right_result=right_result, + ) + + left_normalized = normalize(left_result) + right_normalized = normalize(right_result) + + if left_normalized != right_normalized: + fail( + "normalized query results differ", + query=query, + left_result=left_normalized, + right_result=right_normalized, + left_payload=left["payload"], + right_payload=right["payload"], + ) + + normalized_results[query] = left_normalized + + payload_key = "result" if len(normalized_results) == 1 else "results" + payload_value = next(iter(normalized_results.values())) if len(normalized_results) == 1 else normalized_results print( json.dumps( { "status": "ok", - "result": left_normalized, + payload_key: payload_value, } ) ) diff --git a/cli-e2e/scripts/random_bidirectional_block_ops.py b/cli-e2e/scripts/random_bidirectional_block_ops.py index fabf203e24..c0cc772f2a 100644 --- a/cli-e2e/scripts/random_bidirectional_block_ops.py +++ b/cli-e2e/scripts/random_bidirectional_block_ops.py @@ -364,6 +364,13 @@ def ensure_non_empty_page( ) +PROFILE_DEFAULT_ROUNDS = { + "default": 40, + "high-stress": 100, +} + + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Run randomized bidirectional block operations on two synced graph clients" @@ -375,9 +382,18 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--config-b", required=True) parser.add_argument("--data-dir-b", required=True) parser.add_argument("--page", required=True) - parser.add_argument("--rounds-per-client", type=int, default=100) + parser.add_argument( + "--profile", + choices=sorted(PROFILE_DEFAULT_ROUNDS.keys()), + default="default", + help="Execution profile controlling default stress level", + ) + parser.add_argument("--rounds-per-client", type=int, default=None) parser.add_argument("--seed", type=int, default=424242) - return parser.parse_args() + args = parser.parse_args() + if args.rounds_per_client is None: + args.rounds_per_client = PROFILE_DEFAULT_ROUNDS[args.profile] + return args def main() -> None: diff --git a/cli-e2e/scripts/wait_sync_status.py b/cli-e2e/scripts/wait_sync_status.py index 84bedb402f..d7ed1ad4eb 100644 --- a/cli-e2e/scripts/wait_sync_status.py +++ b/cli-e2e/scripts/wait_sync_status.py @@ -33,7 +33,11 @@ def parse_int(value: Any) -> int: return 0 -def run_status(args: argparse.Namespace) -> Dict[str, Any]: +def status_command(args: argparse.Namespace) -> list[str]: + existing = getattr(args, "status_command", None) + if existing is not None: + return existing + command = [ "node", str(Path(args.cli).expanduser().resolve()), @@ -48,6 +52,13 @@ def run_status(args: argparse.Namespace) -> Dict[str, Any]: "--graph", args.graph, ] + args.status_command = command + return command + + + +def run_status(args: argparse.Namespace) -> Dict[str, Any]: + command = status_command(args) result = subprocess.run(command, capture_output=True, text=True) if result.returncode != 0: diff --git a/cli-e2e/spec/sync_cases.edn b/cli-e2e/spec/sync_cases.edn index 211f028408..6be06ba31b 100644 --- a/cli-e2e/spec/sync_cases.edn +++ b/cli-e2e/spec/sync_cases.edn @@ -94,30 +94,28 @@ :cmds ["{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncMultiBatchOne >/dev/null" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncMultiBatchOne --content '{{batch-marker-1}}' >/dev/null" - "sleep 1" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync upload --graph {{graph-arg}}" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 120 --interval-s 1" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncMultiBatchTwo >/dev/null" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncMultiBatchTwo --content '{{batch-marker-2}}' >/dev/null" - "sleep 1" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync upload --graph {{graph-arg}}" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 120 --interval-s 1" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page SyncMultiBatchThree >/dev/null" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncMultiBatchThree --content '{{batch-marker-3}}' >/dev/null" - "sleep 1" + "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" "{{cli}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json server stop --graph {{graph-arg}}" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync upload --graph {{graph-arg}}" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 120 --interval-s 1" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync upload --graph {{graph-arg}}" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 120 --interval-s 1" - "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-1}}\")] ]' --require-result" - "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-2}}\")] ]' --require-result" - "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-3}}\")] ]' --require-result" + "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-1}}\")] ]' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-2}}\")] ]' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{batch-marker-3}}\")] ]' --require-result" "{{cli-home}} --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], :id "sync-multi-batch-operations", :graph "sync-e2e-multi-batch-operations", @@ -133,7 +131,6 @@ "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert block --graph {{graph-arg}} --target-page SyncSteadyStateHome --content '{{marker-content}}' >/dev/null"], :cmds ["python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{marker-content}}\")] ]' --require-result" - "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 30 --interval-s 1" "{{cli-home}} --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync status --graph {{graph-arg}}"], :id "sync-status-steady-state", @@ -144,7 +141,7 @@ :setup ["{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json upsert page --graph {{graph-arg}} --page '{{random-page}}' >/dev/null"], :cmds - ["python3 '{{repo-root}}/cli-e2e/scripts/random_bidirectional_block_ops.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --page '{{random-page}}' --rounds-per-client {{rounds-per-client}} --seed {{random-seed}}" + ["python3 '{{repo-root}}/cli-e2e/scripts/random_bidirectional_block_ops.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --page '{{random-page}}' --profile default --rounds-per-client {{rounds-per-client}} --seed {{random-seed}}" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{data-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 180 --interval-s 1 --min-tx-delta 1 --baseline-tx 0" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --graph '{{graph}}' --timeout-s 180 --interval-s 1 --min-tx-delta 1 --baseline-tx 0" "python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --query '[:find (pull ?b [:block/uuid :block/title :block/order {:block/parent [:block/uuid]}]) :where [?p :block/title \"{{random-page}}\"] [?b :block/page ?p] [?b :block/uuid]]' --require-result" @@ -154,7 +151,7 @@ :vars {:random-seed "424242", :random-page "SyncRandomOpsHome", - :rounds-per-client "100"}} + :rounds-per-client "40"}} {:tags [:stress :offline :bidirectional :random :block-ops], :extends :sync/common, :setup @@ -164,7 +161,7 @@ ["python3 '{{repo-root}}/cli-e2e/scripts/compare_graph_queries.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --query '[:find ?title :where [?b :block/title ?title] [(= ?title \"{{seed-marker}}\")] ]' --require-result" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync stop --graph {{graph-arg}}" "{{cli-home}} --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync stop --graph {{graph-arg}}" - "python3 '{{repo-root}}/cli-e2e/scripts/random_bidirectional_block_ops.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --page '{{random-page}}' --rounds-per-client {{rounds-per-client}} --seed {{random-seed}}" + "python3 '{{repo-root}}/cli-e2e/scripts/random_bidirectional_block_ops.py' --cli '{{repo-root}}/static/logseq-cli.js' --graph '{{graph}}' --config-a '{{config-path}}' --data-dir-a '{{data-dir}}' --config-b '{{tmp-dir}}/cli-b.edn' --data-dir-b '{{tmp-dir}}/graphs-b' --page '{{random-page}}' --profile high-stress --rounds-per-client {{rounds-per-client}} --seed {{random-seed}}" "{{cli-home}} --data-dir {{data-dir-arg}} --config {{config-path-arg}} --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" "{{cli-home}} --data-dir '{{tmp-dir}}/graphs-b' --config '{{tmp-dir}}/cli-b.edn' --output json sync start --graph {{graph-arg}} --e2ee-password {{e2ee-password-arg}}" "python3 '{{repo-root}}/cli-e2e/scripts/wait_sync_status.py' --cli '{{repo-root}}/static/logseq-cli.js' --data-dir '{{data-dir}}' --config '{{config-path}}' --graph '{{graph}}' --timeout-s 240 --interval-s 1 --min-tx-delta 1 --baseline-tx 0" @@ -177,4 +174,4 @@ {:seed-marker "sync-offline-random-seed-marker", :random-seed "989898", :random-page "SyncOfflineRandomOpsHome", - :rounds-per-client "100"}}]} + :rounds-per-client "40"}}]} diff --git a/cli-e2e/src/logseq/cli/e2e/main.clj b/cli-e2e/src/logseq/cli/e2e/main.clj index d0a6a77d6f..4c0b50f8cc 100644 --- a/cli-e2e/src/logseq/cli/e2e/main.clj +++ b/cli-e2e/src/logseq/cli/e2e/main.clj @@ -173,7 +173,7 @@ :targeted-run? targeted-run?})) (let [suite-context (when sync-suite? (sync-fixture/before-suite! {:run-command run-command})) - sync-context (if sync-suite? + sync-context (if suite-context (assoc suite-context :e2ee-password (:e2ee-password opts)) suite-context) run-case* (if sync-suite? @@ -326,19 +326,28 @@ (println " --skip-build Skip build preflight steps") (println " -i, --include TAG Run only cases with matching tag (repeatable)") (println " --case ID Run a single case by id") - (println (format " --jobs N Run up to N non-sync cases in parallel (Default: %d)" default-cli-jobs)) + (println (format " --jobs N %s (Default: %d)" + (if sync-suite? + "Accepted for CLI consistency; sync cases still run serially" + "Run up to N non-sync cases in parallel") + default-cli-jobs)) (when sync-suite? (println " --e2ee-password VALUE E2EE password for sync commands (Default: 11111)")) (println " --verbose Enable verbose output") (println " --timings Print per-step timings and slow-step summary") (println) (println "Examples:") - (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build")) - (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build --jobs 4")) - (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build -i smoke")) - (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build --case global-help")) + (if sync-suite? + (println (str " bb -f cli-e2e/bb.edn " command-name)) + (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build"))) + (when-not sync-suite? + (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build --jobs 4"))) + (println (str " bb -f cli-e2e/bb.edn " command-name " -i smoke")) + (if sync-suite? + (println (str " bb -f cli-e2e/bb.edn " command-name " --case sync-upload-download-mvp")) + (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build --case global-help"))) (when sync-suite? - (println (str " bb -f cli-e2e/bb.edn " command-name " --skip-build --e2ee-password 'my-secret'"))) + (println (str " bb -f cli-e2e/bb.edn " command-name " --e2ee-password 'my-secret'"))) (flush))) (defn- test-suite! diff --git a/cli-e2e/src/logseq/cli/e2e/paths.clj b/cli-e2e/src/logseq/cli/e2e/paths.clj index fae4671057..7fcac572b6 100644 --- a/cli-e2e/src/logseq/cli/e2e/paths.clj +++ b/cli-e2e/src/logseq/cli/e2e/paths.clj @@ -12,15 +12,20 @@ candidate)) (ancestors (fs/canonicalize path)))) -(defn cli-e2e-root - [] - (or (find-parent-named *file* "cli-e2e") +(def ^:private cli-e2e-root-path + (or (some-> *file* + (find-parent-named "cli-e2e") + str) (throw (ex-info "Unable to locate cli-e2e root" {:file *file*})))) +(defn cli-e2e-root + [] + cli-e2e-root-path) + (defn repo-root [] - (str (fs/parent (cli-e2e-root)))) + (str (fs/parent cli-e2e-root-path))) (defn repo-path [& segments] diff --git a/cli-e2e/src/logseq/cli/e2e/preflight.clj b/cli-e2e/src/logseq/cli/e2e/preflight.clj index 69d72a25fb..3f22d0aee7 100644 --- a/cli-e2e/src/logseq/cli/e2e/preflight.clj +++ b/cli-e2e/src/logseq/cli/e2e/preflight.clj @@ -24,14 +24,14 @@ {:status :skipped :commands [] :missing-artifacts []} - (do + (let [artifacts (paths/required-artifacts)] (doseq [{:keys [cmd]} build-plan] (run-command {:cmd cmd :dir (paths/repo-root)})) - (let [missing (missing-artifacts (paths/required-artifacts) file-exists?)] - (when (seq missing) + (let [missing-after-build (missing-artifacts artifacts file-exists?)] + (when (seq missing-after-build) (throw (ex-info "Build preflight completed but required artifacts are missing" - {:missing-artifacts missing}))) + {:missing-artifacts missing-after-build}))) {:status :ok :commands build-plan :missing-artifacts []})))) diff --git a/cli-e2e/src/logseq/cli/e2e/test_runner.clj b/cli-e2e/src/logseq/cli/e2e/test_runner.clj index 1ad6f2a50d..447015b3c4 100644 --- a/cli-e2e/src/logseq/cli/e2e/test_runner.clj +++ b/cli-e2e/src/logseq/cli/e2e/test_runner.clj @@ -4,6 +4,7 @@ (def test-namespaces '[logseq.cli.e2e.coverage-test logseq.cli.e2e.preflight-test + logseq.cli.e2e.paths-test logseq.cli.e2e.shell-test logseq.cli.e2e.runner-test logseq.cli.e2e.cleanup-test diff --git a/cli-e2e/test/logseq/cli/e2e/compare_graph_queries_test.py b/cli-e2e/test/logseq/cli/e2e/compare_graph_queries_test.py new file mode 100644 index 0000000000..6093590e5a --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/compare_graph_queries_test.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + + +MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "compare_graph_queries.py" +spec = importlib.util.spec_from_file_location("compare_graph_queries", MODULE_PATH) +compare_graph_queries = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(compare_graph_queries) + + +def test_parse_args_supports_repeated_queries(monkeypatch) -> None: + monkeypatch.setattr( + compare_graph_queries.sys, + "argv", + [ + "compare_graph_queries.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--data-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--data-dir-b", + "/tmp/b", + "--query", + "[:find ?x]", + "--query", + "[:find ?y]", + ], + ) + + args = compare_graph_queries.parse_args() + + assert args.query == ["[:find ?x]", "[:find ?y]"] + + +def test_main_batches_multiple_queries_in_one_process(tmp_path: Path) -> None: + left_queries = [] + right_queries = [] + printed = [] + cli_path = tmp_path / "logseq-cli.js" + cli_path.write_text("// mock cli\n") + + def fake_run_query(cli_path, config_path, data_dir, graph, query): + record = { + "cli_path": str(cli_path), + "config_path": str(config_path), + "data_dir": str(data_dir), + "graph": graph, + "query": query, + } + if str(config_path).endswith("a.edn"): + left_queries.append(record) + else: + right_queries.append(record) + return { + "payload": {"status": "ok"}, + "result": [{"title": query}], + } + + compare_graph_queries.run_query = fake_run_query + compare_graph_queries.parse_args = lambda: SimpleNamespace( + cli=str(cli_path), + graph="demo", + query=["[:find ?x]", "[:find ?y]"], + config_a="/tmp/a.edn", + data_dir_a="/tmp/a", + config_b="/tmp/b.edn", + data_dir_b="/tmp/b", + require_result=False, + ) + compare_graph_queries.print = lambda value, **kwargs: printed.append(json.loads(value)) + + compare_graph_queries.main() + + assert [item["query"] for item in left_queries] == ["[:find ?x]", "[:find ?y]"] + assert [item["query"] for item in right_queries] == ["[:find ?x]", "[:find ?y]"] + assert printed == [ + { + "status": "ok", + "results": { + "[:find ?x]": [{"title": "[:find ?x]"}], + "[:find ?y]": [{"title": "[:find ?y]"}], + }, + } + ] diff --git a/cli-e2e/test/logseq/cli/e2e/main_test.clj b/cli-e2e/test/logseq/cli/e2e/main_test.clj index 5559d0eb1f..b852276b85 100644 --- a/cli-e2e/test/logseq/cli/e2e/main_test.clj +++ b/cli-e2e/test/logseq/cli/e2e/main_test.clj @@ -640,9 +640,12 @@ (is (false? @ran?)) (is (string/includes? output "Usage: bb -f cli-e2e/bb.edn test [options]")) (is (string/includes? output "--skip-build")) + (is (not (string/includes? output "--force-build"))) (is (string/includes? output "--include TAG")) (is (string/includes? output "--case ID")) (is (string/includes? output "--jobs N")) + (is (string/includes? output "Run up to N non-sync cases in parallel")) + (is (string/includes? output "--skip-build --jobs 4")) (is (string/includes? output "Default: 4")) (is (string/includes? output "--timings")) (is (not (string/includes? output "--e2ee-password"))))) @@ -666,14 +669,42 @@ (is (false? @ran?)) (is (string/includes? output "Usage: bb -f cli-e2e/bb.edn test-sync [options]")) (is (string/includes? output "--skip-build")) + (is (not (string/includes? output "--force-build"))) (is (string/includes? output "--include TAG")) (is (string/includes? output "--case ID")) (is (string/includes? output "--jobs N")) + (is (string/includes? output "Accepted for CLI consistency; sync cases still run serially")) + (is (not (string/includes? output "--skip-build --jobs 4"))) + (is (string/includes? output "bb -f cli-e2e/bb.edn test-sync")) + (is (string/includes? output "--case sync-upload-download-mvp")) + (is (not (string/includes? output "--skip-build --case sync-upload-download-mvp"))) (is (string/includes? output "Default: 4")) (is (string/includes? output "--timings")) (is (string/includes? output "--e2ee-password VALUE")) (is (string/includes? output "Default: 11111")))) +(deftest run-does-not-pass-force-build-to-preflight + (let [preflight-call (atom nil)] + (with-redefs [logseq.cli.e2e.preflight/run! (fn [opts] + (reset! preflight-call opts) + {:status :ok + :commands [] + :missing-artifacts []})] + (let [result (main/run! {:inventory complete-inventory + :cases sample-cases + :include ["smoke"] + :skip-build false + :force-build true + :run-command (fn [_] + {:exit 0 + :out "" + :err ""}) + :run-case (fn [case _opts] + {:id (:id case) + :status :ok})})] + (is (= :ok (:status result))) + (is (not (contains? @preflight-call :force-build))))))) + (deftest test-single-case-enables-detailed-command-logging (let [command-opts (atom nil) output (with-out-str diff --git a/cli-e2e/test/logseq/cli/e2e/manifests_test.clj b/cli-e2e/test/logseq/cli/e2e/manifests_test.clj index 71fc6c3357..d61712e5a0 100644 --- a/cli-e2e/test/logseq/cli/e2e/manifests_test.clj +++ b/cli-e2e/test/logseq/cli/e2e/manifests_test.clj @@ -165,3 +165,20 @@ (is (some? error)) (is (= [{:type :unused-template :template :unused}] (vec unused-issues)))))) + +(deftest sync-multi-batch-operations-uses-state-driven-waits-instead-of-fixed-sleeps + (let [cases (manifests/load-cases :sync) + multi-batch (some #(when (= "sync-multi-batch-operations" (:id %)) %) cases) + commands (:cmds multi-batch)] + (is (some? multi-batch)) + (is (not-any? #(= "sleep 1" %) commands)) + (is (>= (count (filter #(re-find #"wait_sync_status\.py" %) commands)) + 4)))) + +(deftest sync-status-steady-state-does-not-repeat-identical-b-side-steady-state-waits + (let [cases (manifests/load-cases :sync) + steady-state (some #(when (= "sync-status-steady-state" (:id %)) %) cases) + steady-waits (filter #(re-find #"wait_sync_status\.py.+--data-dir '\{\{tmp-dir\}\}/graphs-b'.+--timeout-s 30 --interval-s 1" %) (:cmds steady-state))] + (is (some? steady-state)) + (is (= 1 (count steady-waits))) + (is (= 1 (count (filter #(re-find #"sync status --graph" %) (:cmds steady-state))))))) diff --git a/cli-e2e/test/logseq/cli/e2e/paths_test.clj b/cli-e2e/test/logseq/cli/e2e/paths_test.clj new file mode 100644 index 0000000000..0e07287a74 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/paths_test.clj @@ -0,0 +1,9 @@ +(ns logseq.cli.e2e.paths-test + (:require [clojure.test :refer [deftest is]] + [logseq.cli.e2e.paths :as paths])) + +(deftest repo-root-does-not-depend-on-runtime-file-binding + (let [expected (paths/repo-root)] + (binding [*file* nil] + (is (= expected + (paths/repo-root)))))) diff --git a/cli-e2e/test/logseq/cli/e2e/preflight_test.clj b/cli-e2e/test/logseq/cli/e2e/preflight_test.clj index c28a20f3aa..fd80f3799c 100644 --- a/cli-e2e/test/logseq/cli/e2e/preflight_test.clj +++ b/cli-e2e/test/logseq/cli/e2e/preflight_test.clj @@ -2,6 +2,19 @@ (:require [clojure.test :refer [deftest is testing]] [logseq.cli.e2e.preflight :as preflight])) +(def required-artifacts + ["/repo/static/logseq-cli.js" + "/repo/static/db-worker-node.js" + "/repo/dist/db-worker-node.js" + "/repo/dist/db-worker-node-assets.json" + "/repo/deps/db-sync/worker/dist/node-adapter.js"]) + +(defn- with-required-artifacts + [f] + (with-redefs [logseq.cli.e2e.paths/repo-root (constantly "/repo") + logseq.cli.e2e.paths/required-artifacts (fn [] required-artifacts)] + (f))) + (deftest build-plan-matches-required-commands (is (= ["clojure -M:cljs compile logseq-cli db-worker-node" "yarn db-worker-node:compile:bundle" @@ -26,29 +39,57 @@ (is (= :skipped (:status result))) (is (false? @called?)))) -(deftest build-runs-commands-before-verifying-artifacts +(deftest build-runs-commands-even-when-artifacts-are-ready + (let [calls (atom [])] + (with-required-artifacts + #(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}] + (swap! calls conj cmd) + {:cmd cmd + :exit 0 + :out "" + :err ""}) + :file-exists? (set required-artifacts)})] + (is (= :ok (:status result))) + (is (= ["clojure -M:cljs compile logseq-cli db-worker-node" + "yarn db-worker-node:compile:bundle" + "yarn --cwd deps/db-sync build:node-adapter"] + @calls)))))) + +(deftest build-runs-commands-when-artifacts-are-partially-present (let [calls (atom []) - existing (atom #{"/repo/static/logseq-cli.js" - "/repo/static/db-worker-node.js" - "/repo/dist/db-worker-node.js" - "/repo/dist/db-worker-node-assets.json" - "/repo/deps/db-sync/worker/dist/node-adapter.js"})] - (with-redefs [logseq.cli.e2e.paths/repo-root (constantly "/repo") - logseq.cli.e2e.paths/required-artifacts (fn [] - ["/repo/static/logseq-cli.js" - "/repo/static/db-worker-node.js" - "/repo/dist/db-worker-node.js" - "/repo/dist/db-worker-node-assets.json" - "/repo/deps/db-sync/worker/dist/node-adapter.js"])] - (let [result (preflight/run! {:run-command (fn [{:keys [cmd]}] - (swap! calls conj cmd) - {:cmd cmd - :exit 0 - :out "" - :err ""}) - :file-exists? @existing})] - (is (= :ok (:status result))) - (is (= ["clojure -M:cljs compile logseq-cli db-worker-node" - "yarn db-worker-node:compile:bundle" - "yarn --cwd deps/db-sync build:node-adapter"] - @calls)))))) + existing (atom (disj (set required-artifacts) + "/repo/dist/db-worker-node-assets.json"))] + (with-required-artifacts + #(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}] + (swap! calls conj cmd) + (reset! existing (set required-artifacts)) + {:cmd cmd + :exit 0 + :out "" + :err ""}) + :file-exists? (fn [path] + (contains? @existing path))})] + (is (= :ok (:status result))) + (is (= ["clojure -M:cljs compile logseq-cli db-worker-node" + "yarn db-worker-node:compile:bundle" + "yarn --cwd deps/db-sync build:node-adapter"] + @calls)))))) + +(deftest build-runs-commands-when-artifacts-are-absent + (let [calls (atom []) + existing (atom #{})] + (with-required-artifacts + #(let [result (preflight/run! {:run-command (fn [{:keys [cmd]}] + (swap! calls conj cmd) + (reset! existing (set required-artifacts)) + {:cmd cmd + :exit 0 + :out "" + :err ""}) + :file-exists? (fn [path] + (contains? @existing path))})] + (is (= :ok (:status result))) + (is (= ["clojure -M:cljs compile logseq-cli db-worker-node" + "yarn db-worker-node:compile:bundle" + "yarn --cwd deps/db-sync build:node-adapter"] + @calls)))))) diff --git a/cli-e2e/test/logseq/cli/e2e/random_bidirectional_block_ops_test.py b/cli-e2e/test/logseq/cli/e2e/random_bidirectional_block_ops_test.py new file mode 100644 index 0000000000..3e03355f5e --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/random_bidirectional_block_ops_test.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + + +MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "random_bidirectional_block_ops.py" +spec = importlib.util.spec_from_file_location("random_bidirectional_block_ops", MODULE_PATH) +random_bidirectional_block_ops = importlib.util.module_from_spec(spec) +assert spec.loader is not None +sys.modules[spec.name] = random_bidirectional_block_ops +spec.loader.exec_module(random_bidirectional_block_ops) + + +def test_default_profile_is_faster_than_high_stress(monkeypatch) -> None: + monkeypatch.setattr( + random_bidirectional_block_ops.sys, + "argv", + [ + "random_bidirectional_block_ops.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--data-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--data-dir-b", + "/tmp/b", + "--page", + "Home", + ], + ) + default_args = random_bidirectional_block_ops.parse_args() + + monkeypatch.setattr( + random_bidirectional_block_ops.sys, + "argv", + [ + "random_bidirectional_block_ops.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--data-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--data-dir-b", + "/tmp/b", + "--page", + "Home", + "--profile", + "high-stress", + ], + ) + high_stress_args = random_bidirectional_block_ops.parse_args() + + assert default_args.profile == "default" + assert high_stress_args.profile == "high-stress" + assert default_args.rounds_per_client < high_stress_args.rounds_per_client + + +def test_explicit_rounds_override_profile_default(monkeypatch) -> None: + monkeypatch.setattr( + random_bidirectional_block_ops.sys, + "argv", + [ + "random_bidirectional_block_ops.py", + "--cli", + "/tmp/logseq-cli.js", + "--graph", + "demo", + "--config-a", + "/tmp/a.edn", + "--data-dir-a", + "/tmp/a", + "--config-b", + "/tmp/b.edn", + "--data-dir-b", + "/tmp/b", + "--page", + "Home", + "--profile", + "high-stress", + "--rounds-per-client", + "12", + ], + ) + + args = random_bidirectional_block_ops.parse_args() + + assert args.profile == "high-stress" + assert args.rounds_per_client == 12 diff --git a/cli-e2e/test/logseq/cli/e2e/sync_fixture_test.clj b/cli-e2e/test/logseq/cli/e2e/sync_fixture_test.clj index b60346788b..1c8093bf48 100644 --- a/cli-e2e/test/logseq/cli/e2e/sync_fixture_test.clj +++ b/cli-e2e/test/logseq/cli/e2e/sync_fixture_test.clj @@ -66,3 +66,4 @@ (is (string/includes? (:cmd (last @calls)) "db_sync_server.py")) (is (string/includes? (:cmd (last @calls)) " stop ")) (is (false? (:throw? (last @calls)))))) + diff --git a/cli-e2e/test/logseq/cli/e2e/wait_sync_status_test.py b/cli-e2e/test/logseq/cli/e2e/wait_sync_status_test.py new file mode 100644 index 0000000000..f58665f872 --- /dev/null +++ b/cli-e2e/test/logseq/cli/e2e/wait_sync_status_test.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + + +MODULE_PATH = Path(__file__).resolve().parents[4] / "scripts" / "wait_sync_status.py" +spec = importlib.util.spec_from_file_location("wait_sync_status", MODULE_PATH) +wait_sync_status = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(wait_sync_status) + + +def test_resolved_status_command_is_built_once_and_reused() -> None: + args = SimpleNamespace( + cli="~/repo/static/logseq-cli.js", + data_dir="~/tmp/graph", + config="~/tmp/cli.edn", + graph="demo", + ) + + command = wait_sync_status.status_command(args) + + assert command[0] == "node" + assert command[-2:] == ["--graph", "demo"] + assert Path(command[1]).is_absolute() + assert Path(command[3]).is_absolute() + assert Path(command[5]).is_absolute() + assert wait_sync_status.status_command(args) is command + + +def test_run_status_uses_precomputed_command() -> None: + command = ["node", "/abs/cli.js", "--graph", "demo"] + args = SimpleNamespace(status_command=command) + calls = [] + + def fake_run(cmd, capture_output, text): + calls.append(cmd) + return SimpleNamespace( + returncode=0, + stdout='{"status":"ok","data":{"pending-local":0,"pending-asset":0,"pending-server":0}}', + stderr="", + ) + + original_run = wait_sync_status.subprocess.run + wait_sync_status.subprocess.run = fake_run + try: + payload = wait_sync_status.run_status(args) + finally: + wait_sync_status.subprocess.run = original_run + + assert payload["status"] == "ok" + assert calls == [command]