From 54939bd06e2b572c6b13d9d31537f73e82256364 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 12:24:04 +0000 Subject: [PATCH 01/28] Add headless drive e2e scenarios and cut per-flow iOS e2e cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless e2e: a 'drive' alcotest suite mounts the chat app in-process (Drive.Session + a host stub that resolves pending effects with canned projections) and runs .drive scenario scripts under shared/test/logseq_chat/drive/. Three scenarios cover graph creation, composer capture, and settings language/tabs. They ride the existing @shared/native/runtest gate so CI runs them — no simulator, microseconds per poll. Drive pin bumped to 2ac231e for the 'change' command and empty-string tokenizer fix (logseq/drive#1). iOS e2e runtime: test-ios-e2e.sh now wipes the app data container instead of uninstalling+reinstalling the identical binary per flow (LOGSEQ_CHAT_IOS_E2E_REINSTALL=1 restores the old path), and the suite exports one run id plus a per-fixture-mode seed cache so the graph-setup Maestro flow + sqlite checkpoint wait runs once per mode instead of once per flow. --- logseq_chat.opam | 2 + logseq_chat.opam.locked | 9 ++ scripts/test-ios-e2e-suite.sh | 11 ++ scripts/test-ios-e2e.sh | 70 ++++++--- shared/test/dune | 4 +- .../drive/cold-start-create-graph.drive | 9 ++ .../logseq_chat/drive/composer-capture.drive | 16 +++ .../drive/settings-language-tabs.drive | 17 +++ .../test/logseq_chat/drive_scenario_test.ml | 135 ++++++++++++++++++ shared/test/logseq_chat/test_main.ml | 1 + 10 files changed, 253 insertions(+), 21 deletions(-) create mode 100644 shared/test/logseq_chat/drive/cold-start-create-graph.drive create mode 100644 shared/test/logseq_chat/drive/composer-capture.drive create mode 100644 shared/test/logseq_chat/drive/settings-language-tabs.drive create mode 100644 shared/test/logseq_chat/drive_scenario_test.ml diff --git a/logseq_chat.opam b/logseq_chat.opam index 079c2545..21564a0a 100644 --- a/logseq_chat.opam +++ b/logseq_chat.opam @@ -15,6 +15,7 @@ depends: [ "ctypes" "ctypes-foreign" "alcotest" {with-test} + "drive" {with-test} "mldoc" "ocaml-fsrs" "yojson" @@ -31,6 +32,7 @@ pin-depends: [ ["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#473b6c6a81fc2ce9e9dbe9116abca14ef2ac2c25"] + ["drive.dev" "git+https://github.com/logseq/drive.git#2ac231e8c35ffbd3a3d38fd28bd55d93682c3c8e"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#976b40f1770a65b3464df1ef38d1550f1d8a43dd"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#553dea6ed8694352527a189747f787365469c9cb"] ["angstrom.dev" "git+https://github.com/logseq/angstrom.git#3be9b966dc2bc9ccf9948d17a7b0df1cb526de15"] diff --git a/logseq_chat.opam.locked b/logseq_chat.opam.locked index 9bd027d6..99f497a1 100644 --- a/logseq_chat.opam.locked +++ b/logseq_chat.opam.locked @@ -41,6 +41,7 @@ depends: [ "datascript-ocaml-native" {= "dev" & ?vendor} "datascript_ocaml" {= "dev" & ?vendor} "delimited_parsing" {= "v0.17.0" & ?vendor} + "drive" {= "dev" & ?vendor} "dune" {= "3.24.2"} "dune-build-info" {= "3.24.2" & ?vendor} "dune-compiledb" {= "0.6.0" & ?vendor} @@ -320,6 +321,10 @@ pin-depends: [ "delimited_parsing.v0.17.0" "https://github.com/janestreet/delimited_parsing/archive/refs/tags/v0.17.0.tar.gz" ] + [ + "drive.dev" + "git+https://github.com/logseq/drive.git#2ac231e8c35ffbd3a3d38fd28bd55d93682c3c8e" + ] [ "dune-build-info.3.24.2" "https://github.com/ocaml/dune/releases/download/3.24.2/dune-3.24.2.tbz" @@ -1476,6 +1481,10 @@ x-opam-monorepo-duniverse-dirs: [ "git+https://github.com/logseq/angstrom.git#3be9b966dc2bc9ccf9948d17a7b0df1cb526de15" "angstrom" ] + [ + "git+https://github.com/logseq/drive.git#2ac231e8c35ffbd3a3d38fd28bd55d93682c3c8e" + "drive" + ] [ "git+https://github.com/logseq/ocaml-fsrs.git#472b8ff1b86e5afd2ac4bbeba5e2eecedd3948fc" "ocaml-fsrs" diff --git a/scripts/test-ios-e2e-suite.sh b/scripts/test-ios-e2e-suite.sh index e6019549..f24b9684 100755 --- a/scripts/test-ios-e2e-suite.sh +++ b/scripts/test-ios-e2e-suite.sh @@ -126,6 +126,17 @@ if [[ ${LOGSEQ_CHAT_IOS_SKIP_BUILD:-0} != 1 ]]; then "$repo_root/scripts/build-mobile-ios-simulator.sh" >/dev/null fi +# One run id for every flow in this suite so graph names are stable, plus a +# shared seed cache: the first flow per fixture mode drives the graph-setup +# flow and caches the seeded graphs dir; later flows copy it into a fresh +# container instead of re-running the Maestro setup. +export LOGSEQ_CHAT_E2E_RUN_ID=${LOGSEQ_CHAT_E2E_RUN_ID:-$(date +%s)} +if [[ ${LOGSEQ_CHAT_E2E_SEED_CACHE:-unset} == unset ]]; then + seed_cache_dir=$(mktemp -d "${TMPDIR:-/tmp}/logseq-chat-e2e-seed-cache.XXXXXX") + export LOGSEQ_CHAT_E2E_SEED_CACHE=$seed_cache_dir + trap 'rm -rf "$seed_cache_dir"' EXIT +fi + start_index=${LOGSEQ_CHAT_IOS_E2E_START_INDEX:-0} if (( start_index < 0 || start_index >= ${#flows[@]} )); then echo "error: LOGSEQ_CHAT_IOS_E2E_START_INDEX must be between 0 and $((${#flows[@]} - 1))" >&2 diff --git a/scripts/test-ios-e2e.sh b/scripts/test-ios-e2e.sh index 9fe30e20..d20805c8 100755 --- a/scripts/test-ios-e2e.sh +++ b/scripts/test-ios-e2e.sh @@ -69,13 +69,26 @@ fi if [[ ${LOGSEQ_CHAT_IOS_SKIP_BUILD:-0} != 1 ]]; then "$repo_root/scripts/build-mobile-ios-simulator.sh" >/dev/null fi -xcrun simctl uninstall "$device" "$app_id" >/dev/null 2>&1 || true +# Reset app state between flows by wiping the data container instead of +# reinstalling the (unchanged) binary — a copy of the .app bundle is the +# slowest part of per-flow setup. LOGSEQ_CHAT_IOS_E2E_REINSTALL=1 restores +# the old uninstall+install behavior. +xcrun simctl terminate "$device" "$app_id" >/dev/null 2>&1 || true +if [[ ${LOGSEQ_CHAT_IOS_E2E_REINSTALL:-0} == 1 ]]; then + xcrun simctl uninstall "$device" "$app_id" >/dev/null 2>&1 || true +fi +data_container=$(xcrun simctl get_app_container "$device" "$app_id" data 2>/dev/null || true) +if [[ -n $data_container && -d $data_container ]]; then + rm -rf "$data_container/Documents" "$data_container/Library" "$data_container/tmp" +fi xcrun simctl spawn "$device" defaults delete "$app_id" logseq.baseURL >/dev/null 2>&1 || true xcrun simctl spawn "$device" defaults delete "$app_id" logseq.selectedGraphId >/dev/null 2>&1 || true xcrun simctl spawn "$device" defaults delete "$app_id" logseq.composerDraft >/dev/null 2>&1 || true xcrun simctl spawn "$device" defaults delete "$app_id" logseq.contentMode >/dev/null 2>&1 || true [[ -d $app_path ]] || die "iOS app bundle was not found: $app_path" -xcrun simctl install "$device" "$app_path" +if [[ -z $data_container ]]; then + xcrun simctl install "$device" "$app_path" +fi xcrun simctl spawn "$device" defaults write "$app_id" logseq.baseURL "$base_url" mkdir -p "$screenshots_dir" @@ -109,27 +122,44 @@ sed \ -e "s|__LOGSEQ_CHAT_E2E_SETUP_FLOW__|$rendered_setup|g" \ -e "s|__LOGSEQ_CHAT_E2E_OUTLINER_ANCHOR_FLOW__|$rendered_outliner_anchor|g" \ "$flow_path" > "$rendered_flow" +# The graph-setup Maestro flow + sqlite/checkpoint wait is the slowest part +# of a seeded flow (~30-60s). When LOGSEQ_CHAT_E2E_SEED_CACHE points at a +# directory, the seeded Documents/graphs tree is cached per fixture mode and +# copied into a fresh container on the next flow instead of re-driving the +# UI setup. The suite script sets this up so the cache spans the whole run. +seed_cache_dir=${LOGSEQ_CHAT_E2E_SEED_CACHE:-} +seed_cache_key=${fixture_seed_mode:-default}-$graph_name if [[ ${LOGSEQ_CHAT_IOS_E2E_SEED_GRAPH:-0} == 1 || -n $fixture_seed_mode ]]; then - MAESTRO_CLI_NO_ANALYTICS=1 "$maestro_bin" --device "$device" test "$rendered_setup" - data_container=$(xcrun simctl get_app_container "$device" "$app_id" data) - graph_database="" - for _ in {1..120}; do - if [[ -d $data_container/Documents/graphs ]]; then - graph_database=$(find "$data_container/Documents/graphs" -name graph.sqlite -type f | head -1) + if [[ -n $seed_cache_dir && -d $seed_cache_dir/$seed_cache_key/graphs ]]; then + data_container=$(xcrun simctl get_app_container "$device" "$app_id" data) + mkdir -p "$data_container/Documents" + cp -R "$seed_cache_dir/$seed_cache_key/graphs" "$data_container/Documents/" + else + MAESTRO_CLI_NO_ANALYTICS=1 "$maestro_bin" --device "$device" test "$rendered_setup" + data_container=$(xcrun simctl get_app_container "$device" "$app_id" data) + graph_database="" + for _ in {1..120}; do + if [[ -d $data_container/Documents/graphs ]]; then + graph_database=$(find "$data_container/Documents/graphs" -name graph.sqlite -type f | head -1) + fi + if [[ -n $graph_database && -f ${graph_database%/graph.sqlite}/sync.checkpoint ]]; then + break + fi + sleep 0.5 + done + [[ -n $graph_database && -f ${graph_database%/graph.sqlite}/sync.checkpoint ]] \ + || die "timed out waiting for the graph snapshot import to finish" + xcrun simctl terminate "$device" "$app_id" >/dev/null 2>&1 || true + if [[ -n $fixture_seed_mode ]]; then + opam exec --switch=5.5.0 -- \ + dune exec shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" "$fixture_seed_mode" + else + opam exec --switch=5.5.0 -- dune exec shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" fi - if [[ -n $graph_database && -f ${graph_database%/graph.sqlite}/sync.checkpoint ]]; then - break + if [[ -n $seed_cache_dir ]]; then + mkdir -p "$seed_cache_dir/$seed_cache_key" + cp -R "$data_container/Documents/graphs" "$seed_cache_dir/$seed_cache_key/" fi - sleep 0.5 - done - [[ -n $graph_database && -f ${graph_database%/graph.sqlite}/sync.checkpoint ]] \ - || die "timed out waiting for the graph snapshot import to finish" - xcrun simctl terminate "$device" "$app_id" >/dev/null 2>&1 || true - if [[ -n $fixture_seed_mode ]]; then - opam exec --switch=5.5.0 -- \ - dune exec shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" "$fixture_seed_mode" - else - opam exec --switch=5.5.0 -- dune exec shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" fi fi if [[ ${flow##*/} == ios-graphs-lifecycle.yaml ]]; then diff --git a/shared/test/dune b/shared/test/dune index 82efdaee..f0efaaa8 100644 --- a/shared/test/dune +++ b/shared/test/dune @@ -10,6 +10,7 @@ (libraries logseq_chat_core logseq_chat_app + drive lui ocaml-signal datascript-ocaml-native @@ -31,6 +32,7 @@ ../native/logseq_chat_e2e_seed.exe ../native/logseq_chat_live_sync.exe ../native/pending_ops_golden.jsonl - test_main.exe) + test_main.exe + (source_tree logseq_chat/drive)) (action (run ./test_main.exe))) diff --git a/shared/test/logseq_chat/drive/cold-start-create-graph.drive b/shared/test/logseq_chat/drive/cold-start-create-graph.drive new file mode 100644 index 00000000..d4723ade --- /dev/null +++ b/shared/test/logseq_chat/drive/cold-start-create-graph.drive @@ -0,0 +1,9 @@ +# Cold start: the graph picker is up, create a local graph and land on +# the application shell. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +wait prop:accessibility-identifier=field.graph-name +type prop:accessibility-identifier=field.graph-name "Drive Graph" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +expect prop:accessibility-identifier=title.main diff --git a/shared/test/logseq_chat/drive/composer-capture.drive b/shared/test/logseq_chat/drive/composer-capture.drive new file mode 100644 index 00000000..222adf62 --- /dev/null +++ b/shared/test/logseq_chat/drive/composer-capture.drive @@ -0,0 +1,16 @@ +# Create a local graph, capture a note through the composer, and verify +# the send path clears the draft. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +type prop:accessibility-identifier=field.graph-name "Drive Graph" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +expect prop:accessibility-identifier=surface.composer.root +press prop:accessibility-identifier=button.composer.expand +wait prop:accessibility-identifier=field.composer +expect-prop prop:accessibility-identifier=button.send enabled false +type prop:accessibility-identifier=field.composer "first drive note" +expect-prop prop:accessibility-identifier=button.send enabled true +press prop:accessibility-identifier=button.send +expect-prop prop:accessibility-identifier=field.composer text "" +expect-prop prop:accessibility-identifier=button.send enabled false diff --git a/shared/test/logseq_chat/drive/settings-language-tabs.drive b/shared/test/logseq_chat/drive/settings-language-tabs.drive new file mode 100644 index 00000000..9040d6a2 --- /dev/null +++ b/shared/test/logseq_chat/drive/settings-language-tabs.drive @@ -0,0 +1,17 @@ +# Create a local graph, open Settings through the native overflow menu, +# switch the language radio, and open the Tabs screen. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +type prop:accessibility-identifier=field.graph-name "Drive Graph" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +ext ext:native-overflow-menu native-overflow-menu settings '{}' +wait prop:accessibility-identifier=sheet.settings +expect prop:accessibility-identifier=layout.settings.general-card +expect prop:accessibility-identifier=picker.settings.language +change prop:accessibility-identifier=button.settings.language.fr +expect-prop prop:accessibility-identifier=button.settings.language.fr checked true +expect-prop prop:accessibility-identifier=button.settings.language.system checked false +press prop:accessibility-identifier=link.settings.tabs +wait prop:accessibility-identifier=sheet.settings.tabs +expect prop:accessibility-identifier=row.settings.tab.graphs diff --git a/shared/test/logseq_chat/drive_scenario_test.ml b/shared/test/logseq_chat/drive_scenario_test.ml new file mode 100644 index 00000000..b55a6b2b --- /dev/null +++ b/shared/test/logseq_chat/drive_scenario_test.ml @@ -0,0 +1,135 @@ +(* Headless drive scenarios: mount the real chat app (reducer + view) + against drive's recording backend and replay .drive scripts from + shared/test/logseq_chat/drive/. A small host stub answers the app's + pending_effects with canned projections — the same responses the + native bridge would deliver — so UI-driven flows run end to end on + Linux in microseconds. *) + +let scenarios_dir = "logseq_chat/drive" + +let ios_profile () = + Lui_protocol.profile Lui_protocol.IOS Lui_protocol.SwiftUIHost + +let graph_entry id name encrypted = + { Model.id; name; is_encrypted = encrypted; is_ready = true } + +let catalog_projection graphs = + { (App_test.empty_core_projection ()) with Model.graphs } + +let graph_projection ~graph_id ~graph_name ~encrypted ~unlocked graphs = + { + (App_test.empty_core_projection ()) with + Model.graph_name = Some graph_name; + selected_graph_id = Some graph_id; + graphs; + is_graph_encrypted = encrypted; + is_graph_unlocked = unlocked; + } + +type host = { mutable graphs : Model.graph list } + +let host_responses host model = + let find_graph id = + List.find_opt (fun (g : Model.graph) -> g.id = id) host.graphs + in + List.concat_map + (fun eff -> + let resolved = [ Model.ResolveEffect (Model.effect_id eff, true, "") ] in + match eff with + | Model.RefreshGraphsEffect id -> + Model.DequeueEffect id + :: Model.ApplyLocalGraphIds + (List.map (fun (g : Model.graph) -> g.id) host.graphs) + :: Model.ApplyCoreSnapshot (catalog_projection host.graphs) + :: resolved + | Model.CreateGraphEffect (id, name, encrypted) -> + let graph = graph_entry name name encrypted in + host.graphs <- host.graphs @ [ graph ]; + Model.DequeueEffect id + :: Model.ApplyLocalGraphIds + (List.map (fun (g : Model.graph) -> g.id) host.graphs) + :: Model.ApplyCoreSnapshot + (graph_projection ~graph_id:name ~graph_name:name ~encrypted + ~unlocked:(not encrypted) host.graphs) + :: resolved + | Model.OpenGraphEffect (id, graph_id) -> ( + match find_graph graph_id with + | Some g -> + Model.DequeueEffect id + :: Model.ApplyCoreSnapshot + (graph_projection ~graph_id:g.id ~graph_name:g.name + ~encrypted:g.is_encrypted ~unlocked:(not g.is_encrypted) + host.graphs) + :: resolved + | None -> + [ + Model.DequeueEffect id; + Model.ResolveEffect (id, false, "unknown graph"); + ]) + | Model.UnlockGraphEffect (id, _) -> ( + match + Option.map find_graph model.Model.selected_graph_id + |> Option.join + with + | Some g -> + Model.DequeueEffect id + :: Model.ApplyCoreSnapshot + (graph_projection ~graph_id:g.id ~graph_name:g.name + ~encrypted:g.is_encrypted ~unlocked:true host.graphs) + :: resolved + | None -> Model.DequeueEffect id :: resolved) + | eff -> + [ + Model.DequeueEffect (Model.effect_id eff); + Model.ResolveEffect (Model.effect_id eff, true, ""); + ]) + model.Model.pending_effects + +let mount ~profile () = + let host = { graphs = [] } in + let self = ref None in + let drain () = + match !self with + | None -> [] + | Some s -> host_responses host (Drive.Session.read_model s) + in + let session = + Drive.Session.mount ~profile ~drain + ~registry:(View.extension_registry ()) + ~initial:(Model.initial ()) ~reducer:Model.update ~view:View.chat_view + () + in + self := Some session; + session + +let read_file path = + let channel = open_in path in + Fun.protect + ~finally:(fun () -> close_in_noerr channel) + (fun () -> really_input_string channel (in_channel_length channel)) + +let scenario_names () = + Sys.readdir scenarios_dir |> Array.to_list + |> List.filter (fun name -> Filename.check_suffix name ".drive") + |> List.sort String.compare + +let run_scenario name () = + let session = mount ~profile:(ios_profile ()) () in + let failures = + Drive.Scenario.run + (Drive.Session.driver session) + (read_file (Filename.concat scenarios_dir name)) + in + Drive.Session.dispose session; + let messages = + List.map + (fun (f : Drive.Scenario.failure) -> + Printf.sprintf "line %d: %s" f.line f.message) + failures + in + Alcotest.(check (list string)) name [] messages + +let cases = + List.map + (fun name -> Alcotest.test_case name `Quick (run_scenario name)) + (scenario_names ()) diff --git a/shared/test/logseq_chat/test_main.ml b/shared/test/logseq_chat/test_main.ml index 0a69fe00..6a26bdc4 100644 --- a/shared/test/logseq_chat/test_main.ml +++ b/shared/test/logseq_chat/test_main.ml @@ -42,4 +42,5 @@ let () = "graph_runtime", Graph_runtime_test.cases; "rpc", Rpc_test.cases; "app", App_test.cases; + "drive", Drive_scenario_test.cases; ] From 937916b4db41b1ca200fc01f7157b6121ae6f222 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 12:30:18 +0000 Subject: [PATCH 02/28] Bump drive pin to a7da801 (base-unix/base-threads opam fix) --- logseq_chat.opam | 2 +- logseq_chat.opam.locked | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/logseq_chat.opam b/logseq_chat.opam index 21564a0a..00429a2d 100644 --- a/logseq_chat.opam +++ b/logseq_chat.opam @@ -32,7 +32,7 @@ pin-depends: [ ["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#473b6c6a81fc2ce9e9dbe9116abca14ef2ac2c25"] - ["drive.dev" "git+https://github.com/logseq/drive.git#2ac231e8c35ffbd3a3d38fd28bd55d93682c3c8e"] + ["drive.dev" "git+https://github.com/logseq/drive.git#a7da801a36d5bca761a5f9c350cc2130984dcebd"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#976b40f1770a65b3464df1ef38d1550f1d8a43dd"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#553dea6ed8694352527a189747f787365469c9cb"] ["angstrom.dev" "git+https://github.com/logseq/angstrom.git#3be9b966dc2bc9ccf9948d17a7b0df1cb526de15"] diff --git a/logseq_chat.opam.locked b/logseq_chat.opam.locked index 99f497a1..6120b30c 100644 --- a/logseq_chat.opam.locked +++ b/logseq_chat.opam.locked @@ -323,7 +323,7 @@ pin-depends: [ ] [ "drive.dev" - "git+https://github.com/logseq/drive.git#2ac231e8c35ffbd3a3d38fd28bd55d93682c3c8e" + "git+https://github.com/logseq/drive.git#a7da801a36d5bca761a5f9c350cc2130984dcebd" ] [ "dune-build-info.3.24.2" @@ -1482,7 +1482,7 @@ x-opam-monorepo-duniverse-dirs: [ "angstrom" ] [ - "git+https://github.com/logseq/drive.git#2ac231e8c35ffbd3a3d38fd28bd55d93682c3c8e" + "git+https://github.com/logseq/drive.git#a7da801a36d5bca761a5f9c350cc2130984dcebd" "drive" ] [ From 889ffb95b60360229c2437410f329c05e6ccd2ab Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 12:47:28 +0000 Subject: [PATCH 03/28] ci: vendor pin-depends at non-SHA refs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #11 moved datascript-ocaml/melange pins to #main, but the duniverse clone step only matched #[0-9a-f]{40} — those pins were never vendored, so _opam's datascript_ocaml resolved persistent_sorted_set_ocaml from _opam while the workspace used the vendored copy: library-name conflict in dune build @shared/native/runtest (also failing main). Match any #ref and fall back to FETCH_HEAD when the ref isn't a local checkout. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a27170d6..4fd5868b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" opam monorepo pull -y - for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[0-9a-f]{40}' logseq_chat.opam | sort -u); do + for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" # opam monorepo pull already vendors lockfile entries at the right # commits (without .git dirs — `git -C` there would hit the parent @@ -93,7 +93,7 @@ jobs: git clone --quiet "https://github.com/$url" "duniverse/$name" if ! git -C "duniverse/$name" checkout --quiet "$sha"; then git -C "duniverse/$name" fetch --quiet "https://github.com/$url" "$sha" - git -C "duniverse/$name" checkout --quiet "$sha" + git -C "duniverse/$name" checkout --quiet FETCH_HEAD fi done From 346807859aedba74ed9360cea3ecd2b2903c6618 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 12:50:41 +0000 Subject: [PATCH 04/28] ci: include workflow file in duniverse cache key The vendored-dirs cache was restored across changes to the pin-clone step itself (same opam files => same key), so a duniverse built with the old sha-only clone logic shadowed the new regex. Hash ci.yml too. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fd5868b..32292062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: uses: actions/cache/restore@v4 with: path: duniverse - key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml') }} - name: Pull vendored dependencies if: steps.duniverse.outputs.cache-hit != 'true' @@ -102,7 +102,7 @@ jobs: uses: actions/cache/save@v4 with: path: duniverse - key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml') }} - name: Restore build caches uses: actions/cache/restore@v4 From 2e6a23d0dd537be1d698b688bc806f447b71402e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 12:58:30 +0000 Subject: [PATCH 05/28] storage_codec: round-trip new storage root index metadata fields datascript-ocaml@main added optional storage_eavt/aevt/avet_metadata fields to storage_root (datascript_types). Decode the eavt-metadata / aevt-metadata / avet-metadata transit keys into them, and emit them when encoding a root without an explicit index_metadata param so encode/decode stays a round-trip. --- shared/src/logseq_chat/core/storage_codec.ml | 36 ++++++++++++++++++- .../logseq_chat/logseq_storage_codec_test.ml | 3 ++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/shared/src/logseq_chat/core/storage_codec.ml b/shared/src/logseq_chat/core/storage_codec.ml index ff2119fa..372b1386 100644 --- a/shared/src/logseq_chat/core/storage_codec.ml +++ b/shared/src/logseq_chat/core/storage_codec.ml @@ -349,6 +349,20 @@ let address_to_json address = let addresses_to_json addresses = Yojson.Safe.to_string (`List (List.map address_to_json addresses)) +let index_metadata_of_transit entries = + match lookup "count" entries, lookup "shift" entries with + | Some count, Some shift -> + Some + { Ds.storage_index_count = required_int "index metadata :count" count + ; storage_index_shift = required_int "index metadata :shift" shift + } + | _ -> None + +let optional_metadata key entries = + match lookup key entries with + | Some (Value.Map metadata) -> index_metadata_of_transit metadata + | _ -> None + let root_of_transit entries : Ds.storage_root = { storage_schema = schema_of_transit (required "schema" entries) ; storage_max_eid = required_int "root :max-eid" (required "max-eid" entries) @@ -356,6 +370,9 @@ let root_of_transit entries : Ds.storage_root = ; storage_eavt = address_of_transit "root :eavt" (required "eavt" entries) ; storage_aevt = address_of_transit "root :aevt" (required "aevt" entries) ; storage_avet = address_of_transit "root :avet" (required "avet" entries) + ; storage_eavt_metadata = optional_metadata "eavt-metadata" entries + ; storage_aevt_metadata = optional_metadata "aevt-metadata" entries + ; storage_avet_metadata = optional_metadata "avet-metadata" entries ; storage_duplicate_datoms = (match lookup "duplicate-datoms" entries with | None -> [] @@ -377,10 +394,16 @@ let index_metadata_to_transit metadata = (Value.Keyword "shift", Value.Int metadata.shift); ] +let stored_index_metadata_to_transit (metadata : Ds.storage_index_metadata) = + Value.Map + [ + (Value.Keyword "count", Value.Int metadata.storage_index_count); + (Value.Keyword "shift", Value.Int metadata.storage_index_shift); + ] + let root_to_transit index_metadata (root : Ds.storage_root) = let metadata = match index_metadata with - | None -> [] | Some metadata -> [ ( Value.Keyword "eavt-metadata" @@ -390,6 +413,17 @@ let root_to_transit index_metadata (root : Ds.storage_root) = ( Value.Keyword "avet-metadata" , index_metadata_to_transit metadata.avet ); ] + | None -> + List.filter_map + (fun (key, value) -> + Option.map + (fun metadata -> (Value.Keyword key, stored_index_metadata_to_transit metadata)) + value) + [ + ("eavt-metadata", root.storage_eavt_metadata); + ("aevt-metadata", root.storage_aevt_metadata); + ("avet-metadata", root.storage_avet_metadata); + ] in Value.Map ([ diff --git a/shared/test/logseq_chat/logseq_storage_codec_test.ml b/shared/test/logseq_chat/logseq_storage_codec_test.ml index 1d12ec8f..51aa8950 100644 --- a/shared/test/logseq_chat/logseq_storage_codec_test.ml +++ b/shared/test/logseq_chat/logseq_storage_codec_test.ml @@ -88,6 +88,9 @@ let root () : Ds.storage_root = storage_eavt = "3"; storage_aevt = "4"; storage_avet = "5"; + storage_eavt_metadata = Some { Ds.storage_index_count = 4; storage_index_shift = 1 }; + storage_aevt_metadata = Some { Ds.storage_index_count = 4; storage_index_shift = 1 }; + storage_avet_metadata = Some { Ds.storage_index_count = 4; storage_index_shift = 1 }; storage_duplicate_datoms = [ datom (Ds.String "duplicate") ]; storage_max_addr = 6; storage_branching_factor = 32; From 4b2c828894c7108989004c1987aa5c9d9d1916c9 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 13:07:19 +0000 Subject: [PATCH 06/28] ci: bypass setup-ocaml on opam root+switch cache hit; repin drive to merged SHA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache ~/.opam (opam root: config, repo index, download cache) under a compiler-only key. When it and the _opam switch cache both hit, skip ocaml/setup-ocaml entirely — its unconditional 'opam update' costs ~40s of the ~64s step — and just install the opam 2.6.0 binary. Saves ~60s of fixed overhead per CI run. - Point the drive pin at the rebase-merged commits on drive main (7f407b0, tree-identical to the previous branch SHA). --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++---- logseq_chat.opam | 2 +- logseq_chat.opam.locked | 4 ++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32292062..2ea77fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,12 @@ jobs: - name: System dependencies run: sudo apt-get install -y libsqlite3-dev libffi-dev pkg-config - - uses: ocaml/setup-ocaml@v3 + - name: Restore opam root + id: opam-root + uses: actions/cache/restore@v4 with: - ocaml-compiler: "5.5" - opam-pin: false - # "cache" (default true) caches the opam root + switch. + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 - name: Restore opam switch id: opam-switch @@ -30,6 +31,33 @@ jobs: path: _opam key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + # setup-ocaml runs `opam update` unconditionally (~40s). When both the + # root and the switch are cache hits the env is already complete, so + # only the opam binary is needed. + - name: Install opam binary + if: steps.opam-root.outputs.cache-hit == 'true' && steps.opam-switch.outputs.cache-hit == 'true' + run: | + mkdir -p ~/.local/bin + curl -fsSL https://github.com/ocaml/opam/releases/download/2.6.0/opam-2.6.0-x86_64-linux -o ~/.local/bin/opam + chmod +x ~/.local/bin/opam + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + eval $(opam env) + opam --version && ocaml -version + + - uses: ocaml/setup-ocaml@v3 + if: steps.opam-root.outputs.cache-hit != 'true' || steps.opam-switch.outputs.cache-hit != 'true' + with: + ocaml-compiler: "5.5" + opam-pin: false + # "cache" (default true) caches the opam root + switch. + + - name: Save opam root + if: steps.opam-root.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 + - name: Install opam dependencies if: steps.opam-switch.outputs.cache-hit != 'true' run: | diff --git a/logseq_chat.opam b/logseq_chat.opam index 00429a2d..e61619d7 100644 --- a/logseq_chat.opam +++ b/logseq_chat.opam @@ -32,7 +32,7 @@ pin-depends: [ ["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#473b6c6a81fc2ce9e9dbe9116abca14ef2ac2c25"] - ["drive.dev" "git+https://github.com/logseq/drive.git#a7da801a36d5bca761a5f9c350cc2130984dcebd"] + ["drive.dev" "git+https://github.com/logseq/drive.git#7f407b07f18e68596cb0dcb3ba1781cb0572a2bb"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#976b40f1770a65b3464df1ef38d1550f1d8a43dd"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#553dea6ed8694352527a189747f787365469c9cb"] ["angstrom.dev" "git+https://github.com/logseq/angstrom.git#3be9b966dc2bc9ccf9948d17a7b0df1cb526de15"] diff --git a/logseq_chat.opam.locked b/logseq_chat.opam.locked index 6120b30c..75b81639 100644 --- a/logseq_chat.opam.locked +++ b/logseq_chat.opam.locked @@ -323,7 +323,7 @@ pin-depends: [ ] [ "drive.dev" - "git+https://github.com/logseq/drive.git#a7da801a36d5bca761a5f9c350cc2130984dcebd" + "git+https://github.com/logseq/drive.git#7f407b07f18e68596cb0dcb3ba1781cb0572a2bb" ] [ "dune-build-info.3.24.2" @@ -1482,7 +1482,7 @@ x-opam-monorepo-duniverse-dirs: [ "angstrom" ] [ - "git+https://github.com/logseq/drive.git#a7da801a36d5bca761a5f9c350cc2130984dcebd" + "git+https://github.com/logseq/drive.git#7f407b07f18e68596cb0dcb3ba1781cb0572a2bb" "drive" ] [ From 0efbd5c2078a8ef86c154cba65cb06d79611c693 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 13:45:38 +0000 Subject: [PATCH 07/28] Add iOS/Android e2e CI jobs and four more headless drive scenarios e2e.yml adds ios-e2e (macos-15, smoke module by default) and android-e2e (ubuntu + KVM emulator, capture module), triggered on PRs, main pushes, nightly (full suite), and workflow_dispatch with module inputs. Both jobs reuse the opam root/switch and duniverse caches from ci.yml; the OCaml cross toolchains and the Flutter/Gradle caches are keyed separately. Scripts: resolve dune via $DUNE/PATH first so CI's local _opam switch (unnamed, not 5.5.0) works; fix repo_root/Flutter -> flutter which broke on case-sensitive Linux. New drive scenarios: encrypted-graph-unlock, search-nodes, graph-switch-delete, journal-outliner. The host stub now seeds two journal rows and answers TapOutlinerBlockEffect with an editing projection. Selector gotcha fixed: text:"Confirm" substring-matches button.graph-add.confirm's a11y id; use prop:text=Confirm for exact. --- .github/workflows/e2e.yml | 338 ++++++++++++++++++ scripts/build-mobile-ocaml.sh | 8 +- scripts/test-android-e2e-runner.sh | 2 +- scripts/test-android-e2e.sh | 14 +- scripts/test-ios-e2e.sh | 10 +- .../drive/encrypted-graph-unlock.drive | 13 + .../drive/graph-switch-delete.drive | 30 ++ .../logseq_chat/drive/journal-outliner.drive | 17 + .../test/logseq_chat/drive/search-nodes.drive | 16 + .../test/logseq_chat/drive_scenario_test.ml | 62 ++++ 10 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 shared/test/logseq_chat/drive/encrypted-graph-unlock.drive create mode 100644 shared/test/logseq_chat/drive/graph-switch-delete.drive create mode 100644 shared/test/logseq_chat/drive/journal-outliner.drive create mode 100644 shared/test/logseq_chat/drive/search-nodes.drive diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..7e7e8296 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,338 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + ios_module: + description: "iOS e2e module (scripts/test-ios-e2e-suite.sh --list)" + required: false + default: "smoke" + android_module: + description: "Android e2e module (scripts/test-android-e2e.sh --list)" + required: false + default: "capture" + schedule: + # Nightly full-suite coverage; PRs and pushes run the small smoke modules. + - cron: "37 3 * * *" + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IOS_MODULE: ${{ inputs.ios_module || (github.event_name == 'schedule' && 'all' || 'smoke') }} + ANDROID_MODULE: ${{ inputs.android_module || (github.event_name == 'schedule' && 'all' || 'capture') }} + +jobs: + ios-e2e: + name: iOS e2e (${{ env.IOS_MODULE }}) + runs-on: macos-15 + timeout-minutes: 90 + env: + LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} + steps: + - uses: actions/checkout@v4 + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Restore opam root + id: opam-root + uses: actions/cache/restore@v4 + with: + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 + + - name: Restore opam switch + id: opam-switch + uses: actions/cache/restore@v4 + with: + path: _opam + key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + + # See ci.yml: on a dual cache hit only the opam binary is needed. + - name: Install opam binary + if: steps.opam-root.outputs.cache-hit == 'true' && steps.opam-switch.outputs.cache-hit == 'true' + run: | + brew install opam + eval $(opam env) + opam --version && ocaml -version + + - uses: ocaml/setup-ocaml@v3 + if: steps.opam-root.outputs.cache-hit != 'true' || steps.opam-switch.outputs.cache-hit != 'true' + with: + ocaml-compiler: "5.5" + opam-pin: false + + - name: Save opam root + if: steps.opam-root.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 + + - name: Install opam dependencies + if: steps.opam-switch.outputs.cache-hit != 'true' + run: | + eval $(opam env) + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_COUNT=3 + export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" + export GIT_CONFIG_KEY_1="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_1="ssh://git@github.com/" + export GIT_CONFIG_KEY_2="credential.helper" + export GIT_CONFIG_VALUE_2='!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + opam pin add -n -y melange-edn-core git+https://github.com/logseq/melange-edn.git#main + opam pin add -n -y melange-edn-native git+https://github.com/logseq/melange-edn.git#main + opam pin add -n -y melange-transit-core git+https://github.com/logseq/melange-transit.git#main + opam pin add -n -y melange-transit-native git+https://github.com/logseq/melange-transit.git#main + opam install -y melange.7.0.1-55 + opam install . --deps-only --yes --with-test --assume-depexts + opam install -y opam-monorepo.0.3.6 + + - name: Save opam switch + if: steps.opam-switch.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: _opam + key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + + - name: Restore duniverse + id: duniverse + uses: actions/cache/restore@v4 + with: + path: duniverse + key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + + - name: Pull vendored dependencies + if: steps.duniverse.outputs.cache-hit != 'true' + run: | + set -e + eval $(opam env) + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git@github.com:" + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" + opam monorepo pull -y + for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do + url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" + if [ -d "duniverse/$name" ]; then + continue + fi + git clone --quiet "https://github.com/$url" "duniverse/$name" + if ! git -C "duniverse/$name" checkout --quiet "$sha"; then + git -C "duniverse/$name" fetch --quiet "https://github.com/$url" "$sha" + git -C "duniverse/$name" checkout --quiet FETCH_HEAD + fi + done + + - name: Save duniverse + if: steps.duniverse.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: duniverse + key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + + - name: Restore iOS OCaml toolchain + id: ios-toolchain + uses: actions/cache/restore@v4 + with: + path: _build/apple-toolchains + key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0 + + - name: Build iOS app for the simulator + run: | + eval $(opam env) + ./scripts/build-mobile-ios-simulator.sh + + - name: Save iOS OCaml toolchain + if: steps.ios-toolchain.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: _build/apple-toolchains + key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0 + + - name: Boot an iOS simulator + run: | + runtime_id=$(xcrun simctl list -j runtimes | python3 -c 'import json,sys; rs=[r for r in json.load(sys.stdin)["runtimes"] if r.get("isAvailable") and ".iOS-" in r["identifier"]]; rs.sort(key=lambda r: [int(x) for x in r["version"].split(".")]); print(rs[-1]["identifier"])') + udid=$(xcrun simctl create "CI iPhone" \ + "com.apple.CoreSimulator.SimDeviceType.iPhone-16" "$runtime_id") + xcrun simctl boot "$udid" + xcrun simctl bootstatus "$udid" -b + echo "LOGSEQ_CHAT_IOS_SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + + - name: Run iOS e2e module + run: | + eval $(opam env) + LOGSEQ_CHAT_IOS_SKIP_BUILD=1 ./scripts/test-ios-e2e-suite.sh "$IOS_MODULE" + + android-e2e: + name: Android e2e (${{ env.ANDROID_MODULE }}) + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} + LOGSEQ_CHAT_ANDROID_ABI: x86_64 + steps: + - uses: actions/checkout@v4 + + - name: System dependencies + run: sudo apt-get install -y libsqlite3-dev libffi-dev pkg-config + + - name: Restore opam root + id: opam-root + uses: actions/cache/restore@v4 + with: + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 + + - name: Restore opam switch + id: opam-switch + uses: actions/cache/restore@v4 + with: + path: _opam + key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + + - name: Install opam binary + if: steps.opam-root.outputs.cache-hit == 'true' && steps.opam-switch.outputs.cache-hit == 'true' + run: | + mkdir -p ~/.local/bin + curl -fsSL https://github.com/ocaml/opam/releases/download/2.6.0/opam-2.6.0-x86_64-linux -o ~/.local/bin/opam + chmod +x ~/.local/bin/opam + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + eval $(opam env) + opam --version && ocaml -version + + - uses: ocaml/setup-ocaml@v3 + if: steps.opam-root.outputs.cache-hit != 'true' || steps.opam-switch.outputs.cache-hit != 'true' + with: + ocaml-compiler: "5.5" + opam-pin: false + + - name: Save opam root + if: steps.opam-root.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 + + - name: Install opam dependencies + if: steps.opam-switch.outputs.cache-hit != 'true' + run: | + eval $(opam env) + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_COUNT=3 + export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" + export GIT_CONFIG_KEY_1="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_1="ssh://git@github.com/" + export GIT_CONFIG_KEY_2="credential.helper" + export GIT_CONFIG_VALUE_2='!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + opam pin add -n -y melange-edn-core git+https://github.com/logseq/melange-edn.git#main + opam pin add -n -y melange-edn-native git+https://github.com/logseq/melange-edn.git#main + opam pin add -n -y melange-transit-core git+https://github.com/logseq/melange-transit.git#main + opam pin add -n -y melange-transit-native git+https://github.com/logseq/melange-transit.git#main + opam install -y melange.7.0.1-55 + opam install . --deps-only --yes --with-test --assume-depexts + opam install -y opam-monorepo.0.3.6 + + - name: Save opam switch + if: steps.opam-switch.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: _opam + key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + + - name: Restore duniverse + id: duniverse + uses: actions/cache/restore@v4 + with: + path: duniverse + key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + + - name: Pull vendored dependencies + if: steps.duniverse.outputs.cache-hit != 'true' + run: | + set -e + eval $(opam env) + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git@github.com:" + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" + git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" + opam monorepo pull -y + for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do + url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" + if [ -d "duniverse/$name" ]; then + continue + fi + git clone --quiet "https://github.com/$url" "duniverse/$name" + if ! git -C "duniverse/$name" checkout --quiet "$sha"; then + git -C "duniverse/$name" fetch --quiet "https://github.com/$url" "$sha" + git -C "duniverse/$name" checkout --quiet FETCH_HEAD + fi + done + + - name: Save duniverse + if: steps.duniverse.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: duniverse + key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Accept Android SDK licenses + run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Restore Android OCaml toolchain + id: android-toolchain + uses: actions/cache/restore@v4 + with: + path: | + _build/android-toolchain + _build/android-dependency-sources + _build/android-sqlite-host + key: android-toolchain-${{ runner.os }}-x86_64-5.5.0 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Android e2e module + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-boot-timeout: 600 + script: | + eval $(opam env) + ./scripts/test-android-e2e.sh "$ANDROID_MODULE" + + - name: Save Android OCaml toolchain + if: steps.android-toolchain.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + _build/android-toolchain + _build/android-dependency-sources + _build/android-sqlite-host + key: android-toolchain-${{ runner.os }}-x86_64-5.5.0 diff --git a/scripts/build-mobile-ocaml.sh b/scripts/build-mobile-ocaml.sh index 5a1893dc..4f82e893 100755 --- a/scripts/build-mobile-ocaml.sh +++ b/scripts/build-mobile-ocaml.sh @@ -11,7 +11,13 @@ repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) target_prefix=$(cd "$1" && pwd) context=$2 target="_build/$context/shared/native/logseq_chat_mobile_entry.exe.o" -dune=${DUNE:-$(opam exec --switch=5.5.0 -- which dune)} +if [[ -n ${DUNE:-} ]]; then + dune=$DUNE +elif command -v dune >/dev/null 2>&1; then + dune=$(command -v dune) +else + dune=$(opam exec --switch=5.5.0 -- which dune) +fi profile=${DUNE_PROFILE:-dev} [[ -x $target_prefix/bin/ocamlc ]] || { diff --git a/scripts/test-android-e2e-runner.sh b/scripts/test-android-e2e-runner.sh index 09f1cf27..8c5ab2f8 100755 --- a/scripts/test-android-e2e-runner.sh +++ b/scripts/test-android-e2e-runner.sh @@ -189,7 +189,7 @@ PATH="$mock_bin:$PATH" \ LOGSEQ_CHAT_ANDROID_E2E_SKIP_INSTALL=1 \ LOGSEQ_CHAT_ANDROID_E2E_SKIP_VISUAL_GATES=1 \ "$runner" signed-out >/dev/null -[[ $(<"$flutter_args") == "$repo_root/Flutter|build apk --debug" ]] \ +[[ $(<"$flutter_args") == "$repo_root/flutter|build apk --debug" ]] \ || die "Android E2E runner did not build the Flutter debug APK" : >"$adb_args" diff --git a/scripts/test-android-e2e.sh b/scripts/test-android-e2e.sh index b0806203..e211c1eb 100755 --- a/scripts/test-android-e2e.sh +++ b/scripts/test-android-e2e.sh @@ -269,7 +269,7 @@ fi if [[ ${LOGSEQ_CHAT_ANDROID_E2E_SKIP_BUILD:-0} != 1 ]]; then ( - cd "$repo_root/Flutter" + cd "$repo_root/flutter" ANDROID_SERIAL=$device flutter build apk --debug ) fi @@ -346,12 +346,16 @@ seed_android_fixture() { local_database=$(mktemp "${TMPDIR:-/tmp}/logseq-chat-android-graph.XXXXXX") temporary_files+=("$local_database") adb -s "$device" exec-out run-as "$app_id" cat "$graph_database" >"$local_database" + local dune_seed=() + if command -v dune >/dev/null 2>&1; then + dune_seed=(dune exec) + else + dune_seed=(opam exec --switch=5.5.0 -- dune exec) + fi if [[ -n $seed_mode ]]; then - opam exec --switch=5.5.0 -- \ - dune exec shared/native/logseq_chat_e2e_seed.exe -- "$local_database" "$seed_mode" + "${dune_seed[@]}" shared/native/logseq_chat_e2e_seed.exe -- "$local_database" "$seed_mode" else - opam exec --switch=5.5.0 -- \ - dune exec shared/native/logseq_chat_e2e_seed.exe -- "$local_database" + "${dune_seed[@]}" shared/native/logseq_chat_e2e_seed.exe -- "$local_database" fi local remote_database="/data/local/tmp/logseq-chat-android-graph-$$.sqlite" diff --git a/scripts/test-ios-e2e.sh b/scripts/test-ios-e2e.sh index d20805c8..6881db52 100755 --- a/scripts/test-ios-e2e.sh +++ b/scripts/test-ios-e2e.sh @@ -150,11 +150,15 @@ if [[ ${LOGSEQ_CHAT_IOS_E2E_SEED_GRAPH:-0} == 1 || -n $fixture_seed_mode ]]; the [[ -n $graph_database && -f ${graph_database%/graph.sqlite}/sync.checkpoint ]] \ || die "timed out waiting for the graph snapshot import to finish" xcrun simctl terminate "$device" "$app_id" >/dev/null 2>&1 || true + if command -v dune >/dev/null 2>&1; then + dune_seed=(dune exec) + else + dune_seed=(opam exec --switch=5.5.0 -- dune exec) + fi if [[ -n $fixture_seed_mode ]]; then - opam exec --switch=5.5.0 -- \ - dune exec shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" "$fixture_seed_mode" + "${dune_seed[@]}" shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" "$fixture_seed_mode" else - opam exec --switch=5.5.0 -- dune exec shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" + "${dune_seed[@]}" shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" fi if [[ -n $seed_cache_dir ]]; then mkdir -p "$seed_cache_dir/$seed_cache_key" diff --git a/shared/test/logseq_chat/drive/encrypted-graph-unlock.drive b/shared/test/logseq_chat/drive/encrypted-graph-unlock.drive new file mode 100644 index 00000000..15cfadd6 --- /dev/null +++ b/shared/test/logseq_chat/drive/encrypted-graph-unlock.drive @@ -0,0 +1,13 @@ +# Create an encrypted graph, land on the unlock sheet, type the +# password, unlock, and reach the application shell. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +wait prop:accessibility-identifier=sheet.graph-create +expect prop:accessibility-identifier=toggle.graph-encryption +type prop:accessibility-identifier=field.graph-name "Locked" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=sheet.graph-unlock +type prop:accessibility-identifier=field.graph-password "pw1234" +press prop:accessibility-identifier=button.graph-unlock +wait prop:accessibility-identifier=application.shell +expect prop:accessibility-identifier=title.main diff --git a/shared/test/logseq_chat/drive/graph-switch-delete.drive b/shared/test/logseq_chat/drive/graph-switch-delete.drive new file mode 100644 index 00000000..c7cf0c64 --- /dev/null +++ b/shared/test/logseq_chat/drive/graph-switch-delete.drive @@ -0,0 +1,30 @@ +# Create Alpha, open the Graphs screen, create Beta, switch back to +# Alpha through the sidebar graph menu, then delete a graph row from +# the picker via its context menu. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +wait prop:accessibility-identifier=field.graph-name +type prop:accessibility-identifier=field.graph-name "Alpha" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +expect prop:accessibility-identifier=title.main +press prop:accessibility-identifier=link.sidebar.graphs +wait prop:accessibility-identifier=graph.Alpha +press prop:accessibility-identifier=button.graph-add +wait prop:accessibility-identifier=field.graph-name +type prop:accessibility-identifier=field.graph-name "Beta" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +press prop:accessibility-identifier=button.graph-switch +wait prop:accessibility-identifier=menu.graph-switch +expect prop:accessibility-identifier=menu.graph.Alpha +expect prop:accessibility-identifier=menu.graph.Beta +press prop:accessibility-identifier=menu.graph.Alpha +wait prop:accessibility-identifier=application.shell +press prop:accessibility-identifier=link.sidebar.graphs +wait prop:accessibility-identifier=graph.Beta +press kind:menu-item&text:"Delete local graph" +wait kind:dialog&text:"Delete local graph" +press kind:button&prop:text=Confirm +expect-absent prop:accessibility-identifier=graph.Alpha +expect prop:accessibility-identifier=graph.Beta diff --git a/shared/test/logseq_chat/drive/journal-outliner.drive b/shared/test/logseq_chat/drive/journal-outliner.drive new file mode 100644 index 00000000..a51181b9 --- /dev/null +++ b/shared/test/logseq_chat/drive/journal-outliner.drive @@ -0,0 +1,17 @@ +# Create a local graph, verify the seeded journal blocks render in the +# outliner, tap a block to start editing, and check the editor toolbar +# surfaces. The host stub answers TapOutlinerBlockEffect with a snapshot +# carrying projection_outliner_editing — the same response the native +# bridge would deliver. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +wait prop:accessibility-identifier=field.graph-name +type prop:accessibility-identifier=field.graph-name "Drive Graph" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +wait prop:accessibility-identifier=outliner.block.journal-block-1 +expect prop:accessibility-identifier=outliner.block.journal-block-2 +press prop:accessibility-identifier=outliner.block-action.journal-block-1 +wait prop:accessibility-identifier=toolbar.outliner.editor +expect prop:accessibility-identifier=button.outliner.editor.task +expect prop:accessibility-identifier=button.outliner.editor.indent diff --git a/shared/test/logseq_chat/drive/search-nodes.drive b/shared/test/logseq_chat/drive/search-nodes.drive new file mode 100644 index 00000000..69251864 --- /dev/null +++ b/shared/test/logseq_chat/drive/search-nodes.drive @@ -0,0 +1,16 @@ +# Create a graph, open the native search screen, submit a query via the +# native-search extension event, and verify results render. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +type prop:accessibility-identifier=field.graph-name "Alpha" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +expect prop:accessibility-identifier=button.search +press prop:accessibility-identifier=button.search +wait prop:accessibility-identifier=screen.search +expect prop:accessibility-identifier=search.empty +ext ext:native-search-presentation native-search-presentation query-changed '{"query":"alpha"}' +wait prop:accessibility-identifier=search.section.pages +expect prop:accessibility-identifier=search.result.page-1 +expect prop:accessibility-identifier=search.section.blocks +expect prop:accessibility-identifier=search.result.block-1 diff --git a/shared/test/logseq_chat/drive_scenario_test.ml b/shared/test/logseq_chat/drive_scenario_test.ml index b55a6b2b..a9742848 100644 --- a/shared/test/logseq_chat/drive_scenario_test.ml +++ b/shared/test/logseq_chat/drive_scenario_test.ml @@ -16,6 +16,15 @@ let graph_entry id name encrypted = let catalog_projection graphs = { (App_test.empty_core_projection ()) with Model.graphs } +let journal_rows = + [ + (App_test.journal_outline_row "journal-block-1" "journal" + "First seeded block" "Today" 20260828 0 + |> fun (row : Model.outline_row) -> { row with has_children = true }); + App_test.journal_outline_row "journal-block-2" "journal" + "Second seeded block" "Today" 20260828 1; + ] + let graph_projection ~graph_id ~graph_name ~encrypted ~unlocked graphs = { (App_test.empty_core_projection ()) with @@ -24,6 +33,8 @@ let graph_projection ~graph_id ~graph_name ~encrypted ~unlocked graphs = graphs; is_graph_encrypted = encrypted; is_graph_unlocked = unlocked; + journal_outliner_rows = journal_rows; + outliner_rows = journal_rows; } type host = { mutable graphs : Model.graph list } @@ -52,6 +63,57 @@ let host_responses host model = (graph_projection ~graph_id:name ~graph_name:name ~encrypted ~unlocked:(not encrypted) host.graphs) :: resolved + | Model.SearchNodesEffect (id, query) -> + Model.DequeueEffect id + :: Model.ApplySearchResults + (query + , [ + { + Model.hit_uuid = "page-1"; + hit_title = "Alpha Doc"; + breadcrumb = ""; + breadcrumbs = []; + is_page = true; + }; + { + Model.hit_uuid = "block-1"; + hit_title = "alpha block"; + breadcrumb = "Alpha Doc"; + breadcrumbs = [ { Model.uuid = "page-1"; title = "Alpha Doc" } ]; + is_page = false; + }; + ]) + :: resolved + | Model.DeleteLocalGraphEffect (id, graph_id) -> + host.graphs <- + List.filter (fun (g : Model.graph) -> g.id <> graph_id) host.graphs; + Model.DequeueEffect id + :: Model.ApplyLocalGraphIds + (List.map (fun (g : Model.graph) -> g.id) host.graphs) + :: Model.ApplyCoreSnapshot (catalog_projection host.graphs) + :: resolved + | Model.TapOutlinerBlockEffect (id, uuid) -> ( + match + Option.map find_graph model.Model.selected_graph_id |> Option.join + with + | Some g -> + Model.DequeueEffect id + :: Model.ApplyCoreSnapshot + { + (graph_projection ~graph_id:g.id ~graph_name:g.name + ~encrypted:g.is_encrypted ~unlocked:(not g.is_encrypted) + host.graphs) + with + projection_outliner_editing = + Some + { + Model.editing_uuid = uuid; + editing_title = ""; + caret_utf16_offset = 0; + }; + } + :: resolved + | None -> Model.DequeueEffect id :: resolved) | Model.OpenGraphEffect (id, graph_id) -> ( match find_graph graph_id with | Some g -> From 9dd9b81ac579ca9c4672d1b77b679323cc8ec1e3 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 13:48:15 +0000 Subject: [PATCH 08/28] ci: fix e2e workflow job names (env context not allowed in jobs..name) --- .github/workflows/e2e.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7e7e8296..217a48dd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -28,7 +28,7 @@ env: jobs: ios-e2e: - name: iOS e2e (${{ env.IOS_MODULE }}) + name: iOS e2e runs-on: macos-15 timeout-minutes: 90 env: @@ -171,7 +171,7 @@ jobs: LOGSEQ_CHAT_IOS_SKIP_BUILD=1 ./scripts/test-ios-e2e-suite.sh "$IOS_MODULE" android-e2e: - name: Android e2e (${{ env.ANDROID_MODULE }}) + name: Android e2e runs-on: ubuntu-latest timeout-minutes: 90 env: From 1312b0599c1e637f70a4efb7a8eec0061dd55bbf Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 13:57:30 +0000 Subject: [PATCH 09/28] ci: bypass opam plugin shim for monorepo pull (fails under restored caches) --- .github/workflows/ci.yml | 4 +++- .github/workflows/e2e.yml | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ea77fa7..8851b0c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,7 +109,9 @@ jobs: git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" - opam monorepo pull -y + # Call the binary directly: the `opam monorepo` plugin shim rejects + # plugins built under a different opam version (restored caches). + opam exec -- opam-monorepo pull for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" # opam monorepo pull already vendors lockfile entries at the right diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 217a48dd..0b8062b3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -117,7 +117,9 @@ jobs: git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" - opam monorepo pull -y + # Call the binary directly: the `opam monorepo` plugin shim rejects + # plugins built under a different opam version (restored caches). + opam exec -- opam-monorepo pull for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" if [ -d "duniverse/$name" ]; then @@ -261,7 +263,9 @@ jobs: git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" - opam monorepo pull -y + # Call the binary directly: the `opam monorepo` plugin shim rejects + # plugins built under a different opam version (restored caches). + opam exec -- opam-monorepo pull for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" if [ -d "duniverse/$name" ]; then From f40da10920eafc3d3f9bbe126a8c292a9480fcff Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 14:15:51 +0000 Subject: [PATCH 10/28] ci: pass git ssh->https rewrite via env to emulator-runner script (pub get ssh clone fix) --- .github/workflows/e2e.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0b8062b3..33550416 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -320,6 +320,19 @@ jobs: - name: Run Android e2e module uses: reactivecircus/android-emulator-runner@v2 + env: + # pub get clones the lui git dep over ssh; rewrite to https + PAT + # credential helper (file-based git config doesn't reliably reach + # the emulator-runner's script environment). + GIT_CONFIG_COUNT: 4 + GIT_CONFIG_KEY_0: url.https://github.com/.insteadOf + GIT_CONFIG_VALUE_0: git@github.com: + GIT_CONFIG_KEY_1: url.https://github.com/.insteadOf + GIT_CONFIG_VALUE_1: ssh://git@github.com/ + GIT_CONFIG_KEY_2: url.https://github.com/.insteadOf + GIT_CONFIG_VALUE_2: git+ssh://git@github.com/ + GIT_CONFIG_KEY_3: credential.helper + GIT_CONFIG_VALUE_3: '!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' with: api-level: 34 target: google_apis From 9d9b216ea51fa785079bd28460d2757832871017 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 14:17:23 +0000 Subject: [PATCH 11/28] ci: quote trailing-colon git url in env (yaml parse fix) --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 33550416..4be3934d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -326,7 +326,7 @@ jobs: # the emulator-runner's script environment). GIT_CONFIG_COUNT: 4 GIT_CONFIG_KEY_0: url.https://github.com/.insteadOf - GIT_CONFIG_VALUE_0: git@github.com: + GIT_CONFIG_VALUE_0: "git@github.com:" GIT_CONFIG_KEY_1: url.https://github.com/.insteadOf GIT_CONFIG_VALUE_1: ssh://git@github.com/ GIT_CONFIG_KEY_2: url.https://github.com/.insteadOf From ef8eeab7d0619e33e38296082b65353b7b164148 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 14:49:51 +0000 Subject: [PATCH 12/28] ci: vendor duniverse once on linux and share cross-OS (opam-monorepo unbuildable on macOS arm64); derive dune-workspace.mobile switch from env --- .github/workflows/e2e.yml | 150 ++++++++++++++++++++++++---------- scripts/build-mobile-ocaml.sh | 12 ++- 2 files changed, 120 insertions(+), 42 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4be3934d..6a335637 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,20 +27,18 @@ env: ANDROID_MODULE: ${{ inputs.android_module || (github.event_name == 'schedule' && 'all' || 'capture') }} jobs: - ios-e2e: - name: iOS e2e - runs-on: macos-15 - timeout-minutes: 90 + # opam-monorepo.0.3.6 (needed for `monorepo pull`) vendors an old base whose + # -mpopcnt C flag fails to compile on macOS arm64. duniverse/ is pure source + # — platform-independent — so vendor it once on Linux and share via cache. + duniverse: + name: Vendor duniverse + runs-on: ubuntu-latest + timeout-minutes: 60 env: LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} steps: - uses: actions/checkout@v4 - - name: Install Maestro - run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - - name: Restore opam root id: opam-root uses: actions/cache/restore@v4 @@ -55,11 +53,13 @@ jobs: path: _opam key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} - # See ci.yml: on a dual cache hit only the opam binary is needed. - name: Install opam binary if: steps.opam-root.outputs.cache-hit == 'true' && steps.opam-switch.outputs.cache-hit == 'true' run: | - brew install opam + mkdir -p ~/.local/bin + curl -fsSL https://github.com/ocaml/opam/releases/download/2.6.0/opam-2.6.0-x86_64-linux -o ~/.local/bin/opam + chmod +x ~/.local/bin/opam + echo "$HOME/.local/bin" >> "$GITHUB_PATH" eval $(opam env) opam --version && ocaml -version @@ -92,6 +92,7 @@ jobs: opam pin add -n -y melange-transit-native git+https://github.com/logseq/melange-transit.git#main opam install -y melange.7.0.1-55 opam install . --deps-only --yes --with-test --assume-depexts + # 0.3.x matches the lockfile's x-opam-monorepo-version. opam install -y opam-monorepo.0.3.6 - name: Save opam switch @@ -107,6 +108,7 @@ jobs: with: path: duniverse key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + enableCrossOsArchive: true - name: Pull vendored dependencies if: steps.duniverse.outputs.cache-hit != 'true' @@ -138,6 +140,96 @@ jobs: with: path: duniverse key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + enableCrossOsArchive: true + + ios-e2e: + name: iOS e2e + needs: duniverse + runs-on: macos-15 + timeout-minutes: 90 + env: + LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} + LOGSEQ_CHAT_OPAM_SWITCH: ${{ github.workspace }} + steps: + - uses: actions/checkout@v4 + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Restore opam root + id: opam-root + uses: actions/cache/restore@v4 + with: + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 + + - name: Restore opam switch + id: opam-switch + uses: actions/cache/restore@v4 + with: + path: _opam + key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + + # See ci.yml: on a dual cache hit only the opam binary is needed. + - name: Install opam binary + if: steps.opam-root.outputs.cache-hit == 'true' && steps.opam-switch.outputs.cache-hit == 'true' + run: | + brew install opam + eval $(opam env) + opam --version && ocaml -version + + - uses: ocaml/setup-ocaml@v3 + if: steps.opam-root.outputs.cache-hit != 'true' || steps.opam-switch.outputs.cache-hit != 'true' + with: + ocaml-compiler: "5.5" + opam-pin: false + + - name: Save opam root + if: steps.opam-root.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.opam + key: opam-root-${{ runner.os }}-ocaml-5.5 + + - name: Install opam dependencies + if: steps.opam-switch.outputs.cache-hit != 'true' + run: | + eval $(opam env) + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_COUNT=3 + export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" + export GIT_CONFIG_KEY_1="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_1="ssh://git@github.com/" + export GIT_CONFIG_KEY_2="credential.helper" + export GIT_CONFIG_VALUE_2='!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' + opam pin add -n -y melange-edn-core git+https://github.com/logseq/melange-edn.git#main + opam pin add -n -y melange-edn-native git+https://github.com/logseq/melange-edn.git#main + opam pin add -n -y melange-transit-core git+https://github.com/logseq/melange-transit.git#main + opam pin add -n -y melange-transit-native git+https://github.com/logseq/melange-transit.git#main + opam install -y melange.7.0.1-55 + opam install . --deps-only --yes --with-test --assume-depexts + + - name: Save opam switch + if: steps.opam-switch.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: _opam + key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + + # Produced by the linux `duniverse` job — opam-monorepo doesn't build on + # macOS arm64, and a plain restore keeps this job off that toolchain. + - name: Restore duniverse + id: duniverse + uses: actions/cache/restore@v4 + with: + path: duniverse + key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + enableCrossOsArchive: true + + - name: Require vendored dependencies + if: steps.duniverse.outputs.cache-hit != 'true' + run: echo "duniverse cache miss; the duniverse job should have populated it" >&2 && exit 1 - name: Restore iOS OCaml toolchain id: ios-toolchain @@ -174,10 +266,12 @@ jobs: android-e2e: name: Android e2e + needs: duniverse runs-on: ubuntu-latest timeout-minutes: 90 env: LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} + LOGSEQ_CHAT_OPAM_SWITCH: ${{ github.workspace }} LOGSEQ_CHAT_ANDROID_ABI: x86_64 steps: - uses: actions/checkout@v4 @@ -238,7 +332,6 @@ jobs: opam pin add -n -y melange-transit-native git+https://github.com/logseq/melange-transit.git#main opam install -y melange.7.0.1-55 opam install . --deps-only --yes --with-test --assume-depexts - opam install -y opam-monorepo.0.3.6 - name: Save opam switch if: steps.opam-switch.outputs.cache-hit != 'true' @@ -247,43 +340,18 @@ jobs: path: _opam key: opam-switch-${{ runner.os }}-ocaml-5.5-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam') }} + # Produced by the `duniverse` job — shared via cross-OS cache. - name: Restore duniverse id: duniverse uses: actions/cache/restore@v4 with: path: duniverse key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + enableCrossOsArchive: true - - name: Pull vendored dependencies - if: steps.duniverse.outputs.cache-hit != 'true' - run: | - set -e - eval $(opam env) - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git@github.com:" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "ssh://git@github.com/" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "git+ssh://git@github.com/" - git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" - # Call the binary directly: the `opam monorepo` plugin shim rejects - # plugins built under a different opam version (restored caches). - opam exec -- opam-monorepo pull - for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do - url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" - if [ -d "duniverse/$name" ]; then - continue - fi - git clone --quiet "https://github.com/$url" "duniverse/$name" - if ! git -C "duniverse/$name" checkout --quiet "$sha"; then - git -C "duniverse/$name" fetch --quiet "https://github.com/$url" "$sha" - git -C "duniverse/$name" checkout --quiet FETCH_HEAD - fi - done - - - name: Save duniverse + - name: Require vendored dependencies if: steps.duniverse.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: duniverse - key: duniverse-${{ hashFiles('logseq_chat.opam.locked', 'logseq_chat.opam', '.github/workflows/ci.yml', '.github/workflows/e2e.yml') }} + run: echo "duniverse cache miss; the duniverse job should have populated it" >&2 && exit 1 - name: Set up Flutter uses: subosito/flutter-action@v2 diff --git a/scripts/build-mobile-ocaml.sh b/scripts/build-mobile-ocaml.sh index 4f82e893..7f741dbf 100755 --- a/scripts/build-mobile-ocaml.sh +++ b/scripts/build-mobile-ocaml.sh @@ -19,6 +19,16 @@ else dune=$(opam exec --switch=5.5.0 -- which dune) fi profile=${DUNE_PROFILE:-dev} +workspace="$repo_root/dune-workspace.mobile" + +# Local opam switches (e.g. CI's _opam) are addressed by their parent dir, not +# by name — the checked-in workspace pins the named `5.5.0` switch. +if [[ -n ${LOGSEQ_CHAT_OPAM_SWITCH:-} ]]; then + workspace="$repo_root/_build/dune-workspace.mobile" + mkdir -p "$(dirname "$workspace")" + sed "s|(switch [^)]*)|(switch $LOGSEQ_CHAT_OPAM_SWITCH)|" \ + "$repo_root/dune-workspace.mobile" > "$workspace" +fi [[ -x $target_prefix/bin/ocamlc ]] || { echo "error: target OCaml compiler is missing at $target_prefix" >&2 @@ -28,7 +38,7 @@ profile=${DUNE_PROFILE:-dev} env -u OPAM_SWITCH_PREFIX -u OCAMLPATH \ PATH="$target_prefix/bin:$PATH" "$dune" build \ --root "$repo_root" \ - --workspace "$repo_root/dune-workspace.mobile" \ + --workspace "$workspace" \ --profile "$profile" \ "$target" From d72e5f51b5a8e7c1be4ba8f3a91b7e667f6f7019 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 15:15:11 +0000 Subject: [PATCH 13/28] ci: export resolved opam switch name and dune path into e2e scripts (gradle exec env) --- .github/workflows/e2e.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6a335637..7e6fe049 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -241,6 +241,8 @@ jobs: - name: Build iOS app for the simulator run: | eval $(opam env) + export LOGSEQ_CHAT_OPAM_SWITCH="$(opam switch show)" + export DUNE="$(command -v dune)" ./scripts/build-mobile-ios-simulator.sh - name: Save iOS OCaml toolchain @@ -262,6 +264,8 @@ jobs: - name: Run iOS e2e module run: | eval $(opam env) + export LOGSEQ_CHAT_OPAM_SWITCH="$(opam switch show)" + export DUNE="$(command -v dune)" LOGSEQ_CHAT_IOS_SKIP_BUILD=1 ./scripts/test-ios-e2e-suite.sh "$IOS_MODULE" android-e2e: @@ -410,6 +414,8 @@ jobs: emulator-boot-timeout: 600 script: | eval $(opam env) + export LOGSEQ_CHAT_OPAM_SWITCH="$(opam switch show)" + export DUNE="$(command -v dune)" ./scripts/test-android-e2e.sh "$ANDROID_MODULE" - name: Save Android OCaml toolchain From b55ed0a255bc8420c0d1134d262126ea20c48279 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 15:47:37 +0000 Subject: [PATCH 14/28] ci: build libffi for mobile targets so ctypes-foreign compiles and links Apple marks libffi unavailable in iOS SDK headers and the Android NDK ships none; ctypes-foreign's discover.exe needs real libffi headers via pkg-config and the final app/.so link needs a real -lffi. Build a static libffi 3.4.8 per target (cached inside the toolchain dirs) and pass its pkgconfig dir + archive to the builds. --- .github/workflows/e2e.yml | 8 ++--- scripts/build-android-native.sh | 11 +++++- scripts/build-mobile-ios-simulator.sh | 12 ++++++- scripts/build-mobile-libffi.sh | 52 +++++++++++++++++++++++++++ scripts/build-mobile-ocaml.sh | 10 ++++-- 5 files changed, 85 insertions(+), 8 deletions(-) create mode 100755 scripts/build-mobile-libffi.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7e6fe049..f27949a4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -236,7 +236,7 @@ jobs: uses: actions/cache/restore@v4 with: path: _build/apple-toolchains - key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0 + key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} - name: Build iOS app for the simulator run: | @@ -250,7 +250,7 @@ jobs: uses: actions/cache/save@v4 with: path: _build/apple-toolchains - key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0 + key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} - name: Boot an iOS simulator run: | @@ -382,7 +382,7 @@ jobs: _build/android-toolchain _build/android-dependency-sources _build/android-sqlite-host - key: android-toolchain-${{ runner.os }}-x86_64-5.5.0 + key: android-toolchain-${{ runner.os }}-x86_64-5.5.0-${{ hashFiles('scripts/bootstrap-android-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} - name: Enable KVM run: | @@ -426,4 +426,4 @@ jobs: _build/android-toolchain _build/android-dependency-sources _build/android-sqlite-host - key: android-toolchain-${{ runner.os }}-x86_64-5.5.0 + key: android-toolchain-${{ runner.os }}-x86_64-5.5.0-${{ hashFiles('scripts/bootstrap-android-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} diff --git a/scripts/build-android-native.sh b/scripts/build-android-native.sh index 453ea448..a6c9a8d8 100755 --- a/scripts/build-android-native.sh +++ b/scripts/build-android-native.sh @@ -69,6 +69,14 @@ cd "$build_dir" "$ndk_bin/llvm-nm" sqlite3.o | grep "sqlite3Fts5Init" >/dev/null "$ndk_bin/llvm-ar" rcs libsqlite3.a sqlite3.o +# The NDK ships no libffi; ctypes-foreign needs real headers for its stub +# build and -lffi must resolve at the final .so link. +ffi_prefix=$("$repo_root/scripts/build-mobile-libffi.sh" \ + "$target_arch-linux-android" \ + "$repo_root/_build/android-toolchain/libffi-$target" \ + "$ndk_bin/clang --target=$target" \ + "$ndk_bin/clang++ --target=$target") + mkdir -p "$build_dir/pkgconfig" cat > "$build_dir/pkgconfig/sqlite3.pc" <&2 + exit 1 +} + +# Builds a static libffi for a mobile target and installs it under PREFIX so +# that ctypes-foreign's pkg-config discovery picks real (non-Apple-SDK) +# headers for the stub compile and the final link resolves -lffi. +# +# usage: build-mobile-libffi.sh HOST_TRIPLE INSTALL_PREFIX CC [CXX] +host_triple=${1:-} +prefix=${2:-} +cc=${3:-} +cxx=${4:-$cc} +[[ -n $host_triple && -n $prefix && -n $cc ]] \ + || die "usage: $0 HOST_TRIPLE INSTALL_PREFIX CC [CXX]" + +version=${LOGSEQ_CHAT_LIBFFI_VERSION:-3.4.8} +stamp="$prefix/.libffi-$version-complete" +if [[ -f $stamp && -f $prefix/lib/libffi.a ]]; then + echo "$prefix" + exit 0 +fi + +src_parent="$prefix/src" +src_dir="$src_parent/libffi-$version" +mkdir -p "$src_parent" +if [[ ! -d $src_dir ]]; then + curl -fsSL \ + "https://github.com/libffi/libffi/releases/download/v$version/libffi-$version.tar.gz" \ + | tar xz -C "$src_parent" +fi + +( + cd "$src_dir" + [[ -f Makefile ]] && make distclean >/dev/null 2>&1 || true + CC="$cc" CXX="$cxx" CFLAGS="-O2 -fPIC" ./configure \ + --host="$host_triple" \ + --prefix="$prefix" \ + --disable-shared \ + --enable-static \ + --disable-multi-os-directory + make -j"${LOGSEQ_CHAT_BUILD_JOBS:-8}" + make install +) + +touch "$stamp" +echo "$prefix" diff --git a/scripts/build-mobile-ocaml.sh b/scripts/build-mobile-ocaml.sh index 7f741dbf..06a8a687 100755 --- a/scripts/build-mobile-ocaml.sh +++ b/scripts/build-mobile-ocaml.sh @@ -23,13 +23,19 @@ workspace="$repo_root/dune-workspace.mobile" # Local opam switches (e.g. CI's _opam) are addressed by their parent dir, not # by name — the checked-in workspace pins the named `5.5.0` switch. -if [[ -n ${LOGSEQ_CHAT_OPAM_SWITCH:-} ]]; then +switch=${LOGSEQ_CHAT_OPAM_SWITCH:-} +if [[ -z $switch ]] && command -v opam >/dev/null 2>&1; then + switch=$(opam switch show 2>/dev/null || true) +fi +if [[ -n $switch ]]; then workspace="$repo_root/_build/dune-workspace.mobile" mkdir -p "$(dirname "$workspace")" - sed "s|(switch [^)]*)|(switch $LOGSEQ_CHAT_OPAM_SWITCH)|" \ + sed "s|(switch [^)]*)|(switch $switch)|" \ "$repo_root/dune-workspace.mobile" > "$workspace" fi +echo "build-mobile-ocaml: dune=$dune workspace=$workspace" >&2 + [[ -x $target_prefix/bin/ocamlc ]] || { echo "error: target OCaml compiler is missing at $target_prefix" >&2 exit 1 From 83d5a18e371514bffb9b9273892e2982d88f4754 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 16:07:06 +0000 Subject: [PATCH 15/28] ci: fix mobile build env issues - build-mobile-libffi: send build output to stderr so $() captures only the prefix - build-mobile-ocaml: resolve opam switch before the dune fallback so the gradle daemon env (no _opam/bin on PATH, no LOGSEQ_CHAT_OPAM_SWITCH) can still find dune and the right workspace via 'opam switch show' --- scripts/build-mobile-libffi.sh | 9 +++++---- scripts/build-mobile-ocaml.sh | 21 +++++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/build-mobile-libffi.sh b/scripts/build-mobile-libffi.sh index 0ad5c88b..1ca132c0 100755 --- a/scripts/build-mobile-libffi.sh +++ b/scripts/build-mobile-libffi.sh @@ -35,17 +35,18 @@ if [[ ! -d $src_dir ]]; then | tar xz -C "$src_parent" fi +# Build chatter goes to stderr so callers can safely capture only the prefix. ( cd "$src_dir" - [[ -f Makefile ]] && make distclean >/dev/null 2>&1 || true + [[ -f Makefile ]] && make distclean >&2 || true CC="$cc" CXX="$cxx" CFLAGS="-O2 -fPIC" ./configure \ --host="$host_triple" \ --prefix="$prefix" \ --disable-shared \ --enable-static \ - --disable-multi-os-directory - make -j"${LOGSEQ_CHAT_BUILD_JOBS:-8}" - make install + --disable-multi-os-directory >&2 + make -j"${LOGSEQ_CHAT_BUILD_JOBS:-8}" >&2 + make install >&2 ) touch "$stamp" diff --git a/scripts/build-mobile-ocaml.sh b/scripts/build-mobile-ocaml.sh index 06a8a687..bfdc5ff3 100755 --- a/scripts/build-mobile-ocaml.sh +++ b/scripts/build-mobile-ocaml.sh @@ -11,22 +11,27 @@ repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) target_prefix=$(cd "$1" && pwd) context=$2 target="_build/$context/shared/native/logseq_chat_mobile_entry.exe.o" +# Local opam switches (e.g. CI's _opam) are addressed by their parent dir, not +# by name — the checked-in workspace pins the named `5.5.0` switch. +switch=${LOGSEQ_CHAT_OPAM_SWITCH:-} +if [[ -z $switch ]] && command -v opam >/dev/null 2>&1; then + switch=$(opam switch show 2>/dev/null || true) +fi + if [[ -n ${DUNE:-} ]]; then dune=$DUNE elif command -v dune >/dev/null 2>&1; then dune=$(command -v dune) -else +elif command -v opam >/dev/null 2>&1 && [[ -n $switch ]]; then + dune=$(opam exec --switch="$switch" -- which dune) +elif command -v opam >/dev/null 2>&1; then dune=$(opam exec --switch=5.5.0 -- which dune) +else + echo "error: dune not found on PATH and opam is unavailable" >&2 + exit 1 fi profile=${DUNE_PROFILE:-dev} workspace="$repo_root/dune-workspace.mobile" - -# Local opam switches (e.g. CI's _opam) are addressed by their parent dir, not -# by name — the checked-in workspace pins the named `5.5.0` switch. -switch=${LOGSEQ_CHAT_OPAM_SWITCH:-} -if [[ -z $switch ]] && command -v opam >/dev/null 2>&1; then - switch=$(opam switch show 2>/dev/null || true) -fi if [[ -n $switch ]]; then workspace="$repo_root/_build/dune-workspace.mobile" mkdir -p "$(dirname "$workspace")" From f5928d7dd973d3a1f39c54fd0bcaa404b82fefb4 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 16:30:11 +0000 Subject: [PATCH 16/28] e2e: android smoke module + iOS ssh clone fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests/e2e/android-local-graph-setup.yaml (bypass hosted sign-in via 'Use without an account', create a local graph) and a 'smoke' module running signed-out + setup + capture on a fresh install — no backend credentials needed; make it the PR/push default. - Set git insteadOf + PAT credential helper at the ios-e2e job level so SwiftPM can clone the ssh:// lui dependency. --- .github/workflows/e2e.yml | 12 +++++- scripts/test-android-e2e.sh | 11 +++++- tests/e2e/android-local-graph-setup.yaml | 48 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/android-local-graph-setup.yaml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f27949a4..8bcdd0f7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -13,7 +13,7 @@ on: android_module: description: "Android e2e module (scripts/test-android-e2e.sh --list)" required: false - default: "capture" + default: "smoke" schedule: # Nightly full-suite coverage; PRs and pushes run the small smoke modules. - cron: "37 3 * * *" @@ -24,7 +24,7 @@ concurrency: env: IOS_MODULE: ${{ inputs.ios_module || (github.event_name == 'schedule' && 'all' || 'smoke') }} - ANDROID_MODULE: ${{ inputs.android_module || (github.event_name == 'schedule' && 'all' || 'capture') }} + ANDROID_MODULE: ${{ inputs.android_module || (github.event_name == 'schedule' && 'all' || 'smoke') }} jobs: # opam-monorepo.0.3.6 (needed for `monorepo pull`) vendors an old base whose @@ -150,6 +150,14 @@ jobs: env: LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} LOGSEQ_CHAT_OPAM_SWITCH: ${{ github.workspace }} + # SwiftPM clones lui over ssh://; rewrite to authenticated https. + GIT_CONFIG_COUNT: 3 + GIT_CONFIG_KEY_0: url.https://github.com/.insteadOf + GIT_CONFIG_VALUE_0: "git@github.com:" + GIT_CONFIG_KEY_1: url.https://github.com/.insteadOf + GIT_CONFIG_VALUE_1: "ssh://git@github.com/" + GIT_CONFIG_KEY_2: credential.helper + GIT_CONFIG_VALUE_2: '!f() { [ -n "$LOGSEQ_GITHUB_PAT" ] || exit 0; echo username=x-access-token; echo "password=$LOGSEQ_GITHUB_PAT"; }; f' steps: - uses: actions/checkout@v4 diff --git a/scripts/test-android-e2e.sh b/scripts/test-android-e2e.sh index e211c1eb..f4a20be8 100755 --- a/scripts/test-android-e2e.sh +++ b/scripts/test-android-e2e.sh @@ -5,6 +5,7 @@ set -euo pipefail repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) app_id=com.logseq.chat signed_out_flow=tests/e2e/android-signed-out.yaml +local_setup_flow=tests/e2e/android-local-graph-setup.yaml connect_flow=tests/e2e/android-staging-connect.yaml capture_flow=tests/e2e/android-capture-search.yaml composer_flow=tests/e2e/android-composer-lifecycle.yaml @@ -67,6 +68,14 @@ case $selector in needs_clear_state=1 needs_primary_button=1 ;; + smoke) + # Fresh-install smoke: bypass hosted sign-in, create a local graph, then + # run the capture assertions. Works without backend credentials. + flows=("$signed_out_flow" "$local_setup_flow" "$capture_flow") + needs_connection=0 + needs_clear_state=1 + needs_primary_button=0 + ;; connect) flows=("$connect_flow") needs_connection=1 @@ -188,7 +197,7 @@ case $selector in needs_primary_button=0 ;; --list) - echo "Modules: all signed-out connect capture composer autocomplete outliner hierarchy audio navigation graphs settings flashcards search rich-content youtube node-tag page-actions shortcuts sharing editor-regressions sharing-image" + echo "Modules: all signed-out smoke connect capture composer autocomplete outliner hierarchy audio navigation graphs settings flashcards search rich-content youtube node-tag page-actions shortcuts sharing editor-regressions sharing-image" printf '%s\n' \ "$signed_out_flow" \ "$connect_flow" \ diff --git a/tests/e2e/android-local-graph-setup.yaml b/tests/e2e/android-local-graph-setup.yaml new file mode 100644 index 00000000..657f6123 --- /dev/null +++ b/tests/e2e/android-local-graph-setup.yaml @@ -0,0 +1,48 @@ +appId: com.logseq.chat +--- +- launchApp +- runFlow: + when: + visible: + id: "button.hosted-sign-in" + commands: + - tapOn: + id: "button.hosted-sign-in" + - runFlow: + when: + visible: "Use without an account" + commands: + - tapOn: "Use without an account" + - extendedWaitUntil: + visible: + id: "screen.graph-picker" + timeout: 60000 +- runFlow: + when: + visible: + id: "screen.graph-picker" + commands: + - tapOn: + id: "button.graph-add" + - extendedWaitUntil: + visible: + id: "field.graph-name" + timeout: 30000 + - tapOn: + id: "field.graph-name" + - inputText: "android-e2e-smoke" + - hideKeyboard + - tapOn: + id: "toggle.graph-encryption" + - assertVisible: + id: "toggle.graph-encryption" + checked: false + - tapOn: + id: "button.graph-add.confirm" + - extendedWaitUntil: + visible: "android-e2e-smoke" + timeout: 60000 +- extendedWaitUntil: + visible: + id: "button.sidebar" + timeout: 60000 From 7447ad081adbba34c0a45a1749989c9e993b0bd2 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 16:50:07 +0000 Subject: [PATCH 17/28] e2e ios: select Xcode 26 for lui's swift-tools 6.2 --- .github/workflows/e2e.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8bcdd0f7..476b917f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -239,12 +239,17 @@ jobs: if: steps.duniverse.outputs.cache-hit != 'true' run: echo "duniverse cache miss; the duniverse job should have populated it" >&2 && exit 1 + - name: Select Xcode 26 + # lui declares swift-tools-version 6.2; the image default (16.4) ships + # Swift 6.1, so select the Xcode 26 toolchain (Swift 6.2). + run: sudo xcode-select --switch /Applications/Xcode_26.3.app + - name: Restore iOS OCaml toolchain id: ios-toolchain uses: actions/cache/restore@v4 with: path: _build/apple-toolchains - key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} + key: apple-toolchain-${{ runner.os }}-xcode26.3-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} - name: Build iOS app for the simulator run: | @@ -258,7 +263,7 @@ jobs: uses: actions/cache/save@v4 with: path: _build/apple-toolchains - key: apple-toolchain-${{ runner.os }}-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} + key: apple-toolchain-${{ runner.os }}-xcode26.3-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} - name: Boot an iOS simulator run: | From 628aa16a22e0dfc479511c3bcc911684000ba79b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 17:00:06 +0000 Subject: [PATCH 18/28] ci: retry opam-monorepo pull on transient download failures --- .github/workflows/ci.yml | 8 +++++++- .github/workflows/e2e.yml | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8851b0c2..14650f97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,13 @@ jobs: git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" # Call the binary directly: the `opam monorepo` plugin shim rejects # plugins built under a different opam version (restored caches). - opam exec -- opam-monorepo pull + # Retry: release-tarball downloads occasionally fail with HTTP 5xx. + for attempt in 1 2 3; do + opam exec -- opam-monorepo pull && break + [ "$attempt" -lt 3 ] || exit 1 + echo "opam-monorepo pull failed (attempt $attempt); retrying" >&2 + sleep 10 + done for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" # opam monorepo pull already vendors lockfile entries at the right diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 476b917f..26982f2b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -121,7 +121,13 @@ jobs: git config --global url."https://x-access-token:${LOGSEQ_GITHUB_PAT}@github.com/".insteadOf "https://github.com/" # Call the binary directly: the `opam monorepo` plugin shim rejects # plugins built under a different opam version (restored caches). - opam exec -- opam-monorepo pull + # Retry: release-tarball downloads occasionally fail with HTTP 5xx. + for attempt in 1 2 3; do + opam exec -- opam-monorepo pull && break + [ "$attempt" -lt 3 ] || exit 1 + echo "opam-monorepo pull failed (attempt $attempt); retrying" >&2 + sleep 10 + done for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[A-Za-z0-9._/-]+' logseq_chat.opam | sort -u); do url="${pin%%#*}"; sha="${pin##*#}"; name="$(basename "$url" .git)" if [ -d "duniverse/$name" ]; then From f2e46dccefecf8f52d27875a4a4de0a473d71274 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 10:00:20 -0700 Subject: [PATCH 19/28] fix(e2e): recreate app container tmp/ after data wipe --- scripts/test-ios-e2e.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test-ios-e2e.sh b/scripts/test-ios-e2e.sh index 6881db52..870b628a 100755 --- a/scripts/test-ios-e2e.sh +++ b/scripts/test-ios-e2e.sh @@ -80,6 +80,9 @@ fi data_container=$(xcrun simctl get_app_container "$device" "$app_id" data 2>/dev/null || true) if [[ -n $data_container && -d $data_container ]]; then rm -rf "$data_container/Documents" "$data_container/Library" "$data_container/tmp" + # iOS guarantees /tmp exists at runtime; the core writes the + # initial graph snapshot there via Filename.temp_file. + mkdir -p "$data_container/tmp" fi xcrun simctl spawn "$device" defaults delete "$app_id" logseq.baseURL >/dev/null 2>&1 || true xcrun simctl spawn "$device" defaults delete "$app_id" logseq.selectedGraphId >/dev/null 2>&1 || true From b660a7764e8e1c21fcae2790d35b1cba868f38ca Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 10:01:10 -0700 Subject: [PATCH 20/28] docs(e2e): document ios simulator e2e prerequisites in skill --- .agents/skills/testing-ios-simulator/SKILL.md | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index 14b92653..04bf7011 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -39,6 +39,32 @@ description: Build and launch the Logseq Chat iOS app in an iOS simulator on mac - First launch may show a local-network permission alert; dismiss it via Simulator UI. - The app opens to a "Logseq Chat / Sign in" screen (Cognito hosted UI); going past it requires credentials. +- `duniverse/` pulls only the lockfile dirs (129). The `logseq/*` pins NOT vendored + (`datascript-ocaml`, `lg`, `lui`, `mldoc`, `persistent-sorted-set-ocaml`, + `signal-lg`) must be cloned manually into `duniverse/` at the SHAs listed in + `logseq_chat.opam` — same loop as `.github/workflows/e2e.yml`. A fresh + `opam-monorepo pull` wipes these dirs, so re-clone them after each pull. +- `scripts/build-mobile-ocaml.sh` regenerates `_build/dune-workspace.mobile` from + `dune-workspace.mobile`; set `LOGSEQ_CHAT_OPAM_SWITCH=5.5.0` when running outside + `opam exec`. + +## Maestro e2e (test-ios-e2e-suite.sh) +- Install Maestro: `curl -fsSL https://get.maestro.mobile.dev | bash` → `~/.maestro/bin`. +- The e2e flows hit the app's sync server at `http://127.0.0.1:8787`. That server is + `deps/db-sync` in the `logseq` repo, and it MUST run from the + `feature/native-mobile-app` branch (the canonical `../logseq-1` checkout) — the app + requires `schema-version` in the `/sync//snapshot/download` response, which only + exists there (`main` returns without it → "Add sync graph" fails with + `NSURLErrorDomain Code=-1017`). Setup: + `cd ../logseq-1/deps/db-sync && pnpm install && clojure -M:cljs release db-sync-node` + (needs `JAVA_HOME=$(/usr/libexec/java_home -v 21)`), then + `DB_SYNC_PORT=8787 DB_SYNC_DATA_DIR=~/db-sync-data ./start.sh`; + health check `curl http://127.0.0.1:8787/health`. +- Run: `LOGSEQ_CHAT_IOS_SKIP_BUILD=1 LOGSEQ_CHAT_E2E_USERNAME= LOGSEQ_CHAT_E2E_PASSWORD=

./scripts/test-ios-e2e-suite.sh smoke` + (4 flows: capture-responsive, cold-start-composer, search-status-regression, sidebar). +- `scripts/test-ios-e2e.sh` wipes the app data container between flows; `/tmp` + must be recreated (the script does `mkdir -p`) or the OCaml core crashes writing the + initial graph snapshot via `Filename.temp_file` (`Sys_error ...tmp/logseq-chat-initial-*.snapshot`). ## Devin Secrets Needed -- none for build/launch/screenshot. +- `LOGSEQ_CHAT_TEST_USERNAME` / `LOGSEQ_CHAT_TEST_PASSWORD` for hosted sign-in and e2e. From c0b2ee9f2a83db4ab1828be36b16501cdfe30a20 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 17:20:10 +0000 Subject: [PATCH 21/28] e2e android: longer sign-in wait on cold emulator; upload Maestro debug artifacts --- .github/workflows/e2e.yml | 18 ++++++++++++++++++ tests/e2e/android-signed-out.yaml | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 26982f2b..d7402baa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -287,6 +287,15 @@ jobs: export DUNE="$(command -v dune)" LOGSEQ_CHAT_IOS_SKIP_BUILD=1 ./scripts/test-ios-e2e-suite.sh "$IOS_MODULE" + - name: Upload Maestro debug artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-maestro-debug + path: ~/.maestro/tests + retention-days: 7 + if-no-files-found: ignore + android-e2e: name: Android e2e needs: duniverse @@ -437,6 +446,15 @@ jobs: export DUNE="$(command -v dune)" ./scripts/test-android-e2e.sh "$ANDROID_MODULE" + - name: Upload Maestro debug artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-maestro-debug + path: ~/.maestro/tests + retention-days: 7 + if-no-files-found: ignore + - name: Save Android OCaml toolchain if: steps.android-toolchain.outputs.cache-hit != 'true' uses: actions/cache/save@v4 diff --git a/tests/e2e/android-signed-out.yaml b/tests/e2e/android-signed-out.yaml index f5a886c3..63d8f781 100644 --- a/tests/e2e/android-signed-out.yaml +++ b/tests/e2e/android-signed-out.yaml @@ -4,7 +4,8 @@ appId: com.logseq.chat - extendedWaitUntil: visible: id: "button.hosted-sign-in" - timeout: 5000 + # Cold-start on a CI emulator can take >5s for the core to boot. + timeout: 30000 - assertVisible: ".*Logseq Chat.*" - assertVisible: "Sign in" - assertNotVisible: ".*LogseqAuthenticationError.*" From d343eeafa8b415ba254a85b006bd5884827ada3b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 17:39:57 +0000 Subject: [PATCH 22/28] e2e: save toolchain caches even when e2e fails --- .github/workflows/e2e.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d7402baa..ceb13c94 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -265,7 +265,9 @@ jobs: ./scripts/build-mobile-ios-simulator.sh - name: Save iOS OCaml toolchain - if: steps.ios-toolchain.outputs.cache-hit != 'true' + # always(): even when e2e fails, preserve the expensive toolchain + # build — bootstrap stamp files keep partial builds safe to cache. + if: always() && steps.ios-toolchain.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: _build/apple-toolchains @@ -456,7 +458,7 @@ jobs: if-no-files-found: ignore - name: Save Android OCaml toolchain - if: steps.android-toolchain.outputs.cache-hit != 'true' + if: always() && steps.android-toolchain.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | From ac262dc07bd92d04b86df31994083e8aa8a39ab9 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 17:46:04 +0000 Subject: [PATCH 23/28] e2e: run real db-sync server in CI; sign in via Cognito form on android smoke --- .github/actions/db-sync-server/action.yml | 99 +++++++++++++++++++++++ .github/workflows/e2e.yml | 11 +++ scripts/test-android-e2e.sh | 10 ++- tests/e2e/android-local-graph-setup.yaml | 20 +++++ 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 .github/actions/db-sync-server/action.yml diff --git a/.github/actions/db-sync-server/action.yml b/.github/actions/db-sync-server/action.yml new file mode 100644 index 00000000..f2e09edc --- /dev/null +++ b/.github/actions/db-sync-server/action.yml @@ -0,0 +1,99 @@ +name: Start db-sync server +description: >- + Check out logseq@feature/native-mobile-app, build the db-sync node adapter, + and start it on the given port (default 8787). The app e2e flows create sync + graphs against it. +inputs: + token: + description: GitHub token with access to the private logseq/logseq repo. + required: true + ref: + description: logseq/logseq ref to check out. + required: false + default: feature/native-mobile-app + port: + description: Port the server binds. + required: false + default: "8787" +outputs: + port: + description: The port the server is listening on. + value: ${{ inputs.port }} +runs: + using: composite + steps: + - name: Check out db-sync server + uses: actions/checkout@v4 + with: + repository: logseq/logseq + ref: ${{ inputs.ref }} + path: logseq-server + token: ${{ inputs.token }} + fetch-depth: 1 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install Clojure CLI and pnpm + shell: bash + run: | + if [[ $RUNNER_OS == macOS ]]; then + brew install clojure/tools/clojure || brew upgrade clojure/tools/clojure + else + curl -fsSL \ + https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh \ + | sudo bash + fi + corepack enable + clojure --version + + - name: Restore db-sync build caches + uses: actions/cache/restore@v4 + with: + path: | + ~/.m2 + ~/.gitlibs + ~/.local/share/pnpm/store + ~/Library/pnpm + logseq-server/node_modules + logseq-server/deps/db-sync/node_modules + key: dbsync-deps-${{ runner.os }}-${{ hashFiles('logseq-server/pnpm-lock.yaml') }} + restore-keys: dbsync-deps-${{ runner.os }}- + + - name: Build and start db-sync + shell: bash + run: | + cd logseq-server + pnpm install + cd deps/db-sync + clojure -M:cljs release db-sync-node + mkdir -p data + DB_SYNC_PORT=${{ inputs.port }} DB_SYNC_DATA_DIR="$PWD/data" \ + nohup ./start.sh >"$PWD/db-sync.log" 2>&1 & + echo $! > "$PWD/db-sync.pid" + for attempt in $(seq 1 90); do + # Any HTTP response (including 401/404) means the port is bound. + if curl -s -o /dev/null "http://127.0.0.1:${{ inputs.port }}/"; then + echo "db-sync is listening on :${{ inputs.port }}" + exit 0 + fi + sleep 2 + done + echo "db-sync did not start; tail of log:" >&2 + tail -100 "$PWD/db-sync.log" >&2 || true + exit 1 + + - name: Upload db-sync log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: db-sync-log-${{ runner.os }} + path: logseq-server/deps/db-sync/db-sync.log + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ceb13c94..1a80048d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -282,6 +282,11 @@ jobs: xcrun simctl bootstatus "$udid" -b echo "LOGSEQ_CHAT_IOS_SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + - name: Start db-sync server + uses: ./.github/actions/db-sync-server + with: + token: ${{ secrets.LOGSEQ_GITHUB_PAT }} + - name: Run iOS e2e module run: | eval $(opam env) @@ -307,6 +312,7 @@ jobs: LOGSEQ_GITHUB_PAT: ${{ secrets.LOGSEQ_GITHUB_PAT }} LOGSEQ_CHAT_OPAM_SWITCH: ${{ github.workspace }} LOGSEQ_CHAT_ANDROID_ABI: x86_64 + LOGSEQ_CHAT_E2E_BASE_URL: http://127.0.0.1:8787 steps: - uses: actions/checkout@v4 @@ -420,6 +426,11 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Start db-sync server + uses: ./.github/actions/db-sync-server + with: + token: ${{ secrets.LOGSEQ_GITHUB_PAT }} + - name: Run Android e2e module uses: reactivecircus/android-emulator-runner@v2 env: diff --git a/scripts/test-android-e2e.sh b/scripts/test-android-e2e.sh index f4a20be8..6e122c1c 100755 --- a/scripts/test-android-e2e.sh +++ b/scripts/test-android-e2e.sh @@ -244,6 +244,10 @@ if (( needs_connection )); then : "${LOGSEQ_CHAT_E2E_PASSWORD:?error: LOGSEQ_CHAT_E2E_PASSWORD is required}" : "${LOGSEQ_CHAT_E2E_BASE_URL:?error: LOGSEQ_CHAT_E2E_BASE_URL is required}" fi +# Modules that still sign in through the in-app Cognito form (e.g. smoke's +# local server setup) use the shared dev-account defaults. +LOGSEQ_CHAT_E2E_USERNAME=${LOGSEQ_CHAT_E2E_USERNAME:-e2etest} +LOGSEQ_CHAT_E2E_PASSWORD=${LOGSEQ_CHAT_E2E_PASSWORD:-Logseq-e2e} command -v adb >/dev/null 2>&1 || die "adb is not installed" command -v maestro >/dev/null 2>&1 || die "Maestro CLI is not installed" @@ -263,7 +267,7 @@ if [[ -z $device ]]; then fi [[ -n $device ]] || die "no online Android emulator or device was found" -if (( needs_connection )) \ +if [[ -n ${LOGSEQ_CHAT_E2E_BASE_URL:-} ]] \ && [[ $LOGSEQ_CHAT_E2E_BASE_URL =~ ^(http|https)://(127\.0\.0\.1|localhost)(:([0-9]+))?([/?#]|$) ]]; then local_backend_port=${BASH_REMATCH[4]:-} if [[ -z $local_backend_port ]]; then @@ -304,7 +308,7 @@ if (( needs_connection )) && [[ ${LOGSEQ_CHAT_ANDROID_E2E_CLEAR_BROWSER_STATE:-1 fi fi -if (( needs_connection )); then +if [[ -n ${LOGSEQ_CHAT_E2E_BASE_URL:-} ]]; then [[ $LOGSEQ_CHAT_E2E_BASE_URL != *['<>&"']* ]] \ || die "LOGSEQ_CHAT_E2E_BASE_URL contains characters that are unsafe in Android preferences" preferences_file=$(mktemp "${TMPDIR:-/tmp}/logseq-chat-android-defaults.xml.XXXXXX") @@ -430,7 +434,7 @@ for flow in "${flows[@]}"; do "$app_id/.MainActivity" >/dev/null fi maestro_args=(--device "$device" test) - if [[ $flow == "$connect_flow" ]]; then + if [[ $flow == "$connect_flow" || $flow == "$local_setup_flow" ]]; then maestro_args+=( -e "USERNAME=$LOGSEQ_CHAT_E2E_USERNAME" -e "PASSWORD=$LOGSEQ_CHAT_E2E_PASSWORD" diff --git a/tests/e2e/android-local-graph-setup.yaml b/tests/e2e/android-local-graph-setup.yaml index 657f6123..33e6891a 100644 --- a/tests/e2e/android-local-graph-setup.yaml +++ b/tests/e2e/android-local-graph-setup.yaml @@ -13,6 +13,26 @@ appId: com.logseq.chat visible: "Use without an account" commands: - tapOn: "Use without an account" + - extendedWaitUntil: + visible: + id: "signInFormUsername" + timeout: 30000 + - tapOn: + id: "signInFormUsername" + - inputText: ${USERNAME} + - runFlow: + when: + visible: "Try out your stylus" + commands: + - tapOn: "Cancel" + - tapOn: + id: "signInFormPassword" + - inputText: ${PASSWORD} + - hideKeyboard + - extendedWaitUntil: + visible: "submit" + timeout: 10000 + - tapOn: "submit" - extendedWaitUntil: visible: id: "screen.graph-picker" From a2c44122f0a8802cdafd79b955f530888bbf57b4 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 17:51:40 +0000 Subject: [PATCH 24/28] tests: add appearance + outliner-edit headless drive scenarios --- .../drive/outliner-edit-title.drive | 16 +++++ .../drive/settings-appearance.drive | 18 ++++++ .../test/logseq_chat/drive_scenario_test.ml | 63 ++++++++++++++++--- 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 shared/test/logseq_chat/drive/outliner-edit-title.drive create mode 100644 shared/test/logseq_chat/drive/settings-appearance.drive diff --git a/shared/test/logseq_chat/drive/outliner-edit-title.drive b/shared/test/logseq_chat/drive/outliner-edit-title.drive new file mode 100644 index 00000000..50693bba --- /dev/null +++ b/shared/test/logseq_chat/drive/outliner-edit-title.drive @@ -0,0 +1,16 @@ +# Create a local graph, tap a seeded journal block to start editing, +# push a text-change + return through the outliner-editor extension +# events, and verify the committed title renders on the block row. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +wait prop:accessibility-identifier=field.graph-name +type prop:accessibility-identifier=field.graph-name "Drive Graph" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +wait prop:accessibility-identifier=outliner.block.journal-block-1 +press prop:accessibility-identifier=outliner.block-action.journal-block-1 +wait prop:accessibility-identifier=toolbar.outliner.editor +ext ext:outliner-editor outliner-editor text-change '{"title":"Renamed block","caret-utf16-offset":13}' +ext ext:outliner-editor outliner-editor return '{"title":"Renamed block","caret-utf16-offset":13}' +expect text:"Renamed block" +expect-absent text:"First seeded block" diff --git a/shared/test/logseq_chat/drive/settings-appearance.drive b/shared/test/logseq_chat/drive/settings-appearance.drive new file mode 100644 index 00000000..744869b9 --- /dev/null +++ b/shared/test/logseq_chat/drive/settings-appearance.drive @@ -0,0 +1,18 @@ +# Create a local graph, open Settings, and switch the appearance theme +# through the settings menu — ChangeAppearance applies optimistically and +# the save effect is resolved by the host stub. +expect prop:accessibility-identifier=button.graph-add +press prop:accessibility-identifier=button.graph-add +type prop:accessibility-identifier=field.graph-name "Drive Graph" +press prop:accessibility-identifier=button.graph-add.confirm +wait prop:accessibility-identifier=application.shell +ext ext:native-overflow-menu native-overflow-menu settings '{}' +wait prop:accessibility-identifier=sheet.settings +press kind:select +wait kind:menu-item&text:"Dark" +press kind:menu-item&text:"Dark" +expect kind:select&text:"Dark" +press kind:select&text:"Dark" +wait kind:menu-item&text:"Light" +press kind:menu-item&text:"Light" +expect kind:select&text:"Light" diff --git a/shared/test/logseq_chat/drive_scenario_test.ml b/shared/test/logseq_chat/drive_scenario_test.ml index a9742848..1722fcd0 100644 --- a/shared/test/logseq_chat/drive_scenario_test.ml +++ b/shared/test/logseq_chat/drive_scenario_test.ml @@ -25,7 +25,8 @@ let journal_rows = "Second seeded block" "Today" 20260828 1; ] -let graph_projection ~graph_id ~graph_name ~encrypted ~unlocked graphs = +let graph_projection ~graph_id ~graph_name ~encrypted ~unlocked ?(rows = journal_rows) + graphs = { (App_test.empty_core_projection ()) with Model.graph_name = Some graph_name; @@ -33,11 +34,14 @@ let graph_projection ~graph_id ~graph_name ~encrypted ~unlocked graphs = graphs; is_graph_encrypted = encrypted; is_graph_unlocked = unlocked; - journal_outliner_rows = journal_rows; - outliner_rows = journal_rows; + journal_outliner_rows = rows; + outliner_rows = rows; } -type host = { mutable graphs : Model.graph list } +type host = + { mutable graphs : Model.graph list + ; mutable rows : Model.outline_row list + } let host_responses host model = let find_graph id = @@ -61,7 +65,7 @@ let host_responses host model = (List.map (fun (g : Model.graph) -> g.id) host.graphs) :: Model.ApplyCoreSnapshot (graph_projection ~graph_id:name ~graph_name:name ~encrypted - ~unlocked:(not encrypted) host.graphs) + ~unlocked:(not encrypted) ~rows:host.rows host.graphs) :: resolved | Model.SearchNodesEffect (id, query) -> Model.DequeueEffect id @@ -102,7 +106,7 @@ let host_responses host model = { (graph_projection ~graph_id:g.id ~graph_name:g.name ~encrypted:g.is_encrypted ~unlocked:(not g.is_encrypted) - host.graphs) + ~rows:host.rows host.graphs) with projection_outliner_editing = Some @@ -121,7 +125,7 @@ let host_responses host model = :: Model.ApplyCoreSnapshot (graph_projection ~graph_id:g.id ~graph_name:g.name ~encrypted:g.is_encrypted ~unlocked:(not g.is_encrypted) - host.graphs) + ~rows:host.rows host.graphs) :: resolved | None -> [ @@ -137,7 +141,48 @@ let host_responses host model = Model.DequeueEffect id :: Model.ApplyCoreSnapshot (graph_projection ~graph_id:g.id ~graph_name:g.name - ~encrypted:g.is_encrypted ~unlocked:true host.graphs) + ~encrypted:g.is_encrypted ~unlocked:true ~rows:host.rows + host.graphs) + :: resolved + | None -> Model.DequeueEffect id :: resolved) + | Model.ChangeOutlinerTextEffect (id, uuid, title, caret) -> ( + match + Option.map find_graph model.Model.selected_graph_id |> Option.join + with + | Some g -> + Model.DequeueEffect id + :: Model.ApplyCoreSnapshot + { + (graph_projection ~graph_id:g.id ~graph_name:g.name + ~encrypted:g.is_encrypted ~unlocked:(not g.is_encrypted) + ~rows:host.rows host.graphs) + with + projection_outliner_editing = + Some + { + Model.editing_uuid = uuid; + editing_title = title; + caret_utf16_offset = caret; + }; + } + :: resolved + | None -> Model.DequeueEffect id :: resolved) + | Model.ReturnOutlinerEditorEffect (id, uuid, title, _caret) -> ( + match + Option.map find_graph model.Model.selected_graph_id |> Option.join + with + | Some g -> + host.rows <- + List.map + (fun (row : Model.outline_row) -> + if row.row_uuid = uuid then { row with row_title = title } + else row) + host.rows; + Model.DequeueEffect id + :: Model.ApplyCoreSnapshot + (graph_projection ~graph_id:g.id ~graph_name:g.name + ~encrypted:g.is_encrypted ~unlocked:(not g.is_encrypted) + ~rows:host.rows host.graphs) :: resolved | None -> Model.DequeueEffect id :: resolved) | eff -> @@ -148,7 +193,7 @@ let host_responses host model = model.Model.pending_effects let mount ~profile () = - let host = { graphs = [] } in + let host = { graphs = []; rows = journal_rows } in let self = ref None in let drain () = match !self with From e077c4be3e251f920ac95d496c39126186ee486b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 19:00:32 +0000 Subject: [PATCH 25/28] =?UTF-8?q?e2e:=20speed=20up=20CI=20=E2=80=94=20seed?= =?UTF-8?q?=20smoke=20flows,=20background=20db-sync=20build=20and=20sim=20?= =?UTF-8?q?boot,=20cache=20iOS=20build=20artifacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seed all smoke flows via the shared graph cache (skip per-flow graph create + checkpoint wait; ~1-2min/flow) - build db-sync in a detached background process while the iOS app and Android APK/emulator run; a wait step polls the port - boot the iOS simulator before the app build (bootstatus gates later) - cache apple/.build (SwiftPM + DerivedData) keyed on Package.resolved --- .github/actions/db-sync-server/action.yml | 29 ++++++++++ .github/workflows/e2e.yml | 66 ++++++++++++++++++----- scripts/test-android-e2e.sh | 15 ++++++ scripts/test-ios-e2e-suite.sh | 6 ++- 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/.github/actions/db-sync-server/action.yml b/.github/actions/db-sync-server/action.yml index f2e09edc..2f6c0e13 100644 --- a/.github/actions/db-sync-server/action.yml +++ b/.github/actions/db-sync-server/action.yml @@ -15,6 +15,13 @@ inputs: description: Port the server binds. required: false default: "8787" + background: + description: >- + When "true", the pnpm install, cljs release build and server start run in + a detached background process (log at logseq-server/deps/db-sync/build.log) + so the job can continue with other work; a later step must poll the port. + required: false + default: "false" outputs: port: description: The port the server is listening on. @@ -67,6 +74,7 @@ runs: restore-keys: dbsync-deps-${{ runner.os }}- - name: Build and start db-sync + if: inputs.background != 'true' shell: bash run: | cd logseq-server @@ -89,6 +97,27 @@ runs: tail -100 "$PWD/db-sync.log" >&2 || true exit 1 + - name: Build and start db-sync in the background + if: inputs.background == 'true' + shell: bash + run: | + mkdir -p "$RUNNER_TEMP" + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -e' \ + 'cd "$GITHUB_WORKSPACE/logseq-server"' \ + 'pnpm install' \ + 'cd deps/db-sync' \ + 'clojure -M:cljs release db-sync-node' \ + 'mkdir -p data' \ + 'DB_SYNC_DATA_DIR="$PWD/data" nohup ./start.sh >"$PWD/db-sync.log" 2>&1 &' \ + 'echo $! > "$PWD/db-sync.pid"' \ + > "$RUNNER_TEMP/db-sync-bg.sh" + chmod +x "$RUNNER_TEMP/db-sync-bg.sh" + DB_SYNC_PORT=${{ inputs.port }} nohup "$RUNNER_TEMP/db-sync-bg.sh" \ + >"logseq-server/deps/db-sync/build.log" 2>&1 & + echo "db-sync build started in background (pid $!)" + - name: Upload db-sync log if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1a80048d..d94c74ca 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -257,6 +257,33 @@ jobs: path: _build/apple-toolchains key: apple-toolchain-${{ runner.os }}-xcode26.3-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} + - name: Boot an iOS simulator + # simctl boot returns as soon as the boot is initiated; the device + # finishes booting while the app builds, and bootstatus gates later. + run: | + runtime_id=$(xcrun simctl list -j runtimes | python3 -c 'import json,sys; rs=[r for r in json.load(sys.stdin)["runtimes"] if r.get("isAvailable") and ".iOS-" in r["identifier"]]; rs.sort(key=lambda r: [int(x) for x in r["version"].split(".")]); print(rs[-1]["identifier"])') + udid=$(xcrun simctl create "CI iPhone" \ + "com.apple.CoreSimulator.SimDeviceType.iPhone-16" "$runtime_id") + xcrun simctl boot "$udid" + echo "LOGSEQ_CHAT_IOS_SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + + - name: Start db-sync server (background) + # Builds while the iOS app build runs; "Wait for db-sync" polls it. + uses: ./.github/actions/db-sync-server + with: + token: ${{ secrets.LOGSEQ_GITHUB_PAT }} + background: "true" + + - name: Restore iOS build artifacts + # SwiftPM + xcodebuild products; stale objects are harmless — the + # build only reuses what still matches. + id: ios-build-artifacts + uses: actions/cache/restore@v4 + with: + path: apple/.build + key: ios-build-artifacts-${{ runner.os }}-xcode26.3-${{ hashFiles('apple/Package.swift', 'apple/Package.resolved') }} + restore-keys: ios-build-artifacts-${{ runner.os }}-xcode26.3- + - name: Build iOS app for the simulator run: | eval $(opam env) @@ -264,6 +291,13 @@ jobs: export DUNE="$(command -v dune)" ./scripts/build-mobile-ios-simulator.sh + - name: Save iOS build artifacts + if: always() + uses: actions/cache/save@v4 + with: + path: apple/.build + key: ios-build-artifacts-${{ runner.os }}-xcode26.3-${{ hashFiles('apple/Package.swift', 'apple/Package.resolved') }} + - name: Save iOS OCaml toolchain # always(): even when e2e fails, preserve the expensive toolchain # build — bootstrap stamp files keep partial builds safe to cache. @@ -273,19 +307,22 @@ jobs: path: _build/apple-toolchains key: apple-toolchain-${{ runner.os }}-xcode26.3-ios-simulator-5.5.0-${{ hashFiles('scripts/bootstrap-ios-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} - - name: Boot an iOS simulator + - name: Wait for db-sync and the simulator run: | - runtime_id=$(xcrun simctl list -j runtimes | python3 -c 'import json,sys; rs=[r for r in json.load(sys.stdin)["runtimes"] if r.get("isAvailable") and ".iOS-" in r["identifier"]]; rs.sort(key=lambda r: [int(x) for x in r["version"].split(".")]); print(rs[-1]["identifier"])') - udid=$(xcrun simctl create "CI iPhone" \ - "com.apple.CoreSimulator.SimDeviceType.iPhone-16" "$runtime_id") - xcrun simctl boot "$udid" - xcrun simctl bootstatus "$udid" -b - echo "LOGSEQ_CHAT_IOS_SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" - - - name: Start db-sync server - uses: ./.github/actions/db-sync-server - with: - token: ${{ secrets.LOGSEQ_GITHUB_PAT }} + for attempt in $(seq 1 180); do + if curl -s -o /dev/null "http://127.0.0.1:8787/"; then + echo "db-sync is listening on :8787" + break + fi + if [[ $attempt == 180 ]]; then + echo "db-sync did not come up; build log:" >&2 + tail -100 logseq-server/deps/db-sync/build.log >&2 || true + tail -100 logseq-server/deps/db-sync/db-sync.log >&2 || true + exit 1 + fi + sleep 2 + done + xcrun simctl bootstatus "$LOGSEQ_CHAT_IOS_SIMULATOR_UDID" -b - name: Run iOS e2e module run: | @@ -426,10 +463,13 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Start db-sync server + - name: Start db-sync server (background) + # Builds while the emulator boots and the APK compiles; the next + # step polls the port before the flows run. uses: ./.github/actions/db-sync-server with: token: ${{ secrets.LOGSEQ_GITHUB_PAT }} + background: "true" - name: Run Android e2e module uses: reactivecircus/android-emulator-runner@v2 diff --git a/scripts/test-android-e2e.sh b/scripts/test-android-e2e.sh index 6e122c1c..ed0799c9 100755 --- a/scripts/test-android-e2e.sh +++ b/scripts/test-android-e2e.sh @@ -379,6 +379,21 @@ seed_android_fixture() { adb -s "$device" shell rm -f "$remote_database" } +if [[ -n ${LOGSEQ_CHAT_E2E_BASE_URL:-} ]] \ + && [[ $LOGSEQ_CHAT_E2E_BASE_URL =~ ^(http|https)://(127\.0\.0\.1|localhost)(:([0-9]+))?([/?#]|$) ]]; then + # CI builds db-sync in a background process; flows must not start before + # it binds the port (a bound port returns any HTTP response, incl. 401/404). + for attempt in $(seq 1 180); do + if curl -s -o /dev/null "${LOGSEQ_CHAT_E2E_BASE_URL}/"; then + break + fi + if (( attempt == 180 )); then + die "db-sync server did not come up at $LOGSEQ_CHAT_E2E_BASE_URL" + fi + sleep 2 + done +fi + for flow in "${flows[@]}"; do echo "==> $flow" if [[ $flow = /* ]]; then diff --git a/scripts/test-ios-e2e-suite.sh b/scripts/test-ios-e2e-suite.sh index f24b9684..5a74bc17 100755 --- a/scripts/test-ios-e2e-suite.sh +++ b/scripts/test-ios-e2e-suite.sh @@ -155,7 +155,11 @@ for ((flow_index = start_index; flow_index < ${#flows[@]}; flow_index++)); do || $flow == tests/e2e/ios-page-share.yaml \ || $flow == tests/e2e/ios-page-favorite.yaml \ || $flow == tests/e2e/ios-sidebar-page-empty-block-delete.yaml \ - || $flow == tests/e2e/ios-rich-block-rendering.yaml ]]; then + || $flow == tests/e2e/ios-rich-block-rendering.yaml \ + || $flow == tests/e2e/ios-capture-responsive.yaml \ + || $flow == tests/e2e/ios-cold-start-composer.yaml \ + || $flow == tests/e2e/ios-search-status-regression.yaml \ + || $flow == tests/e2e/sidebar.yaml ]]; then LOGSEQ_CHAT_IOS_SKIP_BUILD=1 \ LOGSEQ_CHAT_IOS_E2E_SEED_GRAPH=1 \ LOGSEQ_CHAT_IOS_E2E_FLOW="$flow" \ From 3eaf51babab46500ed5623b51177cf89a8ac88d7 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 19:30:02 +0000 Subject: [PATCH 26/28] e2e: wait for app/picker explicitly in iOS graph setup The 'Choose a graph' runFlow condition evaluated while the graph picker was still loading, so graph creation was skipped and the final button.sidebar wait timed out. Now wait for button.sidebar (already in app) or 'Choose a graph' (picker) explicitly before deciding. --- tests/e2e/ios-local-graph-setup.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/e2e/ios-local-graph-setup.yaml b/tests/e2e/ios-local-graph-setup.yaml index 2c74d4b8..09d66f4d 100644 --- a/tests/e2e/ios-local-graph-setup.yaml +++ b/tests/e2e/ios-local-graph-setup.yaml @@ -43,10 +43,19 @@ appId: com.logseq.chat - tapOn: "__LOGSEQ_CHAT_E2E_GRAPH_NAME__" - waitForAnimationToEnd: timeout: 5000 +- extendedWaitUntil: + visible: + id: "button.sidebar" + timeout: 20000 + optional: true - runFlow: when: - visible: "Choose a graph" + notVisible: + id: "button.sidebar" commands: + - extendedWaitUntil: + visible: "Choose a graph" + timeout: 30000 - tapOn: "Add sync graph" - extendedWaitUntil: visible: "Add sync graph" From 0edfb8d5d3efec326255ed683b25a54a25964ab0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 19:56:02 +0000 Subject: [PATCH 27/28] e2e: let the app settle after Cognito sign-in before evaluating conditions --- tests/e2e/ios-local-graph-setup.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/e2e/ios-local-graph-setup.yaml b/tests/e2e/ios-local-graph-setup.yaml index 09d66f4d..97d62240 100644 --- a/tests/e2e/ios-local-graph-setup.yaml +++ b/tests/e2e/ios-local-graph-setup.yaml @@ -25,6 +25,10 @@ appId: com.logseq.chat visible: "submit" timeout: 10000 - tapOn: "submit" + # Post-login bootstrap churns the view tree; hierarchy snapshots taken + # during it have stalled the XCTest driver on CI. Wait it out. + - waitForAnimationToEnd: + timeout: 30000 - runFlow: when: visible: "Enter your username" From 85b88435cb97585e7f37bed3f85abc333a61323e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Thu, 24 Sep 2026 20:50:41 +0000 Subject: [PATCH 28/28] bump lui_flutter_backend pin to b65da35 for unbounded-flex fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logseq/lui#34 fixes RenderFlex asserting 'children have non-zero flex but incoming constraints are unbounded' when a grow child sits inside a shrink-wrap column/row — the cause of the signed-out white screen in Android e2e. --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 7fa3430e..39bb6bff 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -159,8 +159,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a - resolved-ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a + ref: b65da35 + resolved-ref: b65da35dc226b744174b8693151f3262fe5d94ec url: "ssh://git@github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index ecab23c2..a62278eb 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: lui_flutter_backend: git: url: ssh://git@github.com/logseq/lui.git - ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a + ref: b65da35 path: platform/flutter webview_flutter: ^4.14.1