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. diff --git a/.github/actions/db-sync-server/action.yml b/.github/actions/db-sync-server/action.yml new file mode 100644 index 00000000..2f6c0e13 --- /dev/null +++ b/.github/actions/db-sync-server/action.yml @@ -0,0 +1,128 @@ +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" + 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. + 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 + if: inputs.background != 'true' + 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: 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 + 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/ci.yml b/.github/workflows/ci.yml index a27170d6..14650f97 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: | @@ -68,7 +96,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' @@ -81,8 +109,16 @@ 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 - for pin in $(grep -oE 'logseq/[A-Za-z0-9_-]+\.git#[0-9a-f]{40}' logseq_chat.opam | sort -u); do + # Call the binary directly: the `opam monorepo` plugin shim rejects + # plugins built under a different opam version (restored caches). + # 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 # commits (without .git dirs — `git -C` there would hit the parent @@ -93,7 +129,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 @@ -102,7 +138,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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..d94c74ca --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,519 @@ +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: "smoke" + 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' || 'smoke') }} + +jobs: + # 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: 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 + # 0.3.x matches the lockfile's x-opam-monorepo-version. + 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') }} + 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). + # 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 + 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') }} + 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 }} + # 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 + + - 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: 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 }}-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) + export LOGSEQ_CHAT_OPAM_SWITCH="$(opam switch show)" + 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. + if: always() && steps.ios-toolchain.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + 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: Wait for db-sync and the simulator + run: | + 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: | + 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" + + - 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 + 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 + LOGSEQ_CHAT_E2E_BASE_URL: http://127.0.0.1:8787 + 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 + + - 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 `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: 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: 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-${{ hashFiles('scripts/bootstrap-android-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} + + - 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: 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 + 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 + arch: x86_64 + profile: pixel_6 + disable-animations: true + 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: 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: always() && 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-${{ hashFiles('scripts/bootstrap-android-ocaml.sh', 'scripts/build-mobile-libffi.sh') }} 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 diff --git a/logseq_chat.opam b/logseq_chat.opam index 079c2545..e61619d7 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#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 9bd027d6..75b81639 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#7f407b07f18e68596cb0dcb3ba1781cb0572a2bb" + ] [ "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#7f407b07f18e68596cb0dcb3ba1781cb0572a2bb" + "drive" + ] [ "git+https://github.com/logseq/ocaml-fsrs.git#472b8ff1b86e5afd2ac4bbeba5e2eecedd3948fc" "ocaml-fsrs" 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 + +# Build chatter goes to stderr so callers can safely capture only the prefix. +( + cd "$src_dir" + [[ -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 >&2 + make -j"${LOGSEQ_CHAT_BUILD_JOBS:-8}" >&2 + make install >&2 +) + +touch "$stamp" +echo "$prefix" diff --git a/scripts/build-mobile-ocaml.sh b/scripts/build-mobile-ocaml.sh index 5a1893dc..bfdc5ff3 100755 --- a/scripts/build-mobile-ocaml.sh +++ b/scripts/build-mobile-ocaml.sh @@ -11,8 +11,35 @@ 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)} +# 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) +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" +if [[ -n $switch ]]; then + workspace="$repo_root/_build/dune-workspace.mobile" + mkdir -p "$(dirname "$workspace")" + 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 @@ -22,7 +49,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" 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..ed0799c9 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" \ @@ -235,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" @@ -254,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 @@ -269,7 +282,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 @@ -295,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") @@ -346,12 +359,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" @@ -362,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 @@ -417,7 +449,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/scripts/test-ios-e2e-suite.sh b/scripts/test-ios-e2e-suite.sh index e6019549..5a74bc17 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 @@ -144,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" \ diff --git a/scripts/test-ios-e2e.sh b/scripts/test-ios-e2e.sh index 9fe30e20..870b628a 100755 --- a/scripts/test-ios-e2e.sh +++ b/scripts/test-ios-e2e.sh @@ -69,13 +69,29 @@ 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" + # 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 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 +125,48 @@ 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 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 $graph_database && -f ${graph_database%/graph.sqlite}/sync.checkpoint ]]; then - break + if [[ -n $fixture_seed_mode ]]; then + "${dune_seed[@]}" shared/native/logseq_chat_e2e_seed.exe -- "$graph_database" "$fixture_seed_mode" + else + "${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" + 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/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/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/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/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/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/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/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..1722fcd0 --- /dev/null +++ b/shared/test/logseq_chat/drive_scenario_test.ml @@ -0,0 +1,242 @@ +(* 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 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 ?(rows = journal_rows) + 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; + journal_outliner_rows = rows; + outliner_rows = rows; + } + +type host = + { mutable graphs : Model.graph list + ; mutable rows : Model.outline_row 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) ~rows:host.rows 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) + ~rows:host.rows 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 -> + 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; + 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 ~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 -> + [ + Model.DequeueEffect (Model.effect_id eff); + Model.ResolveEffect (Model.effect_id eff, true, ""); + ]) + model.Model.pending_effects + +let mount ~profile () = + let host = { graphs = []; rows = journal_rows } 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/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; 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; ] diff --git a/tests/e2e/android-local-graph-setup.yaml b/tests/e2e/android-local-graph-setup.yaml new file mode 100644 index 00000000..33e6891a --- /dev/null +++ b/tests/e2e/android-local-graph-setup.yaml @@ -0,0 +1,68 @@ +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: "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" + 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 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.*" diff --git a/tests/e2e/ios-local-graph-setup.yaml b/tests/e2e/ios-local-graph-setup.yaml index 2c74d4b8..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" @@ -43,10 +47,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"