diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml new file mode 100644 index 000000000..5a2ecf807 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -0,0 +1,93 @@ +name: Report a bug +description: Report a bug or a functional regression +title: "BUG DESCRIPTION" +labels: ["type: bug"] +body: + - type: "markdown" + attributes: + value: | +

+ Readest Logo +

+ + Please replace the **`BUG DESCRPTION`** template above with a clear and concise description of what the current behvaior is. + + An example of a good bug report title would look like: + ```plaintext + In DarkMode, a blank square appears in bottom right corner while scrolling + ``` + + --- + - type: "textarea" + id: "description" + attributes: + label: "Bug Description" + description: | + A clear and concise description of what the current behavior is. + + Please also add **screenshots** of the issue if applicable. + placeholder: "E.g: In DarkMode, when scrollbar are displayed, we see a blank square in the bottom right corner" + validations: + required: true + - type: "textarea" + id: "steps_to_reproduce" + attributes: + label: "Steps to Reproduce" + description: | + Please include a step-by-step guide for reproducing this issue. Consider writing in concise, numbered bullet points. + validations: + required: true + - type: "textarea" + id: "expected_behvaior" + attributes: + label: "Expected Behavior" + description: | + A clear and concise description of what the expected behavior is. + placeholder: "The blank square should be transparent (invisible)" + validations: + required: true + - type: "input" + id: "reproduction_file" + attributes: + label: "Reproduction File" + description: "If applicable, upload a link to download the specific file that is causing the issue, so as to allow us to easily reproduce the bug." + - type: "dropdown" + id: "os_kind" + attributes: + label: "Operating System" + description: "Select the operating system(s) that this bug occurs on." + multiple: true + options: + - "macOS" + - "Windows" + - "Linux" + - "iOS/iPadOS" + - "Android" + - "Web" + validations: + required: true + - type: "input" + id: "os_version" + attributes: + label: "OS Version" + description: | + The OS version you are running. + placeholder: "E.g: Windows 11 24H2, macOS 15.5, Ubuntu 24.04, Android 14, iOS 18.5" + validations: + required: true + - type: "input" + id: "readest_version" + attributes: + label: "Readest Version" + description: | + The Readest version you are running. + + To find this, go to **☰ -> About Readest**. + placeholder: "Version x.xx.xx (WebView xxx.x.xx)" + validations: + required: true + - type: "textarea" + id: "custom_css" + attributes: + label: "Custom CSS" + description: "If you have custom CSS, please paste it here, as it could be affecting the issue." diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml new file mode 100644 index 000000000..bfe4eacf3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml @@ -0,0 +1,46 @@ +name: Feature request +description: Share an idea or suggestion +title: "FR: FEATURE REQUEST" +labels: ["type: enhancement"] +body: + - type: "markdown" + attributes: + value: | +

+ Readest Logo +

+ + Please replace the **`FEATURE REQUEST`** template above describing your feature request. + + --- + - type: "textarea" + id: "problem_being_solved" + attributes: + label: "What problem would this feature solve?" + description: | + A clear and concise of what problem this feature solves. + placeholder: "E.g: I think it takes too many steps to [...]" + validations: + required: true + - type: "textarea" + id: "feature_request" + attributes: + label: "Describe the solution you'd like." + description: | + A clear and concise of what problem this feature solves. + placeholder: "E.g: I think it takes too many steps to [...]" + validations: + required: true + - type: "textarea" + id: "alternatives" + attributes: + label: "Describe workarounds you may have used to acheive the same results." + description: | + A clear and concise description of any workarounds you've used. + placeholder: "E.g: This functionality can be changed in the settings menu, but I want a keyboard shortcut as well." + - type: "textarea" + id: "additional_context" + attributes: + label: "Additional Context" + description: | + Add any additional context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index 146afe177..000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Feature request -about: Share an idea or suggestion -title: 'FR: describing your feature request' -labels: enhancement -assignees: '' ---- - -**Does your feature request involve difficulty for you to complete a task? Please describe.** - -> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...] - -**Describe the solution you'd like** - -> A clear and concise description of what you'd like to happen. - -**Describe alternatives you've considered** - -> A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** - -> Add any additional context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/report-bug.md b/.github/ISSUE_TEMPLATE/report-bug.md deleted file mode 100644 index e9b30b13a..000000000 --- a/.github/ISSUE_TEMPLATE/report-bug.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Report a bug -about: Report a bug or a functional regression -title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling' -labels: ['type: bug'] -assignees: '' ---- - -## Bug Description - -A clear and concise description of what the current behavior is. -Please also add **screenshots** of the existing application. - -> **Example:** -> In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner -> [screenshot] - -## Expected behavior - -A clear and concise description of what the expected behavior is. - -> **Example:** -> The blank square should be transparent (invisible) - -## Technical inputs - -Operating System: - -Readest Version: - -> **Example:** -> Operating System: Android 14 (WebView 135.0) -> Readest Version: 0.9.0 -> We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper. -> Probably fixable with CSS diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 6621bcffa..909978a1c 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: initialize git submodules run: git submodule update --init --recursive @@ -46,7 +46,7 @@ jobs: cache: pnpm - name: setup Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: 'zulu' java-version: '17' @@ -97,7 +97,7 @@ jobs: test -n "$APK" - name: cache AVD snapshot - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: path: | @@ -107,7 +107,7 @@ jobs: - name: create AVD snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 with: api-level: 34 arch: x86_64 @@ -118,7 +118,7 @@ jobs: script: echo "AVD snapshot created" - name: run Android e2e lane - uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 with: api-level: 34 arch: x86_64 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cb124687a..28d78c415 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -61,7 +61,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` @@ -71,7 +71,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -100,6 +100,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 69700047d..24ea76583 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -39,15 +39,15 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: recursive - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GHCR - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -55,13 +55,13 @@ jobs: - name: Docker meta (for labels) id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository_owner }}/readest - name: Build and push by digest id: build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./Dockerfile @@ -102,10 +102,10 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GHCR - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -122,7 +122,7 @@ jobs: - name: Log in to Docker Hub if: steps.dockerhub.outputs.enabled == 'true' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -130,7 +130,7 @@ jobs: - name: Extract image metadata (GHCR only) id: meta-ghcr if: steps.dockerhub.outputs.enabled != 'true' - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository_owner }}/readest tags: | @@ -144,7 +144,7 @@ jobs: - name: Extract image metadata (GHCR + Docker Hub) id: meta-all if: steps.dockerhub.outputs.enabled == 'true' - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ghcr.io/${{ github.repository_owner }}/readest diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1f94b1984..29b03bee5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -28,7 +28,7 @@ jobs: outputs: nightly_version: ${{ steps.v.outputs.nightly_version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: main persist-credentials: false @@ -40,6 +40,12 @@ jobs: build: needs: compute-version + permissions: + contents: read + # Required by actions/attest-build-provenance: id-token mints the Sigstore + # OIDC identity, attestations writes the provenance to the repo's store. + id-token: write + attestations: write strategy: fail-fast: false matrix: @@ -72,9 +78,11 @@ jobs: args: '--target aarch64-pc-windows-msvc --bundles nsis' runs-on: ${{ matrix.config.os }} - timeout-minutes: 60 + # Backstop only — must stay ABOVE setup time + the per-step timeouts below, + # because a job-level timeout reports `cancelled` and skips assemble-manifest. + timeout-minutes: 75 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: main persist-credentials: false @@ -93,7 +101,7 @@ jobs: - name: setup Java (for Android build only) if: matrix.config.release == 'android' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: 'zulu' java-version: '17' @@ -134,6 +142,10 @@ jobs: echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local + echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env.local + echo "SENTRY_ORG=readest" >> .env.local + echo "SENTRY_PROJECT=readest" >> .env.local cp .env.local apps/readest-app/.env.local - name: install rclone @@ -167,6 +179,7 @@ jobs: - name: build and sign Android apks if: matrix.config.release == 'android' shell: bash + timeout-minutes: 55 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358 @@ -207,9 +220,30 @@ jobs: if: matrix.config.release == 'linux' run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force + # The truly-portable AppImage bundler downloads quick-sharun.sh from + # Anylinux-AppImages@main ONLY when it is not already in the tauri tools + # cache. An upstream strace-mode change (2026-06-29) made bundling launch + # the app under Xvfb and hang forever, timing out the Linux legs (#4906). + # Seed the cache with the last known-good revision so the bundler never + # fetches the moving main-branch script. + - name: pin quick-sharun.sh for AppImage bundling (Linux) + if: matrix.config.release == 'linux' + run: | + set -euo pipefail + cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri" + mkdir -p "$cache_dir" + curl -fsSL --retry 3 -o "$cache_dir/quick-sharun.sh" \ + "https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/b3a9e985cdedf7efa81d172f182cd13983743147/useful-tools/quick-sharun.sh" + chmod +x "$cache_dir/quick-sharun.sh" + - name: build desktop bundles if: matrix.config.release != 'android' shell: bash + # A hung build must fail the STEP (step timeout -> job failure), not hit + # the job-level timeout: job timeouts report `cancelled`, which the + # assemble-manifest guard treats as run cancellation and skips promoting + # latest.json for ALL platforms (#4906). + timeout-minutes: 45 env: TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true' TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -238,6 +272,7 @@ jobs: - name: build and sign portable binaries (Windows only) if: matrix.config.os == 'windows-latest' shell: bash + timeout-minutes: 30 env: TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -381,6 +416,18 @@ jobs: ;; esac + # Attest the distributable binaries staged in nightly-out (apks, AppImage, + # app.tar.gz, setup/portable exe). gh attestation verify is digest-based, + # so it verifies these against readest/readest even though they ship via + # download.readest.com rather than a GitHub release. The .sig updater + # signatures are excluded — they are not binaries users run. + - name: attest nightly binaries + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + nightly-out/Readest* + !nightly-out/*.sig + - name: upload artifacts + fragment to R2 shell: bash run: | diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 390340bd1..c1a2c27b8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,19 +14,19 @@ jobs: SCCACHE_GHA_ENABLED: 'true' RUSTC_WRAPPER: sccache steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'true' - name: setup sccache uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 - name: Install minimal stable with clippy and rustfmt - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: toolchain: stable override: true components: rustfmt, clippy - name: Cache apt packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /var/cache/apt/archives key: apt-rust-lint-${{ runner.os }} @@ -47,7 +47,7 @@ jobs: build_web_app: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'true' @@ -84,7 +84,7 @@ jobs: - name: cache playwright browsers id: playwright-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -122,7 +122,7 @@ jobs: matrix: shard: [1, 2] steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'true' @@ -144,7 +144,7 @@ jobs: - name: cache playwright browsers if: matrix.shard == 1 id: playwright-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -179,13 +179,13 @@ jobs: contents: read pull-requests: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'true' - name: detect koplugin changes id: changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 with: filters: | koplugin: @@ -215,7 +215,7 @@ jobs: - name: cache apt packages if: steps.changes.outputs.koplugin == 'true' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /var/cache/apt/archives key: apt-test-koplugin-${{ runner.os }} @@ -250,7 +250,7 @@ jobs: SCCACHE_GHA_ENABLED: 'true' RUSTC_WRAPPER: sccache steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'true' @@ -266,7 +266,7 @@ jobs: # The tauri tests run `next dev`, whose Turbopack cache lives in # `.next/dev/cache` (a different path from the `next build` cache). - name: cache Turbopack dev cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: apps/readest-app/.next/dev/cache key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -282,7 +282,7 @@ jobs: uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 - name: install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1 + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: toolchain: stable # Disable this action's built-in rust-cache so the explicit @@ -307,7 +307,7 @@ jobs: cache-workspace-crates: 'true' - name: Cache apt packages - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /var/cache/apt/archives key: apt-tauri-${{ runner.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 302109c02..c4f4f8227 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: release_version: ${{ steps.get-release-notes.outputs.release_version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - name: get version @@ -89,7 +89,7 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: create KOReader plugin zip env: @@ -118,10 +118,41 @@ jobs: echo "Uploading ${plugin_zip} to GitHub release" gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber + build-calibre-plugin: + needs: get-release + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: create calibre plugin zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=${{ needs.get-release.outputs.release_version }} + + # Stamp PLUGIN_VERSION in __init__.py with the release version + # (from apps/readest-app/package.json, mirroring the koplugin's + # _meta.lua stamp above); the committed value is a dev placeholder. + version_tuple=$(echo "${version}" | awk -F. '{printf "(%d, %d, %d)", $1, $2, $3}') + perl -i -pe "s/^PLUGIN_VERSION = \(\d+, \d+, \d+\)/PLUGIN_VERSION = ${version_tuple}/" \ + apps/readest-calibre-plugin/__init__.py + + make -C apps/readest-calibre-plugin zip + plugin_zip="apps/readest-calibre-plugin/dist/Readest-${version}.calibre-plugin.zip" + + echo "Uploading ${plugin_zip} to GitHub release" + gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber + build-tauri: needs: get-release permissions: contents: write + # Required by actions/attest-build-provenance: id-token mints the Sigstore + # OIDC identity, attestations writes the provenance to the repo's store. + id-token: write + attestations: write strategy: fail-fast: false matrix: @@ -156,7 +187,7 @@ jobs: runs-on: ${{ matrix.config.os }} timeout-minutes: 60 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: initialize git submodules run: git submodule update --init --recursive @@ -172,7 +203,7 @@ jobs: - name: setup Java (for Android build only) if: matrix.config.release == 'android' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: 'zulu' java-version: '17' @@ -225,6 +256,10 @@ jobs: echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local + echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local + echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env.local + echo "SENTRY_ORG=readest" >> .env.local + echo "SENTRY_PROJECT=readest" >> .env.local cp .env.local apps/readest-app/.env.local - name: build and upload Android apks @@ -267,6 +302,14 @@ jobs: gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber + - name: attest Android apks + if: matrix.config.release == 'android' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_universal.apk + apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_arm64.apk + - name: download and update latest.json for Android release if: matrix.config.release == 'android' env: @@ -308,7 +351,24 @@ jobs: if: matrix.config.release == 'linux' run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force - - uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 + # The truly-portable AppImage bundler downloads quick-sharun.sh from + # Anylinux-AppImages@main ONLY when it is not already in the tauri tools + # cache. An upstream strace-mode change (2026-06-29) made bundling launch + # the app under Xvfb and hang forever (#4906). Seed the cache with the + # last known-good revision so the bundler never fetches the moving + # main-branch script. Keep in sync with nightly.yml. + - name: pin quick-sharun.sh for AppImage bundling (Linux) + if: matrix.config.release == 'linux' + run: | + set -euo pipefail + cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri" + mkdir -p "$cache_dir" + curl -fsSL --retry 3 -o "$cache_dir/quick-sharun.sh" \ + "https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/b3a9e985cdedf7efa81d172f182cd13983743147/useful-tools/quick-sharun.sh" + chmod +x "$cache_dir/quick-sharun.sh" + + - uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 + id: tauri if: matrix.config.release != 'android' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -332,6 +392,16 @@ jobs: releaseBody: ${{ needs.get-release.outputs.release_note }} args: ${{ matrix.config.args || '' }} + # Attest the freshly built desktop bundles (installers, AppImage, dmg, + # updater archives + their .sig). tauri-action reports their on-disk paths + # as a JSON array; fromJSON('"\n"') yields a real newline to join them into + # the newline-delimited list subject-path expects. + - name: attest desktop bundles + if: matrix.config.release != 'android' && steps.tauri.outputs.artifactPaths != '' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: ${{ join(fromJSON(steps.tauri.outputs.artifactPaths), fromJSON('"\n"')) }} + - name: upload release notes to GitHub release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -384,6 +454,15 @@ jobs: echo "Uploading signature to GitHub release" gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber + # The portable rebuild above is not produced by tauri-action, so it is not + # covered by the "attest desktop bundles" step; attest it here. Exactly one + # portable exe is staged at the workspace root per Windows leg. + - name: attest Windows portable binary + if: matrix.config.os == 'windows-latest' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: Readest_*-portable.exe + - name: download and update latest.json for Windows portable release if: matrix.config.os == 'windows-latest' env: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e588b6ddb..9cfd52001 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,7 +34,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: sarif_file: results.sarif diff --git a/.github/workflows/vercel-merge.yml b/.github/workflows/vercel-merge.yml index 15ab32abe..2098725bc 100644 --- a/.github/workflows/vercel-merge.yml +++ b/.github/workflows/vercel-merge.yml @@ -11,7 +11,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'true' - uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42 diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..77b4fc839 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,103 @@ +{ + "languages": { + "Rust": { + "language_servers": ["rust-analyzer"] + }, + "JavaScript": { + "language_servers": ["biome", "...", "!eslint"], + "formatter": { + "language_server": { + "name": "biome" + } + }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "TypeScript": { + "language_servers": ["biome", "...", "!eslint"], + "formatter": { + "language_server": { + "name": "biome" + } + }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "TSX": { + "language_servers": ["biome", "...", "!eslint"], + "formatter": { + "language_server": { + "name": "biome" + } + }, + "code_actions_on_format": { + "source.fixAll.biome": true, + "source.organizeImports.biome": true + } + }, + "CSS": { + "language_servers": ["biome", "...", "!eslint"], + "formatter": { + "language_server": { + "name": "biome" + } + } + }, + "JSON": { + "language_servers": ["biome", "..."], + "formatter": { + "language_server": { + "name": "biome" + } + } + }, + "JSONC": { + "language_servers": ["biome", "..."], + "formatter": { + "language_server": { + "name": "biome" + } + } + } + }, + "lsp": { + "rust-analyzer": { + "settings": { + "linkedProjects": ["packages/tauri/Cargo.toml", "apps/readest-app/src-tauri/Cargo.toml"] + } + }, + "biome": { + "binary": { + "path": "node_modules/.bin/biome", + "arguments": ["lsp-proxy"] + } + }, + "vtsls": { + "settings": { + "typescript": { + "inlayHints": { + "parameterNames": { + "enabled": "all" + }, + "variableTypes": { + "enabled": true + }, + "propertyDeclarationTypes": { + "enabled": true + }, + "functionLikeReturnTypes": { + "enabled": true + }, + "enumMemberValues": { + "enabled": true + } + } + } + } + } + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dc9f3f5c..e6721c19e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,35 +20,33 @@ Basically you need to install or update the following development tools: - **Rust** and **Cargo** for Tauri development ```bash -nvm install v22 -nvm use v22 +nvm install v24 +nvm use v24 npm install -g pnpm rustup update ``` -### Getting Started +## Getting Started To get started with Readest, follow these steps to clone and build the project. -#### 1. Clone the Repository +### 1. Clone the Repository ```bash git clone https://github.com/readest/readest.git cd readest -git submodule update --init --recursive ``` -#### 2. Install Dependencies +### 2. Install Dependencies ```bash # might need to rerun this when code is updated +git submodule update --init --recursive pnpm install # copy vendors dist libs to public directory pnpm --filter @readest/readest-app setup-vendors ``` -#### 3. Verify Dependencies Installation - To confirm that all dependencies are correctly installed, run the following command: ```bash @@ -59,21 +57,78 @@ This command will display information about the installed Tauri dependencies and For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`. -#### 4. Build for Development +#### Using Nix + +If you have Nix installed, you can leverage the included flake to enter a +development shell to install and run all the necessary dependencies and commands: ```bash -pnpm tauri dev +nix develop ./ops # enter a dev shell for the web app +nix develop ./ops#ios # enter a dev shell for the ios app +nix develop ./ops#android # enter a dev shell for the android app ``` -#### 5. Build for Production +### 4. Build for Development + +```bash +# Start development for the Tauri app +pnpm tauri dev +# or start development for the Web app +pnpm dev-web +# preview with OpenNext build for the Web app +pnpm preview +``` + +#### Android + +The following must be run once before running the Android app. Note that this is done automatically if using the nix Android devshell: + +```bash +rm apps/readest-app/src-tauri/gen/android +pnpm tauri android init +pnpm tauri icon ../../data/icons/readest-book.png +git checkout apps/readest-app/src-tauri/gen/android +``` + +To run the Android app: + +```bash +pnpm tauri android dev +# or if you want to dev on a real device +pnpm tauri android dev --host +``` + +#### iOS + +```bash +# Set up the iOS environment (run once) +pnpm tauri ios init +pnpm tauri icon ../../data/icons/readest-book.png + +pnpm tauri ios dev +# or if you want to dev on a real device +pnpm tauri ios dev --host +``` + +### 5. Build for Production ```bash pnpm tauri build +pnpm tauri android build +pnpm tauri ios build ``` +Please refer to our release script if you experience any issues: +https://github.com/readest/readest/blob/main/.github/workflows/release.yml + + +### 7. More information + +Please check the [wiki][link-gh-wiki] of this project for more information on development. + Now you're all setup and can start implementing your changes. -### Implement your changes +## Implement your changes This project is a monorepo. The code for the `readest-app` is in the `apps/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri: @@ -82,12 +137,20 @@ This project is a monorepo. The code for the `readest-app` is in the `apps/reade | `pnpm dev-web` | Starts the development server for the web app only | | `pnpm build-web` | Builds the web app | -Recommended Visual Studio Code plugins for development: +### Editor-specific setup -- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next) -- VS Code ESLint extension (dbaeumer.vscode-eslint) -- Biome - Code formatter and linter (biomejs.biome) -- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only) +#### VS Code + +Upon opening the project, you will be prompted to install the following recommended extensions: + +- JavaScript and TypeScript Nightly (`ms-vscode.vscode-typescript-next`) +- VS Code ESLint extension (`dbaeumer.vscode-eslint`) +- Biome - Code formatter and linter (`biomejs.biome`) +- rust-analyzer (`rust-lang.rust-analyzer`) (for Tauri development only) + +#### Zed + +The only extension needed is [biome-zed](https://github.com/biomejs/biome-zed), for code formatting and linting. ### When you're done diff --git a/Cargo.lock b/Cargo.lock index 49153bd16..ccf94eb2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,7 @@ dependencies = [ "read-progress-stream", "reqwest 0.12.28", "semver", + "sentry", "serde", "serde_json", "tauri", @@ -57,6 +58,7 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-persisted-scope", "tauri-plugin-process", + "tauri-plugin-sentry", "tauri-plugin-sharekit", "tauri-plugin-shell", "tauri-plugin-sign-in-with-apple", @@ -71,9 +73,19 @@ dependencies = [ "tokio", "tokio-util", "walkdir", + "winreg 0.52.0", "zip 2.4.2", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -544,6 +556,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.21.7" @@ -748,7 +775,7 @@ checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -1030,6 +1057,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1340,6 +1373,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crash-context" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" +dependencies = [ + "cfg-if", + "libc", + "mach2", +] + +[[package]] +name = "crash-handler" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3" +dependencies = [ + "cfg-if", + "crash-context", + "libc", + "mach2", + "parking_lot", +] + [[package]] name = "crc32c" version = "0.6.8" @@ -1621,6 +1678,16 @@ dependencies = [ "keyring-core", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid 1.23.2", +] + [[package]] name = "default-net" version = "0.22.0" @@ -1964,7 +2031,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2730,6 +2797,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -2894,6 +2967,17 @@ dependencies = [ "system-deps 7.0.8", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "gtk" version = "0.18.2" @@ -3068,6 +3152,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -3983,6 +4078,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4126,6 +4230,75 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minidump-common" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77" +dependencies = [ + "bitflags 2.11.1", + "debugid", + "num-derive", + "num-traits", + "range-map", + "scroll", + "smart-default", +] + +[[package]] +name = "minidump-writer" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d" +dependencies = [ + "bitflags 2.11.1", + "byteorder", + "cfg-if", + "crash-context", + "goblin", + "libc", + "log", + "mach2", + "memmap2", + "memoffset", + "minidump-common", + "nix 0.28.0", + "procfs-core", + "scroll", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "minidumper" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4ebc9d1f8847ec1d078f78b35ed598e0ebefa1f242d5f83cd8d7f03960a7d1" +dependencies = [ + "cfg-if", + "crash-context", + "libc", + "log", + "minidump-writer", + "parking_lot", + "polling", + "scroll", + "thiserror 1.0.69", + "uds", +] + +[[package]] +name = "minidumper-child" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4f23f835dbe67e44ddf884d3802ff549ca5948bf60e9fd70e9a13c96324d1" +dependencies = [ + "crash-handler", + "minidumper", + "thiserror 1.0.69", + "uuid 1.23.2", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4351,6 +4524,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -4359,7 +4544,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -4486,6 +4671,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -4856,6 +5052,15 @@ dependencies = [ "objc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5386,6 +5591,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.9.0" @@ -5581,6 +5792,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.11.1", + "hex", +] + [[package]] name = "proptest" version = "1.11.0" @@ -5724,7 +5945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -5764,7 +5985,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -5943,6 +6164,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "range-map" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a5a2d6c7039059af621472a4389be1215a816df61aa4d531cfe85264aee95f" +dependencies = [ + "num-traits", +] + [[package]] name = "rapidhash" version = "4.4.1" @@ -6078,6 +6308,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -6269,6 +6500,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6332,6 +6569,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -6514,6 +6752,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sdd" version = "3.0.10" @@ -6596,6 +6854,113 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sentry" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" +dependencies = [ + "httpdate", + "reqwest 0.12.28", + "rustls", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac0c5d6892cd4c414492fc957477b620026fb3411fca9fa12774831da561c88" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc" +dependencies = [ + "rand 0.9.4", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-panic" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b7a23b13c004873de3ce7db86eb0f59fe4adfc655a31f7bbc17fd10bacc9bfe" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-rust-minidump" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63964525bf74b16233dbcfb307e11485ebd8ff8f87f6ae212b07ca7937cd2db1" +dependencies = [ + "minidumper-child", + "sentry", + "thiserror 2.0.18", +] + +[[package]] +name = "sentry-tracing" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080" +dependencies = [ + "bitflags 2.11.1", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412" +dependencies = [ + "debugid", + "hex", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid 1.23.2", +] + [[package]] name = "serde" version = "1.0.228" @@ -6988,6 +7353,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "socket2" version = "0.6.4" @@ -7167,8 +7543,6 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" dependencies = [ "base64 0.21.7", "serde", @@ -7889,11 +8263,16 @@ name = "tauri-plugin-native-bridge" version = "0.1.0" dependencies = [ "apple-native-keyring-store", + "base64 0.22.1", + "block", + "cocoa", "dbus-secret-service-keyring-store", "font-enumeration", "keyring-core", + "objc", "schemars 0.8.22", "serde", + "serde_json", "tauri", "tauri-plugin", "thiserror 2.0.18", @@ -7992,6 +8371,22 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-sentry" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7432b519b6d2d027082a940783c61ba9b684b38d8df7558cd5f924d87009b295" +dependencies = [ + "base64 0.22.1", + "schemars 0.8.22", + "sentry", + "sentry-rust-minidump", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-sharekit" version = "0.3.1" @@ -8894,7 +9289,7 @@ dependencies = [ "branches", "bumpalo", "bytemuck", - "cfg_aliases", + "cfg_aliases 0.2.1", "cfg_block", "chrono", "crc32c", @@ -9100,6 +9495,15 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c31f06fce836457fe3ef09a59f83fe8db95d270b11cd78f40a4666c4d1661" +dependencies = [ + "libc", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -9111,6 +9515,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unarray" version = "0.1.4" @@ -9217,6 +9630,34 @@ dependencies = [ "typenum", ] +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -9260,6 +9701,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -10099,6 +10546,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -10420,6 +10876,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/Cargo.toml b/Cargo.toml index 2ca3c71ff..1bb082c77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,7 @@ rust-version = "1.77.2" [patch.crates-io] tauri = { path = "packages/tauri/crates/tauri" } tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" } +# Xcode 26.2 (Swift 6.2) broke upstream swift-rs 1.0.7's per-swiftc target +# override; the vendored copy cross-compiles via `--triple`/`--sdk` instead. +# Upstream is unmaintained (last release 2024). See packages/swift-rs. +swift-rs = { path = "packages/swift-rs" } diff --git a/README.md b/README.md index f7dd9fd7b..f94840672 100644 --- a/README.md +++ b/README.md @@ -121,115 +121,9 @@ Guides, tutorials, and FAQs for installing and using Readest live in the officia 📖 **[https://readest.com/docs][link-docs]** -## Requirements +## Building from Source -- **Node.js** and **pnpm** for Next.js development -- **Rust** and **Cargo** for Tauri development - -For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms. - -```bash -nvm install v24 -nvm use v24 -npm install -g pnpm -rustup update -``` - -## Getting Started - -To get started with Readest, follow these steps to clone and build the project. - -### 1. Clone the Repository - -```bash -git clone https://github.com/readest/readest.git -cd readest -``` - -### 2. Install Dependencies - -```bash -# might need to rerun this when code is updated -git submodule update --init --recursive -pnpm install -# copy vendors dist libs to public directory -pnpm --filter @readest/readest-app setup-vendors -``` - -### 3. Verify Dependencies Installation - -To confirm that all dependencies are correctly installed, run the following command: - -```bash -pnpm tauri info -``` - -This command will display information about the installed Tauri dependencies and configuration on your platform. Note that the output may vary depending on the operating system and environment setup. Please review the output specific to your platform for any potential issues. - -For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`. - -### 4. Build for Development - -```bash -# Start development for the Tauri app -pnpm tauri dev -# or start development for the Web app -pnpm dev-web -# preview with OpenNext build for the Web app -pnpm preview -``` - -For Android: - -```bash -# Initialize the Android environment (run once) -rm apps/readest-app/src-tauri/gen/android -pnpm tauri android init -pnpm tauri icon ../../data/icons/readest-book.png -git checkout apps/readest-app/src-tauri/gen/android - -pnpm tauri android dev -# or if you want to dev on a real device -pnpm tauri android dev --host -``` - -For iOS: - -```bash -# Set up the iOS environment (run once) -pnpm tauri ios init -pnpm tauri icon ../../data/icons/readest-book.png - -pnpm tauri ios dev -# or if you want to dev on a real device -pnpm tauri ios dev --host -``` - -### 5. Build for Production - -```bash -pnpm tauri build -pnpm tauri android build -pnpm tauri ios build -``` - -Please refer to our release script if you experience any issues: -https://github.com/readest/readest/blob/main/.github/workflows/release.yml - -### 6. Setup dev environment with Nix - -If you have Nix installed, you can leverage flake to enter a development shell -with all the necessary dependencies: - -```bash -nix develop ./ops # enter a dev shell for the web app -nix develop ./ops#ios # enter a dev shell for the ios app -nix develop ./ops#android # enter a dev shell for the android app -``` - -### 7. More information - -Please check the [wiki][link-gh-wiki] of this project for more information on development. +To build Readest from the latest commit, see [Getting Started](./CONTRIBUTING.md#getting-started). ## Troubleshooting diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 853a79aef..d0ccf2af9 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -1,150 +1,142 @@ # Readest Project Memory -## Key Reference Documents -- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies -- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline -- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns -- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues -- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs -- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs - -## Security -- [download_file scope Android regression](download-file-scope-android-regression.md) — #4639 strict `is_allowed` broke ALL Android downloads to app data dir (covers/dicts/books); `app.fs_scope()` lacks command-scoped capability globs; fix = `app.path()` base-dir membership. On-device CDP verify recipe + raw-invoke Channel trick -- [Security advisories 2026-06](security-advisories-web-2026-06.md) — all 4 GHSA fixed in PR #4638 (web: A OPDS-proxy SSRF + canonical `isBlockedHost` in network.ts + `isLanAddress` merge, B storage `isSafeObjectKeyName`, D Stripe `metadata.userId` ownership) + PR #4639 (native C: `transfer_file.rs` fs_scope guard). OPDS proxy can't require auth (`` usage); strict `is_allowed` for C; shared-target worktree build-cache pollution gotcha - -## Paginator Scroll Knowledge -- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas -- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling -- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF) -- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas -- [TOC current-position row](toc-current-position-row.md) — synthetic "Current position" row (open-book icon + live `progress.page`) injected one level deeper under the active TOC item via `buildTOCDisplayItems` in `TOCItem.tsx`. INVARIANT: insert AFTER the active item so its `flatItems` index stays valid for the auto-scroll effects -- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap -- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js) -- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container -- [Dark-mode texture occluded by body bg (#4446)](dark-mode-texture-body-bg-4446.md) — RESOLVED (PR #4564): `body.theme-dark{bg !important}` from #4392 (v0.11.4, NOT foliate-js) painted iframe bodies opaque dark → occluded host texture + poisoned `docBackground` capture → opaque segments/view bgs; fix = `transparent !important` UNCONDITIONALLY (texture-gating would go stale: capture is once-per-section-load); CDP gotchas = patch ALL multiview iframes, stale preload views survive navigation ±2, load-listener sees exact capture-time state -- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits -- [Inline-block column overflow](inline-block-column-overflow.md) — chapter skips to "Reference materials", clipping a large middle; EPUB wraps body in `display:inline-block` div → atomic-inline box can't fragment across columns → vertical overflow clipped (scrollHeight≫clientHeight). Fix = paginator `#demoteUnfragmentableBoxes` in `columnize` (col-mode, over-tall atomic-inline→fragmentable block). Renders via goTo/next but pages unreachable; scrolled mode unaffected - +## Key Reference Documents (aggregators) +- [Bug Patterns](bug-patterns.md) · [CSS & Style](css-style-fixes.md) EPUB CSS + style.ts · [TTS](tts-fixes.md) +- [Layout & UI](layout-ui-fixes.md) insets/z-index · [Platform Compat](platform-compat-fixes.md) · [Annotator & Reader](annotator-reader-fixes.md) +## Safety & Security +- [In-place delete wiped originals](in-place-delete-wiped-originals.md) never `fs.removeFile` `external` · [Backup zip Windows paths #4703](backup-windows-zip-paths-4703.md) normalize `\` · [download_file scope Android #4639](download-file-scope-android-regression.md) +- [Security advisories 2026-06](security-advisories-web-2026-06.md) 4 GHSA #4638; SSRF guard broke dev-LAN OPDS, dev-only exemption 2026-07 PR#5002 +## Paginator & Scroll +- Reading ruler: [line-aware](reading-ruler-line-aware.md) frame-offset map; [vertical-rl backwards #4865](reading-ruler-vertical-rtl-4865.md) +- [Vertical-rl horizontal pagination (#624)](vertical-rl-horizontal-pagination-624.md) — horizontal inputs + two-phase slide; rtl gated `!vertical` +- [Slide/curl turn styles via VT (#555)](page-turn-styles-viewtransitions-555.md) — VT turns gated on nested-VT-groups support (iOS 18 WebKit crashes despite having the API); Tauri fallback = `CapturedPageTurn` capture pipeline (WebGL mesh curl + flat canvas slide, full-gridcell capture, instant nav = drop `animated`); MERGED #4940 (2026-07-05), verified live mac/iOS/Android (mobile = JPEG capped 2x, PNG was 1.5s/turn); Win/Linux capture still open + - [Captured turn ignored instant-highlight hold](captured-turn-instant-highlight-scrolllock.md) — captured slide/curl swipe path (app interceptor, `no-swipe` set) didn't honor `renderer.scrollLocked`; fix = foliate `get scrollLocked()` + gate `useCapturedTurn` move on it (push/VT-slide already gated); PR#5000 + foliate#51 +- TOC: [expand + auto-scroll](toc-expand-and-autoscroll.md); [current-position row](toc-current-position-row.md); [table heading clip #4439](toc-table-heading-clip-4439.md); [BooknoteView auto-scroll #4352](booknote-view-autoscroll-4352.md) +- Paginated bg: [swipe flash](paginator-swipe-bg-flash.md); [texture occlusion #4399](paginated-texture-occlusion-4399.md); [gutter bleed #4394](paginator-gutter-bleed-asymmetry-4394.md); [bg-replace reflow #4785](pageturn-bg-replace-reflow-4785.md) +- [Inline-block column overflow](inline-block-column-overflow.md) `#demoteUnfragmentableBoxes` +- FXL/PDF: [fit-width scroll reset #4683](fixed-layout-paginated-scroll-reset-4683.md); [PDF spread seam #4587](pdf-spread-canvas-seam-4587.md); [spine seam #4857](fxl-spread-spine-seam-4857.md); [portrait auto-spread off-center #4984](fxl-portrait-autospread-offcenter-4984.md) MERGED PR#4992+foliate#50 lone page kept one-sided auto margin -> stranded + taps turned page; `computeSpreadInlineMargins` +- Scrolled: [PDF wheel double #4727](pdf-scroll-mode-wheel-double-4727.md); [header title center #4436](scrolled-header-title-center-4436.md); [Duokan fullscreen cover](duokan-fullscreen-cover-scroll.md) ## Critical Files (Most Bug-Prone) -- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes) -- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds -- `src/services/tts/TTSController.ts` - TTS state machine, section tracking -- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management -- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration -- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle - +- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` · `src/services/tts/TTSController.ts` +- `src/hooks/useSafeAreaInsets.ts` · `src/app/reader/components/FoliateViewer.tsx` · `.../annotator/Annotator.tsx` ## Sync Notes -- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw -- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values -- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind -- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0` -- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=required∪optional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed -- [Statusless books re-pinned to top (#4677)](sync-statusless-book-rebump-4677.md) — never-statused (locally-imported, never-pulled) books send `reading_status:undefined` vs server `null`; server POST `statusChanged` (`undefined!==null`) rewrites `updated_at=now()` every push → batch-identical ts pins them top of date-sort; 1-day re-sync window amplifies; fix = `(a??null)!==(b??null)`. On-device CDP PUSH_SENT-vs-RETURNED proof recipe - -## Testing -- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates -- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android`: adb+CDP drives the installed app on device/emulator; discover-don't-assume targeting, injected hyphenation, MediaStore VIEW transient open (canonical `_data` path gotcha), per-section frame restore; CI workflow with KVM emulator + debug x86_64 APK (no signing secrets) -- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — drive the on-device Readest WebView via adb+CDP to run JS probes/benchmarks inside the live app (no rebuild); gotchas: locked device freezes fetch (not invoke), visible:false throttles setTimeout, `__TAURI_INTERNALS__.convertFileSrc/invoke` always present, books in internal `/data/user/0/...`, fs `read{rid,len}` last-8-bytes=nread, `fs|close` not ACL-allowed, curl mishandles WebView HTTP framing -- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified`→`published` divergence fix -- [TTS browser e2e harness](tts-browser-e2e-harness.md) — faithful auto-advance test (real `` + real `useTTSControl` + mock ONLY the 3 client modules; mock `speak()` yields `end` to drive the real `forward()` walk); seed readerStore/bookDataStore + `settings.globalViewSettings` (else `getMergedRules` crash stops TTS); reproduce FoliateViewer relocate→setProgress; sample-alice Ch4=section 6/Ch5=section 7; assert badge `false` BEFORE tts-stop -- [TTS sync chrome verification](tts-sync-chrome-verification.md) — Edge TTS WORKS in claude-in-chrome (WebSpeech errors there with `InvalidStateError`); use an Edge voice to verify TTS-driven features live (RSVP followed at ~171 wpm). Synthetic-CFI debug recipe (expose controller, `syncToCfi(view.getCFI(docIndex, word.range))`). Exposed the #3235 cross-realm `instanceof Range` bug (frozen RSVP/paragraph follow) → `isRangeLike()` duck-type fix -- [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions` - -## Build & Vendoring -- [fastlane Apple App Store submission](fastlane-apple-appstore-submission.md) — `release_ios`/`release_macos` lanes (App Store review + TestFlight on the altool-uploaded build); gotchas = Tauri auto-notarizes if `APPLE_API_KEY_PATH` is in the macOS build env (keep it OUT, derive from key id), fastlane runs lanes from `./fastlane` so anchor paths via `repo_path`, npm-dotenv-cli vs Ruby-gem shadowing + `cd ../..` -- [Turbopack build-cache OOM + gated Docker standalone (#4619)](turbopack-build-cache-oom-docker-standalone.md) — interrupted-build partial `turbopackFileSystemCacheForBuild` cache → 42 workers/18GB-swap freeze (clean build=~6.5GB); disabled the flag; `output:'standalone'` gated on `BUILD_STANDALONE` (Docker-only); tauri CI uses `next dev` (config-independent) -- [Deps/security override workflow](deps-security-overrides-workflow.md) — fix transitive npm Dependabot alerts: main monorepo overrides live in `pnpm-workspace.yaml` (NOT root package.json); `packages/tauri-plugins` is a SEPARATE submodule project w/ own lockfile + `minimumReleaseAge` (main workspace has no age gate); bound 0.x overrides like `vite`; verify via test+lint+build-web. PR #4618 (esbuild 0.28.1, vitest 4.1.9) -- [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow -- [Deploy workers.dev SNI-block + proxy](deploy-workers-dev-sni-proxy.md) — pnpm deploy crash (CN): workers.dev SNI-blocked (DoH useless), R2 populate WS hangs even via proxy; shipped fix = `dangerous.disableIncrementalCache:true` in open-next.config (stock deploy skips populate; readest has no ISR so runtime no-op) -- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*` - +- [Cloud Sync provider selection #4959/#4380](cloud-sync-provider-selection-plan.md) MERGED #4971+#4973+#4975+#4976: derived provider, exclusive routing, syncBooks auto-enable, fleet probe, chooser; i18n pass + live verify pending +- [Grimmory native sync](grimmory-native-sync.md) Booklore-fork REVERTED +- KOSync: [CFI spine resolution](kosync-cfi-spine-resolution.md); [connect() false-positive #4692](kosync-connect-false-positive-4692.md) +- [Empty-start CFI sync](empty-start-cfi-sync.md) · [Custom fonts vanish #4410](custom-fonts-reincarnation-4410.md) CRDT remove-wins +- koplugin: [note deletion](koplugin-note-deletion-sync.md) tombstone; [stats #4666](koplugin-stats-sync.md); [bulk download #4751](koplugin-bulk-download-4751.md); [dup book rows #4861](koplugin-stats-duplicate-book-rows-4861.md) +- [Statusless re-pin #4677](sync-statusless-book-rebump-4677.md) · [pull cursor synced_at #4678](sync-synced-at-cursor-4678.md) +- [koplugin library stale #4934](koplugin-library-stale-synced-cursor-4934.md) pull cursor updated_at→synced_at + split push watermark + v2→v3 heal migration +- WebDAV: [metadata #4756](webdav-metadata-sync-4756.md) LWW; [group membership #4942](webdav-group-membership-sync-4942.md) mergeBookMetadata dropped groupId/groupName; [credentials #4810](webdav-credential-sync-4810.md); [connect nullified #4780](webdav-connect-nullified-4780.md) stale closure +- [WebDAV deletion + upload-after-enable (#4860/#4856)](webdav-deletion-and-upload-after-enable-4860-4856.md) edit-wins LWW + tombstone union +- File sync: [refactor #4784](webdav-filesync-refactor-plan.md) `FileSyncEngine`; [third-party auto-sync #4835](third-party-library-autosync-4835.md) +- [Transfer Queue clear not persisted](transfer-queue-clear-persistence.md) hook mutated store directly, skipped `persistQueue()`; route clears through `transferManager` +- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md) +- Google Drive: [research](gdrive-sync-provider-research.md); [multi-PR status](gdrive-provider-multipr-status.md); [full walk every sync](gdrive-fullwalk-every-sync-no-source-cursor.md) no-source books never recorded in uploadedHashes + focus refires pullLibrary +- [S3/R2 provider](s3-r2-sync-provider.md) third backend, aws4fetch SigV4 path-style, full slice on dev uncommitted; live R2 verify pending +- [OneDrive provider #5007](onedrive-sync-provider.md) 4th backend; shared `providers/oauth/` extraction + path-addressed Graph App Folder; branch `feat/onedrive-sync` 28 commits merge-ready, LOCAL; Azure client id = ops blocker; LESSON: settingsSync.ts #4580 slice + s3 backup-blacklist gap +- [Hardcover edition_id (#4792)](hardcover-progress-edition-id-4792.md) +## Build, Testing & CI +- [Nightly quick-sharun hang #4906](nightly-quick-sharun-hang-4906.md) pin via cache pre-seed + step timeouts +- [format:check separate gate](verify-format-check-gate.md) · [Worktree rebase submodule drift](worktree-rebase-submodule-drift.md) +- Android CDP: [e2e lane](android-cdp-e2e-lane.md) `pnpm test:android`; [WebView profiling](cdp-android-webview-profiling.md); [double-tap gesture](android-e2e-doubletap-cdp-gesture.md) +- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md) +- TTS tests: [browser e2e harness](tts-browser-e2e-harness.md); [paragraph+RSVP sync #3235](tts-sync-paragraph-rsvp-3235.md) TTS-is-clock +- [fastlane App Store](fastlane-apple-appstore-submission.md) `APPLE_API_KEY_PATH` out of build env +- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md) +- [Deps override workflow](deps-security-overrides-workflow.md) `pnpm-workspace.yaml` +- [Xcode 26.2 broke iOS builds (swift-rs)](xcode26-swiftrs-ios-build-broken.md) — phantom `Bundle.main`/`privacy:` errors; vendored `packages/swift-rs` `--triple`/`--sdk`; Package.swift platforms floor now enforced +- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) copy `wasm/*` +- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) ## Platform Compat -- [Android hyphen selection bounds (#1553)](android-hyphen-selection-bounds-1553.md) — Blink paints the start handle on the paragraph's LAST hyphen when a touch selection starts at the first word of a hyphenated paragraph (`ComputePaintingSelectionStateForCursor` lacks the generated-text offset remap, hyphen offsets {0,1}); drag-extend re-anchors base there. Fix = repair jumped anchor + suppress handles (empty-commit needs one painted frame) + `SelectionRangeEditor` custom handles; multicol NOT required; desktop/iOS unaffected -- [Android NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — why NativeFile is slow (4-IPC/chunk + bridge serialization, tauri#9190); RemoteFile CANNOT replace it on Android (asset-protocol Range broken: start>0 → "Failed to fetch", start-0 capped at 1,024,000; plain no-Range fetch returns full file at 281 MB/s); measured 44/100/281 MB/s; speedups = handle-reuse (2.3×), whole-file asset loader (6.3×), or fix wry upstream. Verified live via CDP. -- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives -- [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent` → `shared-intent` → `useAppUrlIngress` → `useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant) -- [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT`→`LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx` -- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim -- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — 50MB+ PDF import/open crash = foliate makePDF firing ALL pdf.js range reads un-awaited (753 concurrent fetch→shouldInterceptRequest→Java byte[] → 512MB heap OOM), NOT whole-file load; official viewer survives via browser ~6-conn/host cap; fix = MAX_CONCURRENT_RANGES=6 queue in makePDF; on-device CDP recipe; Xiaomi13/WV147 won't OOM but flood 753→6 verified - -## Feature Notes -- [Save image to gallery (Android, #4680)](save-image-to-gallery-android.md) — image-viewer Save button → MediaStore on Android (share sheet can't save-to-file: ACTION_SEND has no file-manager target); sharekit 0-byte self-copy bug (Temp==cacheDir); tsgo misses abstract-class conformance (real tsc catches); on-device CDP verify recipe -- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork) -- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift) -- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent`→`useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout` -- [#4584 tap-death investigation](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heals (RED HERRING, don't "fix" it); likely WebView-148-specific (emulator=133 can't repro); Android emulator/CDP gesture-verification gotchas (swiftshader ANR=artifact, CDP can't native-select, screenX=0) -- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected selections (`ran`/`mice`/`analyses`) resolve to base headwords (`run`/`mouse`/`analysis`) in dicts that store only lemmas (ODE). Pluggable `lemmatize/` registry (default English, explicit non-English no-op), English rules+irregulars, appended to tail of `buildLookupCandidates` so exact match wins; over-generate + dict-validates; `-ses→-sis` ordered before `-es` -- [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordlens-data.mjs` (ECDICT/CC-CEDICT+HSK) -- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — iOS emits multiple `selectionchange`/long-press → instant sys-dict fired 2-3×; deferredAction `fired` once-per-gesture latch + `beginGesture`; tap-to-deselect re-fire fixed by `isLongPressHold` 300ms gate (!isAndroid); Word Lens `wantWordLensDict` now routes via `handleDictionary` to honor system dict -- [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header) -- [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate) -- [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after ``; Firefox DOMParser → `` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap) -- [OPDS 2.0 JSON search greyed out (#4502)](opds2-json-search-4502.md) — `isSearchLink` ignored templated `application/opds+json` links → `hasSearch` false → disabled navbar input; add `MIME.OPDS2`+`templated`, `expandOPDSSearchTemplate` (foliate `uri-template.js`), handleSearch OPDS2 branch. Gotcha: `resolveURL` mangles `{?query}` braces — expand template BEFORE resolving -- [OPDS HTML description (#4503)](opds-html-description-4503.md) — detail-view descriptions showed raw `

`/`"` tags; aggregator double-escapes `type="text"` summary + `PublicationView` dumped it into unsanitized `dangerouslySetInnerHTML`; fix = `getOPDSDescriptionHtml` (decode-one-level-iff-fully-escaped, then `sanitizeHtml`) -- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support -- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls -- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes) -- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md -- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales//translation.po`, extract/apply scripts in `scripts/` -- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)` → `writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader` - -## Feedback -- [Commit messages English-only](feedback-commit-message-english-only.md) — commit messages + PR titles must be English only (no CJK glyphs, no em/en dashes); keep CJK examples/screenshots in the PR body, code, and tests. From PR #4660 - -## Patterns -- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews -- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls - -## Reader UI Fixes -- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — mobile RSVP TTS+settings overlapping transport was a REGRESSION: #4585 fixed it (in-flow `justify-between md:justify-center`), stale-branch #4589 (Word Wise) squash-reverted the whole fix incl. its guard test; re-fixed + on-device CDP recipe (Shift+V to enter RSVP, live-DOM preview) -- [Overlay z-index scale](zindex-overlay-scale.md) — compact global scale (RSVP 100/101, Settings 110, ModalPortal/CmdPalette 120, toast 130, app-lock 200) replacing insane 10000s; Add-Catalog-behind-Settings was MOBILE-ONLY (desktop `.window-border` z-99 traps inline Settings; ModalPortal portals to body→wins); static invariant test `zIndexScale.test.ts`; on-device CDP verify via `pnpm dev-android` devtools build -- [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`) -- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe -- [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `

`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects` -- [Android image callout freeze](android-image-callout-freeze.md) — long-press `` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`) -- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation -- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate -- [Double-click-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — web double-click+drag selection also turned the page; 1st click's deferred single-click (250ms) fires mid-drag while 2nd-click button held; fix = `isMouseDown` flag in `iframeEventHandlers.ts` gates the deferred `postSingleClick`; synthetic-repro gotchas (shadow-DOM iframe walk, chained-repro timing pollution, reload to re-bind listeners) -- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — RSVP word was hardcoded `font-mono`; now mirrors the reader font via `getBaseFontFamily(viewSettings)` (new export in `style.ts`, shares `buildFontFamilyLists` with `getFontStyles`). Overlay renders in the TOP document (portal to body) where custom + basic Google fonts are mounted; known gap = built-in CJK web fonts only in top doc when `isCJKEnv()` -- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — Arabic/RTL word window showed separated, reversed letters: ORP focus-letter split slices words by char index (breaks shaping/order); fix = `isRTLText` → render RTL whole via the CJK `.rsvp-word-whole` branch with `dir=rtl`. Literal-RTL-char Edit pitfall → write regex with `\u` escapes -- [Edge TTS word-highlight drift on middle sentences](tts-word-highlight-singletextnode-drift.md) — `rangeTextExcludingInert` TEXT_NODE fast path ignored range offsets → returned whole paragraph → word offsets drift (spoken "Those"→hl "if th"); only middle sentences of single-`` paras (cac=TEXT_NODE); Edge-only; fix=slice [startOffset,endOffset]; added dev-only `[TTS] word-sync` log; select-word→popup-headphone repro -- [TTS start-from-selection bugs](tts-start-from-selection.md) — foliate `from()` picked first mark at/after selection → started NEXT sentence for non-first words (fix=last mark at/before); + Annotator now `cloneRange()`+`view.deselect()` on TTS start so the word doesn't stay selected; jsdom needs `CSS.escape` polyfill (vitest.setup) since `from()` uses it -- [Reuse TTS session on Paragraph/RSVP entry](tts-reuse-session-mode-entry.md) — modes only engaged following on a fresh `playing` event → entering with TTS already playing didn't sync. Fix = `TTSController.redispatchPosition()` + `useTTSControl` `tts-sync-request` replay (position-before-state) + per-mode engage-on-entry effect (following=true, reset lastSequenceSeen, dispatch request); RSVP paused branch also `setExternallyDriven(true)`. Paragraph live-verified ("Following audio" on entry) -- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — v0.11.4 regression: stray horizontal line below footnote marker. #4383 inlined custom `@font-face` BEFORE the `@namespace epub` (which lived in `getPageLayoutStyles`), invalidating it per CSS spec → namespaced `aside[epub|type~="footnote"]{display:none}` dropped → book's `aside{border:3px double}` showed. Only with custom fonts loaded. Fix = hoist `@namespace` to front of `getStyles`. Repro needs XHTML (`epub:type` namespaced only in XML); Playwright `setContent` parses HTML and won't reproduce -- [Scrolled-mode notch mask vs texture (#4486)](notch-mask-texture-4486.md) — top inset mask occluded the bg texture; full-cell + clip-path paint-box-matching for tile alignment; CDP-inject + MAE seam verification on device; adb taps in status-bar region eaten by SystemUI -- [Paragraph-mode accidental exit + off-center bar (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop/center taps exited focus mode (stray "too high/low" taps); `ParagraphBar` only reshows on mousemove (no touch reshow) so can't just delete tap-exits → new `paragraph-show-controls` event reveals the bar instead. Also bar `absolute`→`fixed`: it centered on the gridcell which a pinned sidebar shifts right, while the paragraph centers on the `fixed inset-0` overlay/viewport -- [Share intent + customizable toolbar (#4014)](annotation-share-toolbar-4014.md) — Share tool in the selection toolbar (sharekit gated mobile+macOS only re: #4343 Windows freeze; `canShareText`/`shareSelectedText` in dual-purpose `share.ts`) + drag-and-drop customizer sub-page; `annotationToolbarItems` view setting (Share hidden by default); pure helpers in `annotationToolbar.ts` - +- Android: [hyphen selection #1553](android-hyphen-selection-bounds-1553.md); [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) +- [Window-state sanitizer #4398](window-state-sanitize-4398.md) · [Android themed icon #4733](android-themed-icon-4733.md) +- [Open-with intent #4521](android-open-with-intent-flow.md) · [dict lookup hijack #4559](dict-lookup-browser-hijack-4559.md) +- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) MAX_CONCURRENT_RANGES=6 +- [Black screen external cache (#4853)](android-black-screen-external-cache-4853.md) FIXED PR#4889; drop `$CACHE` grants +- [macOS 26 Tahoe close→black window (#4875)](macos26-tahoe-close-black-screen-4875.md) `minimize()` not `hide()` +- [Linux app invisible after backup (#3682)](linux-transparent-window-invisible-3682.md) FIXED PR#4904 opaque window +- [Apple OAuth expired + deeplink swallowed (#4881)](oauth-deeplink-error-swallowed-4881.md) +- [Annotation link ignored when reader open #4887](deeplink-drop-running-macos-4887.md) `open-book-in-reader` event +- [iOS auto-brightness locks (#4885)](ios-brightness-lock-background-4885.md) brightness GLOBAL; release on bg +- [iOS share .txt stuck #4917](ios-share-txt-stuck-supportstext.md) drop SupportsText in `project.yml` (xcodegen src) +- [Updater disable non-AppImage (#4874)](updater-disable-nonappimage-linux-4874.md) +- [Fullscreen no-op Phosh (#4034)](fullscreen-maximized-phosh-4034.md) drop `isMaximized` branch +## Reader Features & UI +- [Readest Voice self-hosted TTS](selfhosted-premium-tts-plans.md) plans APPROVED via /autoplan 2026-07-08; Kokoro=Plus/Qwen3=Pro on RunPod; impl not started +- [Android Auto TTS #3919/PR#4907](android-auto-tts-3919.md) MERGED; Play REJECTED Auto feature, car meta-data pulled PR#5038 MERGED pending bug fix (guard test asserts absence); CarPlay blocked on entitlement +- Widgets: [mobile reading #1602/PR#4842](mobile-reading-widgets.md); [iOS App Group stripped PR#4891](ios-widget-appgroup-stripped-appstore.md); [cover bright right-edge line](ios-widget-cover-bright-edge-line.md) fractional resize → round target to whole px +- PDF: [scrolled lag #4795](pdf-scroll-lag-preload-4795.md); [scrolled pinch-zoom #4817](scrolled-pdf-pinch-zoom-4817.md); [pinch vs two-finger scroll #4858](pinch-vs-twofinger-scroll-4858.md); [text selection misplaced w/ OS font scale #4480](pdf-text-selection-fontscale-4480.md) OS font-scale inflates text-layer glyph size not positions; divide `--text-scale-factor` (font-size lever) by detected scale, NOT `--total-scale-factor` +- [Search modes #4560](search-modes-4560-and-spoiler-bound-bug.md) +- [OPDS groups carousel #4750](opds-groups-carousel-4750.md) · [WebDAV browser sort+search #4724](webdav-browse-sort-search-4724.md) +- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) macOS pinch=`ctrl+wheel` +- Instant highlight: [ate tap/swipe](instant-highlight-tap-paginate.md); [delete orphan #4773](instant-highlight-delete-orphan-4773.md); [empty leak #4791](empty-highlight-leak-on-annotate-cancel-4791.md) +- Selection: [keyboard adjust #4728](keyboard-selection-adjust-4728.md); [cross-page auto-turn #4741](cross-page-selection-autoturn-4741.md) +- Click/tap: [double-click word select](iframe-double-click-word-select.md); [dblclick-drag #4524](dblclick-drag-pageturn-4524.md); [tap open image/table #4600](tap-to-open-image-table-4600.md) +- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) +- Paragraph mode: [toggle/resume #4717](paragraph-mode-toggle-resume-4717.md); [accidental exit #4474](paragraph-mode-accidental-exit-4474.md) +- [#4584 tap-death](issue-4584-tap-death-investigation.md) UNFIXED; likely WebView-148 +- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) ONE `filter:` +- [Header/footer over light PDF in dark (#4901)](pdf-header-footer-contrast-blend-4901.md) `mix-blend-difference`, FIXED light anchor +- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) once-per-gesture latch +- Dict: [popup font size #4443](dict-popup-font-size-4443.md); [lemmatization #4574](dict-lemmatization-4574.md); [popup speak button #4876](dict-popup-tts-speak-4876.md) standalone wordPronouncer, Edge-direct + dedicated WebAudio ctx +- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe ruby; [en-en](wordlens-en-en.md) +- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) +- [Save image to gallery (#4680)](save-image-to-gallery-android.md) MediaStore +- [Webtoon Mode (#3647)](webtoon-mode-3647.md) +- [Middle-click autoscroll #4951](middle-click-autoscroll-4951.md) Autoscroller RAF core; `containerPosition +=`; armed-books preventDefault +- [Auto Scroll teleprompter #4998](auto-scroll-teleprompter-4998.md) MERGED PR#4999 PacedScroller + useAutoScroll + gridcell-centered pill; scrolled-only; tap=pause via iframe-single-click consume +- [Biometric app-lock #4645](biometric-app-lock-4645.md) · [Reference Pages #4542](reference-pages-672-4542.md) · [e-ink refresh page-turner #4687](eink-screen-refresh-pageturner-4687.md) +- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md) +- Customize Toolbar: [global serializeConfig #4760](customize-toolbar-global-serializeconfig.md); [e-ink black bar #4839](customize-toolbar-eink-black-bar-4839.md) +- [Edge TTS Web Audio engine (#3851)](edge-tts-webaudio-engine.md) gapless WebAudioPlayer + WSOLA +- [Background TTS sessions PR#4941](tts-background-session-decoupling.md) — hash-keyed session manager, detach/attach, NowPlayingBar; header X routes `onCloseBook` NOT `onGoToLibrary` +- [TTS player redesign](tts-player-redesign.md) mini-player + Dialog sheet replaces icon/popup/TTSBar; MERGED #4996; open: isPlaying glyph desync at section transitions +- [Android bg TTS media session fix](android-bg-tts-media-session-fix.md) — #4941/#4931 regression: `startService()` dies backgrounded → in-process instance calls; always request POST_NOTIFICATIONS; + lock-screen duration scrubber + `onSeekTo` (Edge-only) +- Native TTS: [iOS #4676](native-ios-tts-4676.md) pause==stop; [offline halt #4613](native-tts-offline-autoadvance-4613.md) +- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md); [drift](tts-word-highlight-singletextnode-drift.md) +- TTS UX: [highlight granularity](tts-highlight-granularity-setting.md); [start-from-selection](tts-start-from-selection.md); [reuse session](tts-reuse-session-mode-entry.md) +- RSVP: [control-bar REVERT](rsvp-control-bar-overlap-revert.md); [font #4519](rsvp-font-settings-4519.md); [RTL word #4630](rsvp-rtl-word-display-4630.md) +- [Overlay z-index scale](zindex-overlay-scale.md) RSVP 100 → app-lock +- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) +- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md) +- [Android image callout freeze](android-image-callout-freeze.md) `.no-context-menu` ANCESTOR +- [Inline-img vertical-align (#4866)](inline-img-vertical-align-4866.md) gated on computed valign +- [Table dark-mode tint #4419](table-dark-mode-tint-4419.md) · [footnote aside border #4438](footnote-aside-namespace-order-4438.md) +- Proofread: [enhancements #4700](proofread-enhancements-4700.md); [per-book CRDT #4781](proofread-per-book-crdt-sync.md); [edit Find + toggle #4859](proofread-edit-toggle-4859.md) +- [Russian NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md) +- OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md); [JSON search #4502](opds2-json-search-4502.md); [HTML desc #4503](opds-html-description-4503.md); [self-link #4749](opds-self-link-metadata-4749.md); [popular dedup #4782](opds-popular-catalog-dedup-4782.md); [auto-download subdir crawl #4272](opds-autodownload-subdir-crawl-4272.md) bounded BFS, never crawl newest-feed catalogs; [preemptive Basic 400s digest Calibre](opds-preemptive-basic-digest-400.md) bare-retry on 400 PR#5002; [auto-download TLS #4988](opds-autodownload-tls-skipssl-4988.md) skipSslVerification parity PR#5002 +- [D-pad Navigation](dpad-navigation.md) +- [koplugin cover upload (#4374)](koplugin-cover-upload.md) +- [koplugin Library slow open #4954](koplugin-library-open-mosaic-cache-4954.md) group mosaics recomposed every paint; PR#4974 availability-keyed cache + async compose + cache nil result +- [Calibre plugin push #4863](calibre-plugin-push-4863.md) OAuth localhost relay +- [Calibre custom columns #4811](calibre-custom-columns-4811.md) `metadata.calibreColumns` ## Library Fixes -- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing -- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard -- [TXT chapter measure-word false positives (#4658)](txt-chapter-measure-word-4658.md) — `第一封信`/`第四本书…` (量词 prose) detected as chapters; `createChapterRegexps('zh')` unit class split into strong `[章节回讲篇话]` (attached title OK) vs weak/量词 `[卷本册部封]` (title needs a separator or line end, never a bare noun) -- [Cover stale until refresh (in-place mutation vs React.memo)](cover-stale-inplace-mutation-memo.md) — editing a book cover in details + Save left the library cover stale until reload; `handleUpdateMetadata` mutated `book` IN PLACE so memoized ``'s prev snapshot pointed at the same object → comparator saw no change → skip; fix = pure `getBookWithUpdatedMetadata` returns a NEW book object. Cloning in `updateBook` wouldn't help (original already mutated). Verified live on emulator via CDP fiber-store extraction (A: mutate→stale, B: new obj→updates) -- [Series/author folder back no-op (#4437)](series-folder-back-noop-4437.md) — back arrow dead inside Series/Author folder after cold start; Next.js 16.2 static-export empty-search `router.replace` no-op (same as #3782/#3832); `GroupHeader.handleBack` missed the `group=''` workaround. CDP-verify gotcha: synthetic `el.click()` won't fire React onClick — use trusted `Input.dispatchMouseEvent` - -## Library Architecture -- [Book action platform surfaces](book-actions-platform-surfaces.md) — library context menu is **Tauri-desktop-only** (`hasContextMenu` false on web + iOS/Android); cross-platform book actions go in `BookDetailView`'s icon row. #4543 Goodreads search added both surfaces + a built-in web-search provider for highlighted-text lookup - -## Architecture Notes -- foliate-js is a git submodule at `packages/foliate-js/` -- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book -- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color) -- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time -- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view -- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles -- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat -- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`) -- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe - -## Workflow -- [Test file filter](feedback_test_file_filter.md) — use `pnpm test ` without `--` to run a single file -- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs -- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue -- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global -- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?` no `--` +- [No test seams in prod](feedback_no_test_seams_in_prod.md) · [no lookbehind regex](feedback_no_lookbehind_regex.md) +- i18n: [en plurals manual](feedback_en_plurals_manual.md); [i18n:extract prunes keys](i18n-extract-prunes-keys.md) +- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) `pnpm-workspace.yaml` `overrides:` +- [Upgrade gstack locally](feedback_gstack_upgrade.md) diff --git a/apps/readest-app/.claude/memory/android-auto-tts-3919.md b/apps/readest-app/.claude/memory/android-auto-tts-3919.md new file mode 100644 index 000000000..cea79bb4f --- /dev/null +++ b/apps/readest-app/.claude/memory/android-auto-tts-3919.md @@ -0,0 +1,14 @@ +--- +name: android-auto-tts-3919 +description: "Android Auto TTS (#3919, PR#4907) — Play rejected the Auto feature; manifest opt-in removed 2026-07-09 pending bug fix, re-add to re-enable" +metadata: + node_type: memory + type: project + originSessionId: 87b553ae-fcfd-4e40-8f37-d8e926f18961 +--- + +Android Auto TTS media session support (#3919) merged in PR#4907. CarPlay counterpart blocked on Apple entitlement. + +**Google Play rejection (2026-07-09):** the Android Auto review track rejected the release because the Auto TTS feature still has a bug (being fixed in a separate session). To unblock the resubmission, the Auto opt-in was removed from `src-tauri/gen/android/app/src/main/AndroidManifest.xml` in PR#5038 (MERGED 2026-07-09): the `` entry inside ``. The guard test `src/__tests__/android/android-auto-declarations.test.ts` now asserts the meta-data is ABSENT; flip those assertions back when re-enabling. + +**How to re-enable once the bug is fixed:** re-add that meta-data entry. The resource `res/xml/automotive_app_desc.xml` was intentionally left in place (unreferenced but harmless), and the `MediaBrowserService` intent-filter on `com.readest.native_tts.MediaPlaybackService` also stays — it's required for the phone lock-screen/background TTS media session ([[android-bg-tts-media-session-fix]]) and does not by itself trigger Play's Android Auto review; only the `com.google.android.gms.car.application` meta-data does. diff --git a/apps/readest-app/.claude/memory/android-bg-tts-media-session-fix.md b/apps/readest-app/.claude/memory/android-bg-tts-media-session-fix.md new file mode 100644 index 000000000..7717db663 --- /dev/null +++ b/apps/readest-app/.claude/memory/android-bg-tts-media-session-fix.md @@ -0,0 +1,26 @@ +--- +name: android-bg-tts-media-session-fix +description: Android background TTS regression - startService() dies backgrounded; use in-process service calls; + lock-screen scrubber/seek +metadata: + node_type: memory + type: project + originSessionId: 052bb3f3-27fe-4eb0-95c3-699a3122083a +--- + +Branch `fix/android-bg-tts-media-session` (worktree `/Users/chrox/dev/readest-fix-android-bg-tts-media-session`), 2026-07-07. **PR readest/readest#4994 MERGED** (2026-07-07; worktree + local branch cleaned up. rebased onto origin/main; foliate-js submodule re-synced to f6dced2 after rebase per [[worktree-rebase-submodule-drift]]). 5 commits: 15817fc4b in-process IPC, 04e4b4fe6 duration scrubber+seek, 67c22b72b FGS hardening+diagnostic logs, 27e224bcc keepAppInForeground removal (the real fix), a8643ec12 Edge edge-fade click fix. Verified on-device (Xiaomi/MIUI/Android 15). Diagnostic Log.d traces left in 67c22b72b (offered to strip). + +**Regression (commit 1, `fix(android): keep background TTS media controls alive when backgrounded`):** after #4941 (session decoupling) + #4931 (Edge WebAudio engine), Android background TTS lost the lock-screen control and audio died when backgrounded. Logs: `Not allowed to start service Intent { act=UPDATE_PLAYBACK_STATE ... MediaPlaybackService }: app is in background`. + +Root cause: `NativeTTSPlugin.update_media_session_state`/`update_media_session_metadata` pushed updates to the already-running foreground service via `activity.startService(intent)`. Android 8+ (BSSR) rejects `Context.startService()` from the background unless an active foreground service exempts the app; each per-sentence update threw, so the FGS notification stopped refreshing (lock-screen control went stale) and the audio route was lost. **Fix pattern: never `startService()` to talk to a running service - call the live instance in-process.** `MediaPlaybackService` already had the pattern: static `@Volatile instance` + `requestDeactivation()` posting to it on the main thread. Added companion `pushMetadata`/`pushPlaybackState` (update statics, post to `instance` via `Handler(Looper.getMainLooper())`) + private instance `applyMetadata`/`applyPlaybackState`; removed the dead `UPDATE_METADATA`/`UPDATE_PLAYBACK_STATE` intent branches + the now-unused `serviceScope`/`kotlinx.coroutines.*`. `startForeground()` to *update* an already-foregrounded service is allowed from background (unlike `Context.startForegroundService()` to *start* one). + +Secondary: #4941 dropped the `keepAppInForeground`/notification titles from `TTSMediaBridge.bind()`'s `setActive({active:true})`. `keepAppInForeground` gated `requestPostNotificationPermission()` in `mediaSession.ts`, and it defaults false (`alwaysInForeground` in constants.ts), so POST_NOTIFICATIONS was never requested. Fix: `setActive` requests it on EVERY activation (no-op once decided), not gated on the setting - else the FGS media notification (= the lock-screen control) is silently suppressed on Android 13+. + +**Feature (commit 2, `feat(android): show section duration and enable seek on the TTS media session`):** user asked to show estimated section duration + seek from the media session. JS half was already there - `ttsMediaBridge.#updatePositionState` already sends `{playing, position, duration}` (ms) every mark, and `mediaSession.ts` already listens for a `media-session-seek` event -> `handlers['seekto']` -> `controller.seekToTime(pos/1000)`. Native side never used them. Added: `METADATA_KEY_DURATION` in the session metadata (Android reads scrubber length from METADATA, thumb from PlaybackState), `ACTION_SEEK_TO` in `setActions`, and `SessionCallback.onSeekTo(pos)` -> `pluginEventTrigger("media-session-seek", {position})` + optimistic thumb move. Bare play/pause updates omit position/duration, so `pushPlaybackState(playing, position: Long?, duration: Long?)` preserves last-known statics (else scrubber snaps to 0 on pause). **Section timeline is Edge/WebAudio ONLY** (`TTSController` comment "position/duration/seek (Edge client only)"; `getPlaybackInfo()` returns null for native TextToSpeech) - native TTS leaves duration 0 so no scrubber appears, which is correct. + +**On-device (Xiaomi/MIUI, targetSdk 36) round 1 FAILED - two findings:** (1) tested APK was STALE - logcat still showed `startService(act=UPDATE_METADATA/UPDATE_PLAYBACK_STATE)` which the fix removes, so the fix wasn't built in (likely built from main tree, not the worktree). (2) Deeper root cause the IPC fix does NOT touch: `W/ActivityManager: Stopping service due to app idle: ...MediaPlaybackService` = the service was NEVER promoted to a foreground service (FGS services aren't idle-stopped; readest uid never appears in FGS-type logs). Also MIUI hostile: uid 10186 (SecurityCenter) repeatedly sets readest `post_notification` appop to `ignore`; `Force stopping service`. Audio plays via WebView (`org.chromium.content.browser.AudioFocusDelegate` holds focus), and the service ExoPlayer also requests AUDIOFOCUS_GAIN - possible focus-steal conflict (unconfirmed). + +**Commit 3 (`fix(android): harden TTS foreground-service promotion + add diagnostics`):** `showNotification` now uses `ServiceCompat.startForeground(this, id, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)` (explicit type, targetSdk 34+) wrapped in try/catch+Log. `setActive` (mediaSession.ts) decoupled: POST_NOTIFICATIONS request in its own try/catch so a throw/hang can't abort `set_media_session_active` (the FGS start). Trace logs added: `set_media_session_active: startForegroundService` (plugin), `activateSession (wasActive=)`, `startForeground ok`/`failed`. Next device run: build FROM the worktree + `adb uninstall com.bilingify.readest` first; set MIUI Autostart ON + battery No-restrictions + lock in recents; grep logcat for those tags to see where the FGS path breaks. If `startForeground ok` but audio still dies backgrounded -> WebAudio AudioContext suspension (test native voice: survives = confirms WebView issue). + +**ACTUAL ROOT CAUSE found round 3 (WebView console `[INFO:CONSOLE]` via `adb logcat` chromium tag):** `Failed to set media session active state: invalid args payload for command set_media_session_active: missing field keepAppInForeground`. The Rust `SetMediaSessionActiveRequest` (models.rs) had `keep_app_in_foreground: bool` as a REQUIRED serde field (all other fields `Option`); #4941's `ttsMediaBridge.bind()` sends `setActive({active:true})` without it, so **Tauri rejected the invoke at the serde layer before the command ran** -> `set_media_session_active` never executed -> FGS never started -> no notification + Android 15 `AS.AudioService: AudioHardening background playback would be muted` killed background audio. Every earlier fix (in-process IPC, FGS hardening, POST_NOTIFICATIONS decouple) was downstream of this and couldn't help because the command never ran. Diagnostic trap: native tags (MediaPlaybackService/NativeTTSPlugin) were absent from logcat because the service was never touched; the answer was only in the WebView JS console (grep logcat for `CONSOLE`). Commit 4 (`fix(android): drop required keepAppInForeground so the TTS service starts`, 27e224bcc): removed `keepAppInForeground` ENTIRELY (dead everywhere - no platform read it; FGS always starts, POST_NOTIFICATIONS now unconditional) from Rust/Kotlin/iOS/TS payloads per user request ("default true"). Follow-up commit 0b8843012 also removed the now-dead `alwaysInForeground` setting + its Android "Background Read Aloud" library-menu toggle (settings.ts/constants.ts/SettingsMenu.tsx + tests) and pruned the i18n key across 33 locales via `pnpm i18n:extract`. **Lesson: on a failed Tauri mobile command, capture the WebView console (logcat `CONSOLE` tag) FIRST - serde arg-rejection surfaces only there, not in native logs.** + +Verified: `pnpm test` (7022 pass), `pnpm lint` clean, `cargo check/fmt/clippy -p tauri-plugin-native-tts` clean. **Kotlin NOT compiled/device-verified** - worktree `src-tauri/gen/android` lacks `tauri.settings.gradle` so the plugin's `app.tauri.plugin.*` deps don't resolve standalone; needs `pnpm tauri android` on a real Android 13+/14 device (logcat: foreground -> background -> lock screen). Related: [[tts-background-session-decoupling]], [[edge-tts-webaudio-engine]], [[native-ios-tts-4676]]. diff --git a/apps/readest-app/.claude/memory/android-e2e-doubletap-cdp-gesture.md b/apps/readest-app/.claude/memory/android-e2e-doubletap-cdp-gesture.md new file mode 100644 index 000000000..be8e26498 --- /dev/null +++ b/apps/readest-app/.claude/memory/android-e2e-doubletap-cdp-gesture.md @@ -0,0 +1,56 @@ +--- +name: android-e2e-doubletap-cdp-gesture +description: Nightly Android E2E double-tap test failed since +metadata: + node_type: memory + type: project + originSessionId: eafe11ed-faac-4406-957d-1674353f081c +--- + +Nightly `Android E2E (CDP)` failed every night from 2026-06-28 (first night after +PR #4846 merged) with `timed out waiting for selection of "..." (last: null)` in +`double-click.android.test.ts`. The test never passed on CI. Fixed 2026-07-04 — +harness-only, no app code changed. + +**Two stacked root causes:** + +1. **PRIMARY — feature is opt-in on mobile.** `DEFAULT_MOBILE_VIEW_SETTINGS` + ships `disableDoubleClick: true` (double-click detection delays single-tap + page turns by the 250ms disambiguation window, so mobile opts out). + `handleClick` (iframeEventHandlers.ts) then posts `iframe-single-click` + IMMEDIATELY and never arms the double-click window — the #4846 double-tap + word selection is deliberately "gated by the user's double-click setting" + (Annotator comment). The e2e assumed default config → could never pass on a + fresh device. Diagnostic signature: `iframe-single-click` ~20ms after click + (window disabled) vs ~250ms after (window armed, no second click). +2. **SECONDARY — adb double-tap can't hit the window.** The old helper ran + `input tap x y && input tap x y`; each `input` invocation spawns a fresh + app_process JVM (measured 0.9–1.05s each, 28s cold). Warm fast host ≈130ms + click gap (passes), loaded CI emulator >250ms (always fails). + +**Fix (all in `src/__tests__/android/`):** +- `reader.ts patchGlobalViewSettings(patch)` — force-stop app, read/patch + `settings.json` `globalViewSettings` via `adb shell run-as` (debug builds + only; file lives at the app data dir ROOT, not files/), write back via + base64 pipe, return previous values for restore in afterAll. Missing + settings.json is fine: `loadSettings` deep-merges partial file over defaults. +- `cdp.ts CdpPage.doubleTap(cssX, cssY)` — ONE + `Input.synthesizeTapGesture {tapCount: 2, duration: 20, gestureSourceType: 'touch'}`; + renderer-internal timing gives ~200ms click gap on a busy emulator. TWO + sequential synthesizeTapGesture commands are too slow (~535ms gap — each + resolves long after its gesture). Raw `Input.dispatchTouchEvent` delivers + touch events but does NOT reliably synthesize clicks on Android WebView. +- Word finder requires `range.getClientRects().length === 1`: a + hyphenated/wrapped word's bounding rect spans two lines, so its center taps + between lines and selects the neighboring word (saw `'party' !== 'sensation'`). +- `dismissSelection` picks a mid-column tap spot (0.78H or 0.25H) that the + `.selection-popup` doesn't cover — a blind 0.78H tap can press a toolbar + button when the selection sits low. + +**Other gotchas:** headless emulator display sleeps → adb `input` no-ops while +CDP input still works (`input keyevent KEYCODE_WAKEUP`); a leaked single-click +(broken double-tap) toggles header or opens the media viewer, contaminating the +session; local repro = `pnpm tauri android build --debug --target aarch64` + +`adb install -r` + `pnpm test:android`. + +Related: [[android-cdp-e2e-lane]], [[iframe-double-click-word-select]] diff --git a/apps/readest-app/.claude/memory/android-open-with-intent-flow.md b/apps/readest-app/.claude/memory/android-open-with-intent-flow.md index e8b82b283..26d7199d9 100644 --- a/apps/readest-app/.claude/memory/android-open-with-intent-flow.md +++ b/apps/readest-app/.claude/memory/android-open-with-intent-flow.md @@ -11,7 +11,7 @@ Android "Open with Readest" / "Send to Readest" file-intent pipeline and the #45 **Pipeline (ACTION_VIEW = "Open with", ACTION_SEND = "Share"):** - `NativeBridgePlugin.kt::handleIntent` is the real handler (NOT `MainActivity.kt` — its ACTION_SEND branch is legacy/redundant). → `emitSharedIntent("VIEW"|"SEND", uris)` → JS `useAppUrlIngress` `shared-intent` plugin listener → `app-incoming-url` event → `useOpenWithBooks`. -- VIEW → `openTransient` → straight to reader (ephemeral book, `deletedAt` set, `filePath` = the content:// URI, no library write/upload). SEND → `window.OPEN_WITH_FILES` → `library/page.tsx::processOpenWithFiles` (full ingest + force cloud upload on mobile). +- VIEW routing is now gated by `autoImportBooksOnOpen` (PR #4747, issue #4746): `shouldOpenTransient(action, autoImportBooksOnOpen)` in `helpers/openWith.ts` → only `VIEW` with the setting OFF goes `openTransient` (ephemeral book, `deletedAt` set, `filePath` = content:// URI, no library write/upload); `VIEW` with it ON falls through to the SEND path. SEND (and VIEW-with-import-on) → `window.OPEN_WITH_FILES` → `library/page.tsx::processOpenWithFiles` (full ingest + force cloud upload on mobile). The setting defaults TRUE on mobile (`DEFAULT_MOBILE_SYSTEM_SETTINGS`, desktop default still false) and its "Auto Import on File Open" toggle is now shown on mobile too. `useOpenWithBooks.handle` reads it via `appService.loadSettings()` (disk), NOT the settings store — the store is unhydrated during the cold-start intent replay and would wrongly fall back to transient. So the Telegram default is now import-to-library (persists past the dying URI grant); transient is opt-out. - content:// read: `nativeAppService.openFile` → if URI contains `com.android.externalstorage` → direct `NativeFile` (real path); else `copyURIToPath` → `contentResolver.openInputStream` → copy to Cache → `NativeFile`. `basename` here is LEXICAL (`@tauri-apps/api/path`), not a ContentResolver `DISPLAY_NAME` query — but EPUB format is sniffed by zip magic (`document.ts isZip()`), so an extension-less content URI still opens. - The Tauri deep-link plugin's `getCurrent()`/`onOpenUrl` only fire for configured deep-link domains (`https://web.readest.com`, `readest:`); `content://`/`file://` VIEW intents are filtered out by `DeepLinkPlugin.isDeepLink()`, so file opens flow ONLY through the native `shared-intent` channel, never the deep-link plugin. diff --git a/apps/readest-app/.claude/memory/android-themed-icon-4733.md b/apps/readest-app/.claude/memory/android-themed-icon-4733.md new file mode 100644 index 000000000..7f8fc79a1 --- /dev/null +++ b/apps/readest-app/.claude/memory/android-themed-icon-4733.md @@ -0,0 +1,52 @@ +--- +name: android-themed-icon-4733 +description: "Android Material-You themed (monochrome) launcher icon — restoring it (#4733), the gen/android force-commit pipeline, and emulator verification" +metadata: + node_type: memory + type: project + originSessionId: 6bc82dac-a705-4ef2-ab28-c13b43f48a46 +--- + +Issue #4733 = add Android themed (Material You / monochrome) launcher icon. It had +existed (#2122/#2153 added `ic_launcher_monochrome.png`) but PR #2353 ("fixed +launcher icon size") rewrote the committed adaptive icon to inset the foreground +22% and **silently dropped the `` layer**, so themed icons stopped +working. Fix = re-add `` +to `ic_launcher.xml` + ship the monochrome mipmaps. + +**Android icon pipeline (non-obvious).** `src-tauri/gen` is gitignored, BUT specific +customized res files are **force-added** (tracked): `mipmap-anydpi-v26/ic_launcher.xml`, +`drawable/ic_launcher_background.xml`, `values/themes.xml`, `splash_icon.png`. CI +(release/nightly/android-e2e) does `rm -rf gen/android` → `tauri android init` → +`tauri icon ../../data/icons/readest-book.png` → **`git checkout .`** (restores the +tracked customizations) → build. So **the committed gen files are the build's source +of truth.** `tauri icon` (CLI 2.10.1) writes gen mipmaps + a DEFAULT `ic_launcher.xml` +(foreground+background only) and does NOT emit a monochrome layer — so the monochrome +PNGs (or a vector drawable) MUST be force-committed into `gen/.../res/` to survive +`git checkout .`. `git add -f apps/.../gen/.../mipmap-*/ic_launcher_monochrome.png`. +`src-tauri/icons/android/*` is the historical master but is NOT what the build reads. + +**Themed tint = SRC_IN (alpha only).** The launcher replaces the monochrome layer's +RGB with the wallpaper tint, keeping only alpha → any fully-opaque artwork flattens +to a solid blob (the original desaturated-logo monochrome lost all detail). Convey +character via negative space. For Readest we kept the existing artwork and carved a +**narrow vertical center-gap (spine)** via an alpha-multiply mask (ImageMagick: +`magick src -alpha extract a.png; magick -size WxH xc:white -fill black -draw "roundrectangle ..." g.png; magick a.png g.png -compose multiply -composite na.png; magick src na.png -alpha off -compose CopyOpacity -composite out.png`), +gap ≈ centered, width ~4% of content, from ~3%→84% of content height (pages stay +joined at the binding). Preview-as-themed = tint `-colorize`, inset to central 56% +(=22% inset), composite over dark bg, circular mask. + +**Emulator verify (Pixel_9_Pro AVD, Google Play image, NexusLauncher).** Themed icons +toggle: Wallpaper & style (`am start -n com.google.android.apps.wallpaper/com.android.customization.picker.CustomizationPickerActivity`) +→ "Home screen" tab → "Themed icons" switch. Only the **home screen/dock** is themed; +the **app drawer keeps full color** (expected, not a bug). `uiautomator dump` returns +"null root node" on the wallpaper picker (SurfaceView) → navigate by screenshot +coords. Build for emulator = `pnpm tauri android build --debug --target aarch64 --apk` +(NDK_HOME must be set). Gradle-standalone (`./gradlew :app:assembleUniversalDebug`) +fails: the `rustBuild*` task shells `pnpm tauri ...` which panics at +`tauri-cli/src/mobile/mod.rs:403` unless driven by `tauri android build`. Confirm the +APK packaged it: `aapt2 dump xmltree --file res/mipmap-anydpi-v26/ic_launcher.xml app.apk` +should show an `E: monochrome` node. Regression guard: +`src/__tests__/android/themed-icon.test.ts` (asserts `` in the XML + a +tracked monochrome mipmap per density). Related: [[dict-lookup-browser-hijack-4559]] +(Android resource/manifest gotchas), [[android-cdp-e2e-lane]]. diff --git a/apps/readest-app/.claude/memory/annotator-onload-listener-leak-paragraph-mode.md b/apps/readest-app/.claude/memory/annotator-onload-listener-leak-paragraph-mode.md new file mode 100644 index 000000000..d86e75503 --- /dev/null +++ b/apps/readest-app/.claude/memory/annotator-onload-listener-leak-paragraph-mode.md @@ -0,0 +1,25 @@ +--- +name: annotator-onload-listener-leak-paragraph-mode +description: "Paragraph mode degrades over chapters on Android (#4735) — Annotator onLoad leaked renderer-scroll + native-touch listeners on long-lived objects; per-view fix + the reusable per-section-leak pattern" +metadata: + node_type: memory + type: project + originSessionId: 980f8d79-9360-4402-bd49-8dd389200c1e +--- + +PR #4735 (`fix/annotator-input-listener-leak`). Reported: reading a 3000-chapter web novel in **paragraph reading mode** on Android (Z Fold 7), paragraph transitions get sluggish after a few chapters and keep degrading until app restart. Classic per-section-transition resource leak. + +**Root cause — Annotator `onLoad` attaches listeners to objects that OUTLIVE the section.** `Annotator.tsx`'s `onLoad` (wired via `useFoliateEvents(view, { onLoad })` to the foliate `load` event, which fires once per section document load) did: +- `view.renderer.addEventListener('scroll', handleScroll)` — never removed +- `view.renderer.addEventListener('scroll', () => repositionPopups())` — anonymous, unremovable +- Android: `eventDispatcher.on('native-touch', handleNativeTouch)` — never `off`'d + +`view.renderer` is created ONCE per book (`createElement('foliate-view')` + `view.open()` in `FoliateViewer.tsx`), lives the whole session; the global `eventDispatcher` too. So every `load` permanently adds listeners. **foliate fires `load` for PRELOADED neighbour sections too** (`paginator.js#loadAdjacentSection` → `dispatchEvent(new CustomEvent('load',…))`, `#preloadNext` loads up to 8), so the accrual is several-per-chapter, not one. The doc-scoped `detail.doc.addEventListener(...)` listeners do NOT leak (the section iframe is destroyed by foliate's `#destroyView`, taking them with it). Only the renderer-/dispatcher-scoped ones leak. + +**Why paragraph mode + Android specifically.** Every paragraph advance calls `renderer.goTo({index, anchor})` (`focusCurrentParagraph`), which scrolls the renderer container → `paginator.js:~1161` `this.#container.addEventListener('scroll', () => { if(!#isAnimating) dispatchEvent(new Event('scroll')) })` → runs ALL accumulated scroll listeners. Normal paginated reading scrolls only on occasional page turns, so the same leak is far less felt. Cost is REAL on Android: `handleScroll` (useTextSelector.ts, the `#873` selection-pin workaround) early-returns unless `osPlatform==='android'`, then calls `getViewSettings`; `native-touch` is Android-only. Restart recreates the view/renderer → cleared (the reporter's workaround). + +**Fix = `useRendererInputListeners(view, {...})` hook** (`src/app/reader/hooks/`): registers the renderer `scroll` + (Android) `native-touch` listeners ONCE per view in an effect keyed `[view, enableNativeTouch]`, with cleanup; handlers routed through refs so re-renders never re-subscribe. The native-touch handler now resolves the CURRENT primary section's doc/index at fire time (`view.renderer.getContents().find(c=>c.index===primaryIndex)`) instead of capturing a load's doc/index (a load may be an off-screen preload — and the old code fan-fired EVERY loaded section's handler per touch, calling handleTouchEnd/handlePointerUp N times; new code fires once = strictly more correct). Dropped the redundant `scroll→repositionPopups` (a dedicated effect already repositions popups on scroll). `listenToNativeTouchEvents()` just sets one global `window.onNativeTouch` that re-dispatches `native-touch` via eventDispatcher — idempotent, fine to call once/view. + +**Reusable pattern (the lesson).** Listeners attached inside a per-section / per-event handler (`onLoad`, `load`, relocate, create-overlay) to an object that outlives that event (`view.renderer`, global `eventDispatcher`, `window`, `document`) LEAK one set per event. Audit `addEventListener`/`eventDispatcher.on` inside `onLoad`-style handlers: if the target isn't the per-section `detail.doc` (which dies with the iframe), it must move to a per-view `useEffect` with cleanup. `eventDispatcher` (`utils/event.ts`) stores async listeners in a per-event `Set` keyed by callback reference; fresh closures each call never dedupe → unbounded. + +**Verify gotchas.** Hook unit-tested with `renderHook` + a `MockRenderer extends EventTarget` tracking scroll listeners + a mocked `eventDispatcher` Set; assert size stays 1 across 20 re-renders, latest-handler routing, unmount→0, Android gate. NOTE: creating the mock `view` INSIDE the renderHook callback churns view identity → the `[view]`-keyed effect re-runs each render (still no leak — cleanup keeps Set at 1 — but `listenToNativeTouchEvents` call-count grows); hoist `view` outside to mirror the real stable `getView(bookKey)`. Needs on-device Android verification (Android-gated paths). Related: [[paragraph-mode-toggle-resume-4717]], [[tts-sync-paragraph-rsvp-3235]], [[android-nativefile-remotefile-io]]. diff --git a/apps/readest-app/.claude/memory/auto-scroll-teleprompter-4998.md b/apps/readest-app/.claude/memory/auto-scroll-teleprompter-4998.md new file mode 100644 index 000000000..0bf292b78 --- /dev/null +++ b/apps/readest-app/.claude/memory/auto-scroll-teleprompter-4998.md @@ -0,0 +1,51 @@ +--- +name: auto-scroll-teleprompter-4998 +description: "Auto Scroll teleprompter mode (#4998, PR#4999): PacedScroller core, useAutoScroll hook, control pill centered on gridcell, scrolled-mode-only View menu toggle" +metadata: + node_type: memory + type: project + originSessionId: 129a72a3-6d52-4f4c-a499-972c0055b4e3 +--- + +Auto Scroll reading mode (#4998), PR #4999 MERGED 2026-07-08 (merge +f8ad47a41); worktree and local branch cleaned up. +Teleprompter scrolling for scrolled mode only, toggled from the View menu +(Shift+A, `onToggleAutoScroll`), dispatches `autoscroll-toggle` events. + +Key structure: +- `PacedScroller` added to `src/app/reader/utils/autoscroller.ts` alongside the + middle-click `Autoscroller` ([[middle-click-autoscroll-4951]]): constant + velocity, whole-pixel emission + fractional carry, injected raf/now for + tests, `PACED_SCROLL_MAX_FRAME_MS` dt clamp (background tab resume). A + scrollBy callback may stop() the scroller mid-tick; #tick re-checks active + before re-arming (test covers it). +- `useAutoScroll(bookKey, viewRef)` in reader/hooks, mounted in FoliateViewer: + scrolls `renderer.containerPosition += sign * delta`; sign = -1 when + `renderer.scrollProp === 'scrollLeft'` (scrolled+vertical), matching foliate + paginator.js `offset = -offset` for scrolled vertical (vertical-lr is a known + upstream FIXME). Manual wheel/drag composes with the paced steps by design + (no pause-on-wheel). Tap pause/resume consumes `iframe-single-click` via + eventDispatcher.onSync (same swallow mechanism as middle-click). Stall + detection: containerPosition unchanged ≥800ms → `view.next()` (hops sections + under noContinuousScroll) or stop + 'End of book' toast when + `renderer.atEnd`. Session state mirrored to readerStore + `viewState.autoScrollEnabled` (new setter) for the ViewMenu checkmark; + session never persisted, speed IS: `autoScrollSpeed` percent in BookLayout + (default 100 = 20 px/s base, 25-500 step 25, constants in + services/constants.ts). +- `AutoScrollControl` pill reuses the ParagraphBar chassis but positioned + `absolute` (NOT `fixed`): maintainer explicitly wants it centered on the + book's gridcell, not the viewport — pinned sidebar pushes the reading column + off window center. (ParagraphBar's #4474 comment argues the opposite for + paragraph mode; the two are intentionally different.) Fades after 2.5s while + scrolling, wakes on mousemove/pause, hidden while hoveredBookKey shows bars. +- Adding a field to readerStore ViewState breaks two test fixtures that build + ViewState literals (reader-store.test.ts, tts-auto-advance.browser.test.tsx). +- i18n: 6 new keys (Auto Scroll, Toggle Auto Scroll, Slower, Faster, Exit Auto + Scroll, End of book) hand-translated across all 33 locales following each + locale's existing Scrolled Mode / RSVP Slower-Faster terminology; scanner + extraction only touched trailing commas (no pruning this time). + +Verified live in dev-web with claude-in-chrome (localhost:3001): 20 px/s at +100%, menu gating, pill geometry (pillCenterX == gridcell center != viewport +center). diff --git a/apps/readest-app/.claude/memory/backup-windows-zip-paths-4703.md b/apps/readest-app/.claude/memory/backup-windows-zip-paths-4703.md new file mode 100644 index 000000000..48cdd2e69 --- /dev/null +++ b/apps/readest-app/.claude/memory/backup-windows-zip-paths-4703.md @@ -0,0 +1,16 @@ +--- +name: backup-windows-zip-paths-4703 +description: Backup zip exported on Windows failed to restore anywhere — backslash separators in zip entry names +metadata: + node_type: memory + type: project + originSessionId: dd015419-996e-466b-8039-f2d98312d9d6 +--- + +#4703: Backup `.zip` exported on Windows wouldn't restore on any platform (Web/Android/Windows) — books restored with metadata but missing files/covers. + +**Root cause:** `appService.readDirectory` returns paths with the host separator. On Windows `nativeAppService.readDir` → `getRelativePath` strips the base prefix but leaves backslashes, so `file.path` is `hash\cover.png`. `addBackupEntriesToZip` used `file.path` verbatim as the zip entry name. Restore (`restoreFromBackupZip`) matches a book's files by `e.filename.startsWith(`${hash}/`)` (forward slash) → backslash names never match → all files silently skipped. (The "garbled Unicode" reported in zip viewers was just the `\` rendered oddly.) + +**Fix (export side only):** normalize the zip entry name to forward slashes — `file.path.replace(/\\/g, '/')` — in `addBackupEntriesToZip` (`src/services/backupService.ts`). Keep `file.path` (host separators) for `readFile`. Test: `backup-windows-paths.test.ts` drives the now-exported `addBackupEntriesToZip` with a capturing ZipWriter stub + Windows-style backslash listing (no zip.js workers needed; the forced `useWebWorkers`/`useCompressionStream` config makes a real round-trip impractical under jsdom). + +**General lesson:** `readDirectory`/`readDir` paths carry host separators; normalize to `/` at any cross-platform boundary (zip entries, sync keys, anything serialized for another device). Already-broken Windows backups still need re-export — restore was left unchanged (minimal fix; matches the issue's expected behavior). See [[platform-compat-fixes]]. diff --git a/apps/readest-app/.claude/memory/calibre-custom-columns-4811.md b/apps/readest-app/.claude/memory/calibre-custom-columns-4811.md new file mode 100644 index 000000000..e761d75d0 --- /dev/null +++ b/apps/readest-app/.claude/memory/calibre-custom-columns-4811.md @@ -0,0 +1,24 @@ +--- +name: calibre-custom-columns-4811 +description: "Surface Calibre custom columns from OPF user metadata (#4811) - parse formats, calibreColumns field, details UI, library search" +metadata: + node_type: memory + type: project + originSessionId: 7f74fc26-9614-4fe3-987b-66c8ce412523 +--- + +Feature #4811 SHIPPED (app PR #4939 merged 2026-07-05 as `ec45a08`; foliate-js#47 merged as `8485e93`): surface Calibre custom columns embedded in EPUB OPFs. Worktree and branches cleaned up. + +**Calibre OPF encodings (verified against calibre source opf2.py/opf3.py):** +- OPF2: one `` per column; label must start with `#` +- OPF3: a single `{"#label": {...}}` (raw property attr always literally `calibre:user_metadata`; the `calibre:` prefix maps to `https://calibre-ebook.com` but foliate's URL-resolution concatenates without `:` so match the RAW attr, not the resolved one). Calibre prefers OPF3 over OPF2 when both present (`read_user_metadata3 || read_user_metadata2`). +- Value in `#value#` (array for multi-value), series index in `#extra#`; datetimes wrapped `{"__class__": "datetime.datetime", "__value__": ""}`, unset date = `0101-01-01`; embedded files carry EVERY library column so empty values (null/''/[]/rating 0/undefined-date) must be dropped at parse time. + +**Where things live:** +- Parser: `getCalibreUserMetadata` in foliate-js `epub.js`, attached AFTER `tidy()` (tidy would collapse single-element value arrays) as `metadata.calibreColumns` `[{label, name, datatype, value, extra?}]` +- Type: `CalibreCustomColumn` in `src/libs/document.ts`; `BookMetadata.calibreColumns` +- Formatter: `formatCalibreColumnValue` in `src/utils/book.ts` (rating → ★ half-stars /2, series → `Name [idx]`, bool → ✓/✗, comments → strip tags, datetime → formatDate) +- UI: extra grid cells in `BookDetailView.tsx` Metadata section after Identifier (column names are user content, NOT i18n keys) +- Search: `getCalibreColumnsText` in `src/app/library/utils/libraryUtils.ts` `createBookFilter` (both regex and substring branches) + +**Why safe:** metaHash dedupe uses only title/authors/identifiers; metadata editor spreads `{...metadata}` so the field survives edits; import assigns `loadedBook.metadata` as-is. Calibre plugin pushes already embed user metadata via calibre `set_metadata`, so plugin-pushed books get columns through the same OPF parse (the plugin's flat `customColumns` wire field is a DIFFERENT shape and stays unused). E2E-verified on the real sample (Elena Sabe, OPF3, 11 columns → 7 shown, search "CT1" filters). Related: [[calibre-plugin-push-4863]]. diff --git a/apps/readest-app/.claude/memory/calibre-plugin-push-4863.md b/apps/readest-app/.claude/memory/calibre-plugin-push-4863.md new file mode 100644 index 000000000..5a15bbc4c --- /dev/null +++ b/apps/readest-app/.claude/memory/calibre-plugin-push-4863.md @@ -0,0 +1,23 @@ +--- +name: calibre-plugin-push-4863 +description: "readest-calibre-plugin (#4863) pushes calibre books+metadata to Readest cloud; key protocol facts (OAuth localhost relay, /sync explicit-null carry-over)" +metadata: + node_type: memory + type: project + originSessionId: 5d4d83a0-0aee-4200-852f-555df5243bed +--- + +`apps/readest-calibre-plugin/` implements #4863: calibre GUI plugin pushing selected books + metadata into the Readest cloud, modeled on BookFusion's plugin. MERGED to main via PR #4918 (2026-07-04, merge 6b403d019); packaged in releases as `Readest-.calibre-plugin.zip` by release.yml's `build-calibre-plugin` job. + +Design decisions and hard-won protocol facts: +- **Identity**: `Book.hash` = partial MD5 (KOReader algorithm; JS `1024 << -2` wraps to 0, so offsets are 0, 1024, 4096, ... 1024<<20). metaHash = `md5(NFC("title|authors,|ids,"))`, preferred id scheme uuid > calibre > isbn; Python impl verified byte-identical to `js-md5` output. +- **OPF embedding + uuid dedup** (v2, per maintainer request): metadata IS embedded into a temp copy at upload (`calibre.ebooks.metadata.meta.set_metadata` — deterministic for EPUB, writes custom columns as `calibre:user_metadata`). Dedup keys: calibre uuid in row `metadata.identifier` (survives byte changes) + `metadata.calibreSourceHash` = raw library-file partialMD5 (change detection, no local state; v1 rows fall back to `book_hash` which equals the raw hash). File changed → replace flow: upload new blob, push new row (carry-over) + tombstone old in one /sync POST, best-effort delete old cloud files. Metadata-only edit → row update, no re-upload (embedded OPF goes stale until next file upload). +- **POST /sync explicit-nulls absent fields** (transformBookToDB) — updates must carry over `uploadedAt`, `groupId/Name`, `progress`, `readingStatus*`, `coverHash` from the pulled server row (`wire.py::merge_for_push`); same lesson as koplugin syncbooks.lua. +- **Upload key** `Readest/Books/{hash}/{hash}.{ext}`; app's `{title}.{ext}` downloads resolve via download.ts hash+extension fallback. cover.png stores *original* bytes (app never converts formats, bookService.ts:568), so calibre's cover.jpg bytes upload as-is; coverHash = partialMD5 of those bytes. +- **OAuth from a non-app client works**: `{supabase}/auth/v1/authorize?provider=X&redirect_to=http://localhost:PORT` is whitelisted (readest-app's Flatpak/custom-OAuth production path uses it). Tokens arrive in the URL *fragment*; serve a page whose JS relays `location.hash` to `/callback?...` (tauri-plugin-oauth trick). Implemented in `oauth.py`. +- Pure modules (`api.py`, `wire.py`, `oauth.py`) are calibre-free; `make test` runs 56 unittests; `make zip` builds; smoke-test inside calibre with `calibre-debug -c` after `from calibre.customize.ui import find_plugin` (initializes the `calibre_plugins` namespace). + +- **Release packaging** (PR #4918): `build-calibre-plugin` job in release.yml mirrors the koplugin job; perl-stamps `PLUGIN_VERSION` from readest-app package.json, `make zip` → `Readest-.calibre-plugin.zip` release asset. Committed version stays the (0, 1, 0) dev placeholder. +- **Pushing workflow files**: gh's OAuth token lacks `workflow` scope (HTTPS push of .github/workflows/* rejected); SSH push works (transient hangs — retry with ConnectTimeout/ServerAliveInterval). + +Related: [[koplugin-cover-upload]], [[grimmory-native-sync]], [[ci-pr-delivery-and-push]] diff --git a/apps/readest-app/.claude/memory/captured-turn-instant-highlight-scrolllock.md b/apps/readest-app/.claude/memory/captured-turn-instant-highlight-scrolllock.md new file mode 100644 index 000000000..f186942bc --- /dev/null +++ b/apps/readest-app/.claude/memory/captured-turn-instant-highlight-scrolllock.md @@ -0,0 +1,43 @@ +--- +name: captured-turn-instant-highlight-scrolllock +description: Captured slide/curl page turns ignored the instant-highlight still-hold gate; fixed by honoring renderer.scrollLocked like the push paginator +metadata: + node_type: memory + type: project + originSessionId: 871c7b42-61c0-44e7-a1d6-8edb35d80300 +--- + +Instant Highlight's 300ms still-hold gate ([[instant-highlight-tap-paginate]]) +worked in **push** mode but NOT in **slide/curl** — a swipe after the hold turned +the page (with the slide/curl effect) instead of extending the highlight. + +**Root cause: two independent swipe paths.** foliate's native `#onTouchMove` +(paginator.js) bows out at `if (this.hasAttribute('no-swipe')) return` (~2149), +THEN checks `if (this.#scrollLocked) return` (~2162), THEN the `#layeredTurn` VT +drag (~2179). So: +- **push** (no turn-style, no no-swipe) → native swipe, honors `#scrollLocked`. ✅ +- **VT-layered slide** (`turn-style='slide'`, no no-swipe; engines with nested VT + groups) → native swipe → layered turn, still AFTER the scrollLocked check. ✅ +- **captured curl (always) / captured slide (Tauri w/o full VT support)** → + `applyPageTurnAttributes` sets `no-swipe`, so native swipe returns early and the + APP-side captured-turn touch interceptor in `useCapturedTurn.ts` (priority 5, + driven by `iframe-touchmove` → `dispatchTouchInterceptors`) is the swipe handler. + It began a drag on any >15px horizontal move WITHOUT checking scrollLocked. ❌ + +`useTextSelector.startInstantAnnotating` sets `view.renderer.scrollLocked = true` +when the hold engages. The captured interceptor is a parallel reimplementation of +swipe-to-turn and must honor the same lock independently. + +**Fix (app PR readest#5000 + foliate readest/foliate-js#51, tests: +`useCapturedTurn-scrollLock.test.ts`):** +1. foliate `paginator.js`: add `get scrollLocked()` — it was setter-only, so JS + couldn't read it back (app `src/types/view.ts` already declared it a readable + boolean). foliate PR #51 MERGED (squash → `ba57ec8` on foliate main); app + #5000 bumps the submodule pointer to `ba57ec8` (mergeable, awaiting merge). +2. `useCapturedTurn.ts` touch interceptor, `move` phase, before starting a drag + (`!state` branch): `if (currentView.renderer.scrollLocked) return false;`. + +**Why the `!state` branch is sufficient:** a captured drag needs >15px horizontal +travel, but `maybeCancelInstantHoldOnMove` cancels the hold at >10px — so a drag +can never already be in progress when instant annotation engages; no need to gate +an in-flight drag. See [[page-turn-styles-viewtransitions-555]]. diff --git a/apps/readest-app/.claude/memory/cloud-sync-provider-selection-plan.md b/apps/readest-app/.claude/memory/cloud-sync-provider-selection-plan.md new file mode 100644 index 000000000..869d0a6bb --- /dev/null +++ b/apps/readest-app/.claude/memory/cloud-sync-provider-selection-plan.md @@ -0,0 +1,32 @@ +--- +name: cloud-sync-provider-selection-plan +description: "APPROVED /autoplan-reviewed plan making third-party sync (WebDAV/Drive) a first-class selectable provider; quota scoped to Readest Cloud (#4959/#4380)" +metadata: + node_type: memory + type: project + originSessionId: c0549d91-7f40-46a8-b110-628964be195b +--- + +Plan APPROVED 2026-07-06 after full /autoplan review (CEO+Design+Eng dual voices, 43 logged decisions). Plan file: `~/.claude/plans/research-on-https-github-com-readest-rea-velvet-meteor.md` (contains registries, UI state matrix, eng hardening, coverage diagram, 26 tasks). CEO doc: `~/.gstack/projects/unknown/ceo-plans/2026-07-06-cloud-sync-provider-selection.md`. + +**Architecture:** policy layer over TWO engines (native DB-sync + FileSyncEngine) — Readest Cloud is NOT wrapped in FileSyncProvider (would regress server merges #4634/#4544/#4678). New `src/services/sync/cloudSyncProvider.ts`: pure `getCloudSyncProvider(settings)` derived from `webdav/googleDrive.enabled` (device-local) + separate `resolveCloudSyncGate(settings, plan)` w/ cached plan accessor (isCloudSyncAllowed needs async JWT — can't be settings-pure). Guard trips → PAUSED state + prompt, never silent readest fallback. Native gating = one branch in `syncCategories.isSyncCategoryEnabled` (book/progress/note); binary gating = `transferManager.queueUpload` returns null. Account channels (settings/stats/replicas/translations/Send) always native. + +**Sequence (user-ruled at gate):** PR1 quota decouple (#4959 hotfix: gate + quota-403 no-retry + BATCH toast dedupe — spam is N-books×1-toast after retries, verified transferManager.ts:376/395) → PR1.5 file-engine parity (tags+readingStatus in mergeBookConfig/mergeBookMetadata+wire, BEFORE gating) → PR2 exclusive gating + mixed-fleet detection → PR3 chooser UI. + +**Gate rulings:** UC3 = `syncBooks` AUTO-ENABLES on third-party selection (closes books-backed-up-nowhere hole; opt-out shows warning). UC1 = derived/device-local kept + read-only `/api/sync?since=providerSelectedAt&limit=1` probe → one-time "another device still syncs" banner + Sentry provider tag. UC2 = parity before gating. Switch-back = new-imports-only auto-upload (NO 675-book burst); metadata rows DO re-push (intended). + +**PR1 IMPLEMENTED (2026-07-06):** commit `f6e5d7740` on branch `fix/cloud-sync-quota-decouple` (worktree `/Users/chrox/dev/readest-fix-cloud-sync-quota-decouple`), 22 files, LOCAL ONLY (not pushed, per confirm-before-push). Full suite 6900 pass + lint clean; new suites: `cloudSyncProvider.test.ts` (18), `transfer-manager-gating.test.ts` (19). i18n extraction deliberately SKIPPED in this PR (scanner pruned ~1350 live translations, e.g. "Read Aloud" — run the dedicated /i18n pass later; new strings fall back to English keys). Deviations from plan, all sound: paused toast centralized in `handleBookUpload` (both manual surfaces route through it); useTransferQueue default-param hazard fixed by the manager-level settings barrier instead of signature churn; migration passes the settings snapshot into `runMigrations(lastVersion, settings)` and mutates in place because `Settings.loadSettings` re-reads disk (subclass post-save would clobber an independent save). **SERIES FULLY MERGED (2026-07-07): #4971 (PR1 quota) + #4973 (PR1.5 parity) + #4975 (PR2 exclusive routing, closes #4380) + #4976 (PR3 chooser UI).** Worktrees removed, local branches deleted. **LIVE-VERIFY BUG FOUND+FIXED = #4981 OPEN** (`3a0af54dd`, fix/file-sync-auth-abort): expired Drive web token → engine swallowed AUTH_FAILED on index pull → remoteIndex=null read as FIRST SYNC → attempted 682-book re-upload march; latent hazard: null index skips the peers-tombstone union in the final re-push (#4860 class — transient pull failure could resurrect deletions). Fix: unreadable index (throw) aborts (404→null stays first-sync); terminal AUTH_FAILED latch stops runPool + skips index push + rethrows; web auto-sync preflights hasValidWebDriveToken. KEY ENGINE INVARIANT going forward: FileSyncError AUTH_FAILED is terminal — rethrow, never aggregate. + +**i18n PASS = #4980 OPEN** (`237953cc2`, fix/cloud-sync-i18n, worktree `readest-fix-cloud-sync-i18n`): 22 strings x 33 locales + CLDR plural forms + en `_one`/`_other`, appended WITHOUT the scanner (removeUnusedKeys would prune live keys), additions-only diff. REMAINING: live verification checklist (real WebDAV 192.168.2.3:6065: exclusive e2e, syncBooks auto-enable on connect, fleet banner, switch-back no-burst, two-window switch), TODOS.md follow-ups (Sentry Rust tag, server quota error code, download-all-before-switch, library sync indicator, account chip, stats/viewSettings parity, Manage-Sync binary-gating mismatch). Note: GitHub reports 5 dependabot vulns on default branch (1 high) — pre-existing. + +**PR3 contents:** activation moved to `src/services/sync/cloudSyncActivation.ts` (accepts 'readest'; component cloudSync.ts is a re-export shim); pure status matrix `cloudSyncStatus.ts` (getReadestCloudRowStatus/getThirdPartyRowStatus, fully tested — paused renders on the THIRD-PARTY row, not Readest row as plan sketch had it); Cloud Sync section (Readest-first radio rows, scope subtitle, role=radiogroup); Readest Cloud inline sub-page (Quota + NavigationRow to Account, never navigateToProfile from the row); premium branch keeps Readest row; capability Tips both directions in webdav/gdrive sub-pages; FileSyncForm Upload Book Files relabel; SyncCategoriesSection 'Managed by {{provider}}' description swap (toggles stay live). + +**REMAINING (user/ops):** push 2 branch stacks + open PRs (PR1.5 independent; PR2/3 stacked on PR1); dedicated /i18n pass for ~20 new strings (extraction pruning hazard — run /i18n which handles it); live verification per plan (real WebDAV 192.168.2.3:6065: exclusive mode e2e, syncBooks auto-enable, fleet banner, switch-back no-burst); TODOS.md follow-ups (Sentry Rust tag, quota error code, etc.). Discard uncommitted TODOS.md duplicate in main checkout. + +**PR2 IMPLEMENTED (2026-07-06):** commit `95fd33f0a` on `feat/cloud-sync-exclusive-gating`, STACKED on PR1 in the same worktree (`/Users/chrox/dev/readest-fix-cloud-sync-quota-decouple`), 27 files +869/-99, LOCAL ONLY. Full suite 6923 pass + lint clean. Contents: syncCategories provider gate (book/progress/note, runtime override, user toggles persist); `persistActiveCloudProvider` single write path (chooser + both connect/disconnect flows + gdrive OAuth callback which had bypassed broadcast); minimal switch-only broadcast (`{enabled, providerSelectedAt}` — never credentials/cursors); **found+fixed PR1 integration bug: buildWebDAVConnectSettings pre-set `enabled:true` so fresh-connect never triggered the syncBooks auto-flip — builder is now activation-agnostic**; fileSyncStore `lastError` + `fleetNoticeShown`; `runActiveFileLibrarySync()` shared runner (menu tap + pull-to-refresh + BackupWindow all route via pullLibrary's provider branch — fixes "undefined book(s) synced"); SettingsMenu "Synced via {{provider}}" + quota caption + Auto-Upload hidden (also command palette `action.autoUpload` filtered, BookItem badge, TransferQueuePanel Upload All); mixed-fleet read-only probe (`pullChanges(providerSelectedAt,'books',...,1)` in useBooksSync's throttled interval, once-per-session toast); `providerSelectedAt` in both provider types + backup blacklist. Sentry cloudSyncProvider tag DEFERRED to TODOS (tagging is Rust-mediated via set_webview_info pattern — needs src-tauri command). + +**PR1.5 IMPLEMENTED (2026-07-06):** commit `f19fc6fa1` on `feat/file-sync-metadata-parity` (worktree `/Users/chrox/dev/readest-feat-file-sync-metadata-parity`, branched off origin/main independent of PR1), 4 files +253/-26, LOCAL ONLY. Full suite 6869 pass + lint clean. KEY FINDING: library.json already serializes FULL Book objects — tags/readingStatus were on the wire all along; the drop was `mergeBookMetadata`'s overlay (same gap as #4942 groups) + the reconcile predicate not firing on status-only changes. Fix: tags join the metadata LWW subset (raw assignment, removals propagate); readingStatus merges on its own `readingStatusUpdatedAt` clock (client mirror of #4634); new `shouldApplyRemoteBookMetadata` predicate (either clock) replaces `isRemoteBookMetadataNewer` in the engine reconcile filter (the old predicate stays exported). NO wire changes needed. PR2 stacks on PR1 (needs cloudSyncProvider.ts) — merge PR1 first or stack branches. + +**Key traps found in review:** `BACKUP_SETTINGS_BLACKLIST` does NOT exclude enabled flags/webdav.deviceId (plan text was wrong; PR1 adds deviceId/lastSyncedAt to blacklist); settings broadcast must carry ONLY `{enabled}` (password would leak; routine lastSyncedAt writes could revert a switch via slice LWW); Drive OAuth callback writes via appService.saveSettings bypassing broadcast → centralize `activateCloudProvider()`; `useTransferQueue()` DEFAULT params (`libraryLoaded=true`) in SettingsMenu/TransferQueuePanel are the real unguarded init path (barrier = `settings.version`); cancelled needs structured `cancelReason` + queue schemaVersion (`retryAllFailed` resurrects cancelled rows today; failed-includes-cancelled copy-pasted in 5 places); fileSyncStore is process-local — durable lastSyncedAt lives in provider settings. + +See [[webdav-filesync-refactor-plan]] · [[gdrive-provider-multipr-status]]. diff --git a/apps/readest-app/.claude/memory/cross-page-selection-autoturn-4741.md b/apps/readest-app/.claude/memory/cross-page-selection-autoturn-4741.md new file mode 100644 index 000000000..d31407cf1 --- /dev/null +++ b/apps/readest-app/.claude/memory/cross-page-selection-autoturn-4741.md @@ -0,0 +1,57 @@ +--- +name: cross-page-selection-autoturn-4741 +description: Cross-page selection/highlight in paginated mode via extracted useAutoPageTurn; all four selection gestures drive the corner-dwell turn +metadata: + node_type: memory + type: project + originSessionId: 33b70e98-fb55-467a-b03f-e4065491bc7e +--- + +#4741: in paginated (non-scrolling) mode, extend a selection/highlight past the +page edge by turning the page mid-gesture. Branch `feat/cross-page-highlight-autoturn`. + +**Extracted `src/app/reader/hooks/useAutoPageTurn.ts`** from `useTextSelector` — +the corner-dwell auto page-turn (#1354), now **decoupled from the DOM selection** +so selection-less gestures can drive it. API: `notePoint`/`noteAutoTurnPoint` +(window-coord engagement point), `cancel`, `isAutoTurning`, `onAfterTurn(cb)` +(Set of subs), `cornerAtPoint`, `readingAreaRect`. Liveness at dwell fire-time is +an injected predicate, not `doc.getSelection()`: `noteCorner(corner, isInCorner)`. +`useTextSelector` keeps the dual-signal native liveness (`pointerCornerNow || +caretCornerNow`); point-only callers use `noteAutoTurnPoint` (last-point liveness). +Pure exports `getReadingAreaRect`, `turnForFocusBeyondPage`, `keyboardTurnDirection`. + +**Key trap:** the old `armDwell` required a valid DOM selection to turn. Instant +Highlight (`user-select:none` + CFI overlay) and AnnotationRangeEditor (CFI +overlay) have **no** DOM selection, so the machine refused to turn for them. The +decoupling is what makes them work at all. + +**Four gestures, all feeding the one machine** (`useTextSelector` re-exposes +`noteAutoTurnPoint`/`cancelAutoTurn`/`onAutoTurn` to the editors via `Annotator`): +1. Instant Highlight drag — `handlePointerMove`/`handleNativeTouchMove` feed the + finger corner. `useInstantAnnotation` now **DOM-anchors the start** (`startPosRef` + = `{node,offset}` at pointer-down; `buildRangeFromAnchor` builds anchor->end each + move) so it survives the scroll; relaxed the pointer-up `distance<10` cancel with + `&& !previewAnnotationRef.current`. See [[instant-highlight-tap-paginate]]. +2. `SelectionRangeEditor` handle drag — already DOM-anchored the fixed end; just + feed `noteAutoTurnPoint(point)` + cancel + re-emit. +3. `AnnotationRangeEditor` handle drag — `useAnnotationEditor` changed from + `handleAnnotationRangeChange(startPt,endPt)` (`buildRangeFromPoints` resolved BOTH + ends from window coords -> lost previous page) to `applyAnnotationRange(range,...)`; + component anchors the non-dragged end (`fixedAnchorRef`) + builds via + `rangeFromAnchorToPoint` like SelectionRangeEditor. +4. `Shift+Arrow` keyboard adjust (#4728) — `useBookShortcuts.adjustTextSelection`, + after `extendSelectionFromContents`, **immediate turn-on-cross** (no dwell): + `keyboardTurnDirection(contents, getReadingAreaRect(...))` -> `view.next()/prev()` + when the extended focus leaves the page. Desktop-only; gated `!scrolled`. + +**After-turn re-emit:** active gesture subscribes `onAfterTurn` to rebuild its range +from the held point onto the new page immediately (instant: `reapplyInstantAnnotation`; +editors: `subscribeAutoTurnReemit` -> `updateFromDraggedPoint(lastPoint)`). Native +selection does NOT subscribe (browser extends its own). The Android #873 scroll-pin +(`selectionPosition`) is re-anchored after every turn via `onAfterTurn` in useTextSelector. + +`focusCaretWindowPos` promoted `useTextSelector` -> `src/utils/sel.ts` (keyboard reuse). +Scope: within-section column turns only (a Range can't span two iframe docs). +Tests: `useAutoPageTurn.test.ts` (21), `useTextSelector-instantTurn.test.ts`, +`useInstantAnnotation.test.ts`, `useAnnotationEditor.test.ts`; existing autoTurn/ +instantHold suites stay green (regression net for the extraction). diff --git a/apps/readest-app/.claude/memory/customize-toolbar-eink-black-bar-4839.md b/apps/readest-app/.claude/memory/customize-toolbar-eink-black-bar-4839.md new file mode 100644 index 000000000..b2e9b430f --- /dev/null +++ b/apps/readest-app/.claude/memory/customize-toolbar-eink-black-bar-4839.md @@ -0,0 +1,14 @@ +--- +name: customize-toolbar-eink-black-bar-4839 +description: Customize Toolbar preview rendered as a solid black bar in e-ink; preview surfaces copying bg-gray-600 need eink-bordered +metadata: + type: project +--- + +#4839: the Customize Toolbar sub-page (`AnnotationToolbarCustomizer.tsx`) toolbar **preview** Zone copied the live popup's `selection-popup bg-gray-600 text-white` but rendered as an unreadable solid black bar under `[data-eink='true']`. + +**Why:** the real reader popup earns its e-ink chrome from `.popup-container` (globals.css `[data-eink] .popup-container` → `bg base-100` + 1px `base-content` border). The preview Zone is a plain `

` with NO `popup-container`, so the dark `bg-gray-600` survived in e-ink; the base-content (inverted via `[data-eink] button`) chip icons then sat black-on-black. + +**How to apply:** any e-ink "preview" surface that mimics the live popup must scope the dark fill to non-e-ink (`not-eink:bg-gray-600 not-eink:text-white`) and add `eink-bordered` so e-ink renders it as `bg-base-100` + 1px `base-content` border (don't just rely on `eink-bordered`'s `!important` to override the gray — drop the gray in e-ink outright). Also fix copied white hint text (`text-white/70` → `not-eink:text-white/70 eink:text-base-content`) since the surface turns base-100. Chip icons need no change — they are ` + +
+ ), +})); vi.mock('@/components/metadata/SourceSelector', () => ({ __esModule: true, default: () => null })); vi.mock('@/components/Spinner', () => ({ __esModule: true, default: () => null })); @@ -122,3 +139,49 @@ describe('BookDetailModal cover refresh after save', () => { expect(screen.getByTestId('cover').getAttribute('src')).toBe('_blank'); }); }); + +describe('BookDetailModal purge-on-delete routing', () => { + const renderModal = (handlers: { handleBookDelete: () => void; handleBookPurge: () => void }) => + render( + + + , + ); + + const openStandardDelete = (container: HTMLElement) => { + fireEvent.click(container.querySelector('button[aria-label="Delete Book Options"]')!); + fireEvent.click(screen.getByText('Remove from Cloud & Device')); + }; + + it('shows the purge toggle on the standard delete and routes to purge when enabled', () => { + const handleBookDelete = vi.fn(); + const handleBookPurge = vi.fn(); + const { container } = renderModal({ handleBookDelete, handleBookPurge }); + + openStandardDelete(container); + expect(screen.getByTestId('delete-confirm').getAttribute('data-purge-toggle')).toBe('true'); + + fireEvent.click(screen.getByText('confirm-purge-data')); + expect(handleBookPurge).toHaveBeenCalledTimes(1); + expect(handleBookDelete).not.toHaveBeenCalled(); + }); + + it('routes the standard delete to a plain delete when the toggle is off', () => { + const handleBookDelete = vi.fn(); + const handleBookPurge = vi.fn(); + const { container } = renderModal({ handleBookDelete, handleBookPurge }); + + openStandardDelete(container); + fireEvent.click(screen.getByText('confirm-keep-data')); + expect(handleBookDelete).toHaveBeenCalledTimes(1); + expect(handleBookPurge).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx b/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx index dec935ada..6603e929f 100644 --- a/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx +++ b/apps/readest-app/src/__tests__/components/BookDetailView.test.tsx @@ -166,23 +166,17 @@ describe('BookDetailView More menu (Goodreads + Share)', () => { }); }); -describe('BookDetailView Purge Data action', () => { - it('offers Purge Data in the delete dropdown and calls onPurge', () => { - const onPurge = vi.fn(); - const { container, getByText } = renderView({ onPurge }); - const toggle = container.querySelector('button[aria-label="Delete Book Options"]'); - fireEvent.click(toggle!); - const purgeButton = getByText('Purge Data').closest('button'); - expect(purgeButton).toBeTruthy(); - fireEvent.click(purgeButton!); - expect(onPurge).toHaveBeenCalledTimes(1); - }); - - it('omits Purge Data when onPurge is not provided', () => { +describe('BookDetailView delete dropdown (purge folded into the confirm alert)', () => { + it('no longer offers a Purge Data action and keeps the three remove options', () => { const { container, queryByText } = renderView(); const toggle = container.querySelector('button[aria-label="Delete Book Options"]'); fireEvent.click(toggle!); + // Purge is now an opt-in toggle on the delete confirmation alert, not a + // standalone menu item. expect(queryByText('Purge Data')).toBeNull(); + expect(queryByText('Remove from Cloud & Device')).toBeTruthy(); + expect(queryByText('Remove from Cloud Only')).toBeTruthy(); + expect(queryByText('Remove from Device Only')).toBeTruthy(); }); }); diff --git a/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx b/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx new file mode 100644 index 000000000..2a5d1ce2c --- /dev/null +++ b/apps/readest-app/src/__tests__/components/DeleteConfirmAlert.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, screen } from '@testing-library/react'; + +import DeleteConfirmAlert from '@/components/DeleteConfirmAlert'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +// The wrapped calls useKeyDownActions, which reads these. +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: null }), +})); + +vi.mock('@/store/deviceStore', () => ({ + useDeviceControlStore: () => ({ + acquireBackKeyInterception: vi.fn(), + releaseBackKeyInterception: vi.fn(), + }), +})); + +afterEach(() => cleanup()); + +const setup = (props?: Partial>) => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { onConfirm, onCancel }; +}; + +describe('DeleteConfirmAlert purge toggle', () => { + it('renders the purge toggle OFF by default and confirms without purging', () => { + const { onConfirm } = setup(); + + const toggle = screen.getByRole('checkbox') as HTMLInputElement; + expect(toggle.checked).toBe(false); + + const confirm = screen.getByText('Delete').closest('button')!; + expect(confirm.className).toContain('btn-warning'); + + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(false); + }); + + it('escalates to a destructive purge when the toggle is turned on', () => { + const { onConfirm } = setup(); + + const toggle = screen.getByRole('checkbox') as HTMLInputElement; + fireEvent.click(toggle); + expect(toggle.checked).toBe(true); + + const confirm = screen.getByText('Purge & Delete').closest('button')!; + expect(confirm.className).toContain('btn-error'); + + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(true); + }); + + it('omits the toggle and always confirms without purging when showPurgeToggle is false', () => { + const { onConfirm } = setup({ showPurgeToggle: false }); + + expect(screen.queryByRole('checkbox')).toBeNull(); + + fireEvent.click(screen.getByText('Delete').closest('button')!); + expect(onConfirm).toHaveBeenCalledWith(false); + }); + + it('invokes onCancel from the Cancel button', () => { + const { onCancel } = setup(); + fireEvent.click(screen.getByText('Cancel').closest('button')!); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx b/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx index aa80bcb64..47c51ad87 100644 --- a/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx +++ b/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, cleanup, fireEvent } from '@testing-library/react'; +import { render, cleanup, fireEvent, act } from '@testing-library/react'; import ImageViewer from '@/app/reader/components/ImageViewer'; @@ -83,4 +83,35 @@ describe('ImageViewer', () => { fireEvent.mouseUp(window); expect(img.style.transition).not.toBe('none'); }); + + // Trackpad pinch flicker (#4742): on macOS a trackpad pinch-to-zoom arrives + // as a rapid stream of ctrl+wheel events. With the 0.05s transition left on, + // each event restarts the in-flight transition from its interpolated + // mid-point, so the image lags and flickers — the same root cause as the + // #4451 pan flicker. The transition must be off while the wheel-zoom gesture + // is streaming, then return for discrete zoom once the gesture settles. + it('disables the transform transition during ctrl+wheel (trackpad pinch) zoom', () => { + vi.useFakeTimers(); + try { + const { container } = render( + , + ); + const img = container.querySelector('img')!; + + expect(img.style.transition).not.toBe('none'); + + act(() => { + fireEvent.wheel(img, { deltaY: -50, ctrlKey: true, clientX: 100, clientY: 100 }); + }); + expect(img.style.transition).toBe('none'); + + // After the gesture settles the smoothing returns for discrete zoom. + act(() => { + vi.advanceTimersByTime(500); + }); + expect(img.style.transition).not.toBe('none'); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx index a41a0b87d..872d504e6 100644 --- a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ProgressBar from '@/app/reader/components/ProgressBar'; import { DEFAULT_VIEW_CONFIG } from '@/services/constants'; import type { BookProgress, ViewSettings } from '@/types/book'; +import type { TOCItem } from '@/libs/document'; const saveViewSettings = vi.fn(); @@ -11,9 +12,11 @@ let currentViewSettings: ViewSettings; let currentProgress: BookProgress | null; let currentBookData: { isFixedLayout: boolean; - bookDoc?: { metadata?: Record }; + bookDoc?: { metadata?: Record; toc?: TOCItem[] }; } | null; let currentRenderer: { page: number; pages: number }; +let currentSectionFractions: number[] = []; +let currentTocHrefIndex: Record = {}; vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string, values?: Record) => @@ -23,8 +26,9 @@ vi.mock('@/hooks/useTranslation', () => ({ .replace('{{time}}', String(values?.['time'] ?? '{{time}}')), })); +let currentAppService = { isMobile: false, hasSafeAreaInset: false }; vi.mock('@/context/EnvContext', () => ({ - useEnv: () => ({ envConfig: {}, appService: { isMobile: false, hasSafeAreaInset: false } }), + useEnv: () => ({ envConfig: {}, appService: currentAppService }), })); // Production code uses per-field selectors; mock must apply them so each @@ -34,7 +38,12 @@ vi.mock('@/store/readerStore', () => { const state = { getProgress: () => currentProgress, getViewSettings: () => currentViewSettings, - getView: () => ({ renderer: currentRenderer }), + getView: () => ({ + renderer: currentRenderer, + getSectionFractions: () => currentSectionFractions, + resolveNavigation: (href: string) => + href in currentTocHrefIndex ? { index: currentTocHrefIndex[href]! } : null, + }), }; return { useReaderStore: (selector?: (s: typeof state) => R) => (selector ? selector(state) : state), @@ -89,9 +98,12 @@ afterEach(() => { beforeEach(() => { saveViewSettings.mockClear(); + currentAppService = { isMobile: false, hasSafeAreaInset: false }; currentProgress = null; currentBookData = { isFixedLayout: false }; currentRenderer = { page: 0, pages: 0 }; + currentSectionFractions = []; + currentTocHrefIndex = {}; }); const makeProgress = (current: number, total: number): BookProgress => @@ -101,70 +113,12 @@ const makeProgress = (current: number, total: number): BookProgress => timeinfo: { section: 0, total: 0 }, }) as BookProgress; -describe('ProgressBar — tap-to-toggle disabled reverts hidden footer', () => { - it("resets progressInfoMode to 'all' when the user disables tapToToggleFooter while mode was 'none'", () => { - // Simulate a user who tapped the footer to dismiss it (mode='none') - // while tapToToggleFooter was on. Now they have it switched off. - currentViewSettings = { - ...baseSettings, - tapToToggleFooter: false, - progressInfoMode: 'none', - } as ViewSettings; - - renderProgressBar(); - - // The persisted progressInfoMode should be reset to the default - // ('all') so the footer reverts to its default visibility. - const persistCalls = saveViewSettings.mock.calls.filter( - (args) => args[2] === 'progressInfoMode', - ); - expect(persistCalls.length).toBeGreaterThanOrEqual(1); - const lastCall = persistCalls[persistCalls.length - 1]!; - expect(lastCall[3]).toBe('all'); - }); - - it("does not overwrite mode when tapToToggleFooter is on (user's cycled state stays)", () => { - currentViewSettings = { - ...baseSettings, - tapToToggleFooter: true, - progressInfoMode: 'none', - } as ViewSettings; - - renderProgressBar(); - - // initial save mirrors the existing mode; importantly we never see - // a save with 'all' overriding the user's tap-cycled choice. - const persistCalls = saveViewSettings.mock.calls.filter( - (args) => args[2] === 'progressInfoMode', - ); - expect(persistCalls.every((args) => args[3] === 'none')).toBe(true); - }); - - it("leaves mode untouched when tapToToggleFooter is off but mode is already 'all'", () => { - currentViewSettings = { - ...baseSettings, - tapToToggleFooter: false, - progressInfoMode: 'all', - } as ViewSettings; - - renderProgressBar(); - - const persistCalls = saveViewSettings.mock.calls.filter( - (args) => args[2] === 'progressInfoMode', - ); - // Either no save or a save matching the existing 'all' value — never - // a transition through some intermediate state. - expect(persistCalls.every((args) => args[3] === 'all')).toBe(true); - }); -}); - describe('ProgressBar — fixed-layout remaining pages', () => { it('says "in book" with section-derived count for fixed-layout books', () => { currentViewSettings = { ...baseSettings, showRemainingPages: true, showRemainingTime: false, - progressInfoMode: 'all', } as ViewSettings; currentProgress = makeProgress(2, 5); currentBookData = { isFixedLayout: true, bookDoc: { metadata: {} } }; @@ -181,7 +135,6 @@ describe('ProgressBar — fixed-layout remaining pages', () => { ...baseSettings, showRemainingPages: true, showRemainingTime: false, - progressInfoMode: 'all', } as ViewSettings; currentProgress = makeProgress(2, 5); currentBookData = { isFixedLayout: false }; @@ -204,7 +157,6 @@ describe('ProgressBar — decorative footer is not focusable', () => { // not be focusable so it can never receive a focus ring. currentViewSettings = { ...baseSettings, - progressInfoMode: 'all', } as ViewSettings; currentProgress = makeProgress(2, 5); currentBookData = { isFixedLayout: false }; @@ -217,3 +169,184 @@ describe('ProgressBar — decorative footer is not focusable', () => { expect(progressInfo!.hasAttribute('tabindex')).toBe(false); }); }); + +describe('ProgressBar — sticky progress bar', () => { + const tocItem = (href: string): TOCItem => ({ id: 0, label: href, href, index: 0 }) as TOCItem; + + const enableStickyBar = (overrides?: Partial) => { + currentViewSettings = { + ...baseSettings, + showStickyProgressBar: true, + ...overrides, + } as ViewSettings; + // fraction (0.5) deliberately differs from the page fraction + // ((2+1)/5 = 0.6) so the test proves the fill uses progress.fraction. + currentProgress = { ...makeProgress(2, 5), fraction: 0.5 } as BookProgress; + currentBookData = { + isFixedLayout: false, + bookDoc: { + toc: [ + tocItem('ch1.xhtml'), + tocItem('ch2.xhtml'), + tocItem('ch3.xhtml'), + tocItem('ch4.xhtml'), + ], + }, + }; + // 5 sections; chapter starts [0.2, 0.4, 0.6, 0.8]; first & last dropped -> 2 ticks. + currentSectionFractions = [0, 0.2, 0.4, 0.6, 0.8, 1]; + currentTocHrefIndex = { 'ch1.xhtml': 1, 'ch2.xhtml': 2, 'ch3.xhtml': 3, 'ch4.xhtml': 4 }; + currentRenderer = { page: 1, pages: 4 }; + }; + + it('renders the sticky bar with chapter ticks and a fill from progress.fraction', () => { + enableStickyBar(); + + const { container } = renderProgressBar(); + + const bar = container.querySelector('.sticky-progress-bar'); + expect(bar).not.toBeNull(); + expect(bar!.querySelectorAll('.sticky-progress-tick').length).toBe(2); + const fill = bar!.querySelector('.sticky-progress-fill') as HTMLElement; + expect(fill.style.width).toBe('50%'); + }); + + it('does not render the sticky bar when the setting is off', () => { + enableStickyBar({ showStickyProgressBar: false }); + + const { container } = renderProgressBar(); + + expect(container.querySelector('.sticky-progress-bar')).toBeNull(); + }); + + it('does not render the sticky bar in vertical writing mode', () => { + enableStickyBar({ vertical: true }); + + const { container } = renderProgressBar(); + + expect(container.querySelector('.sticky-progress-bar')).toBeNull(); + }); +}); + +describe('ProgressBar — display-only footer overlay', () => { + // The footer overlay is stacked above the book but is purely informational: + // it must never intercept taps or text selection over book content, and it + // exposes no clickable tap targets. In scrolled mode (no reserved band) each + // info segment carries its own shrink-wrapped pill backdrop so it stays + // legible floating over the text instead of a full-width bar. + const readerSettings = (overrides?: Partial) => { + currentViewSettings = { + ...baseSettings, + ...overrides, + } as ViewSettings; + currentProgress = makeProgress(2, 5); + currentBookData = { isFixedLayout: false }; + currentRenderer = { page: 1, pages: 4 }; + }; + + it('keeps the full-width container pointer-events-none even on mobile', () => { + readerSettings({ showRemainingPages: true }); + currentAppService = { isMobile: true, hasSafeAreaInset: false }; + + const { container } = renderProgressBar(); + + const progressInfo = container.querySelector('.progressinfo') as HTMLElement; + expect(progressInfo.classList.contains('pointer-events-none')).toBe(true); + expect(progressInfo.classList.contains('pointer-events-auto')).toBe(false); + }); + + it('exposes no interactive targets (display-only, no tap-to-toggle)', () => { + readerSettings({ showRemainingPages: true }); + + const { container } = renderProgressBar(); + + expect(container.querySelector('.pointer-events-auto')).toBeNull(); + expect(container.querySelector('.cursor-pointer')).toBeNull(); + expect(container.querySelector('.progress-restore-pad')).toBeNull(); + // No showFooter write ever originates from tapping the footer. + expect(saveViewSettings.mock.calls.some((args) => args[2] === 'showFooter')).toBe(false); + }); + + it('wraps each info segment in its own pill backdrop in scrolled mode', () => { + // Scrolled mode reserves no bottom band — the info floats over the book + // text, so each segment needs a shrink-wrapped backdrop to stay legible. + readerSettings({ scrolled: true, showRemainingPages: true }); + + const { container } = renderProgressBar(); + + const pills = container.querySelectorAll('.progress-pill'); + expect(pills.length).toBeGreaterThanOrEqual(2); // remaining + progress + for (const pill of pills) { + expect((pill as HTMLElement).classList.contains('bg-base-100/85')).toBe(true); + } + }); + + it('does not add pill backdrops in paginated mode (band holds the info)', () => { + readerSettings({ scrolled: false, showRemainingPages: true }); + + const { container } = renderProgressBar(); + + expect(container.querySelector('.progress-pill')).toBeNull(); + }); +}); + +describe('ProgressBar — contrast against the page (#4901)', () => { + // A light-mode PDF under a dark theme keeps its white page, so the footer + // progress/remaining text blends against the real backdrop (text-white/75 + + // mix-blend-difference) to stay legible over the white page. Reflowable books + // theme their own page to the UI, so the footer uses plain base-content text + // instead of the blend. StatusInfo (clock/battery) is intentionally left + // alone -- it manages its own blend against the battery glyph. + it('blends the progress and remaining text over a fixed-layout page in non-eink mode', () => { + currentViewSettings = { + ...baseSettings, + isEink: false, + showRemainingPages: true, + showRemainingTime: false, + } as ViewSettings; + currentProgress = makeProgress(2, 5); + currentBookData = { isFixedLayout: true }; + currentRenderer = { page: 1, pages: 4 }; + + const { container } = renderProgressBar(); + + const info = container.querySelector('.progressinfo') as HTMLElement; + expect(info.classList.contains('mix-blend-difference')).toBe(true); + expect(info.classList.contains('text-white/75')).toBe(true); + }); + + it('uses themed base-content text for reflowable books in non-eink mode', () => { + currentViewSettings = { + ...baseSettings, + isEink: false, + showRemainingPages: true, + showRemainingTime: false, + } as ViewSettings; + currentProgress = makeProgress(2, 5); + currentBookData = { isFixedLayout: false }; + currentRenderer = { page: 1, pages: 4 }; + + const { container } = renderProgressBar(); + + const info = container.querySelector('.progressinfo') as HTMLElement; + expect(info.classList.contains('mix-blend-difference')).toBe(false); + expect(info.classList.contains('text-base-content')).toBe(true); + }); + + it('does not blend in eink mode', () => { + currentViewSettings = { + ...baseSettings, + isEink: true, + showRemainingPages: true, + showRemainingTime: false, + } as ViewSettings; + currentProgress = makeProgress(2, 5); + currentBookData = { isFixedLayout: false }; + currentRenderer = { page: 1, pages: 4 }; + + const { container } = renderProgressBar(); + + const info = container.querySelector('.progressinfo') as HTMLElement; + expect(info.classList.contains('mix-blend-difference')).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx index 0caa5370c..51ac2a9fb 100644 --- a/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProofreadPopup.test.tsx @@ -227,6 +227,54 @@ describe('ProofreadPopup Component', () => { }); }); + describe('Regex Toggle', () => { + it('should render the regex checkbox (off by default)', () => { + renderWithProviders(); + + const regexLabel = screen.getByText('Regex:'); + expect(regexLabel).toBeTruthy(); + const checkbox = regexLabel + .closest('label')! + .querySelector('input[type="checkbox"]') as HTMLInputElement | null; + expect(checkbox).toBeTruthy(); + expect(checkbox!.checked).toBe(false); + }); + + it('should call onConfirm with isRegex true when regex is enabled', async () => { + renderWithProviders(); + + const regexLabel = screen.getByText('Regex:'); + const regexCheckbox = regexLabel + .closest('label')! + .querySelector('input[type="checkbox"]') as HTMLInputElement; + fireEvent.click(regexCheckbox); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'replacement' } }); + + const applyButton = screen.getByText('Apply'); + fireEvent.click(applyButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ isRegex: true })); + }); + }); + + it('should default isRegex to false when the toggle is left off', async () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText('Enter text...'); + fireEvent.change(input, { target: { value: 'replacement' } }); + + const applyButton = screen.getByText('Apply'); + fireEvent.click(applyButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ isRegex: false })); + }); + }); + }); + describe('Click Outside Behavior', () => { it('should not call onClose when clicking inside the menu', () => { renderWithProviders(); diff --git a/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx index e4cee73f0..3aaa6f6ab 100644 --- a/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProofreadRules.test.tsx @@ -12,6 +12,8 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useReaderStore } from '@/store/readerStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { useBookDataStore } from '@/store/bookDataStore'; +import { useProofreadStore } from '@/store/proofreadStore'; +import { eventDispatcher } from '@/utils/event'; import { ProofreadRule } from '@/types/book'; // ------------------------------ @@ -97,6 +99,7 @@ describe('ProofreadRulesManager', () => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); }); it('renders book and library (global) proofreading rules from stores', async () => { @@ -245,6 +248,383 @@ describe('ProofreadRulesManager', () => { expect(screen.getByText("'book-hit'")).toBeTruthy(); }); + it('hides tombstoned (deleted) book rules from the list', async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } }, + }); + + const liveRule: ProofreadRule = { + id: 'live', + scope: 'book', + pattern: 'visible-pattern', + replacement: 'kept', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }; + const deletedRule: ProofreadRule = { + id: 'dead', + scope: 'book', + pattern: 'deleted-pattern', + replacement: 'gone', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + updatedAt: 5, + deletedAt: 10, + }; + const rules = [liveRule, deletedRule]; + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: rules } } }, + }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { viewSettings: { proofreadRules: rules } }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + + await screen.findByRole('dialog'); + expect(screen.getByText('visible-pattern')).toBeTruthy(); + expect(screen.queryByText('deleted-pattern')).toBeNull(); + }); + + it('keeps a disabled book rule visible so it can be re-enabled', async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } }, + }); + + const disabledRule: ProofreadRule = { + id: 'd1', + scope: 'book', + pattern: 'disabled-pattern', + replacement: 'x', + enabled: false, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }; + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: [disabledRule] } } }, + }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { viewSettings: { proofreadRules: [disabledRule] } }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + + await screen.findByRole('dialog'); + expect(screen.getByText('disabled-pattern')).toBeTruthy(); + }); + + it('toggles a rule on/off via its switch', async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } }, + }); + + const rule: ProofreadRule = { + id: 'b1', + scope: 'book', + pattern: 'toggle-me', + replacement: 'x', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }; + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } }, + }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { viewSettings: { proofreadRules: [rule] } }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + const toggleSpy = vi + .spyOn(useProofreadStore.getState(), 'toggleRule') + .mockResolvedValue(undefined); + const recreateSpy = vi + .spyOn(useReaderStore.getState(), 'recreateViewer') + .mockResolvedValue(undefined as never); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + await screen.findByRole('dialog'); + + const row = screen.getByText('toggle-me').closest('li'); + const toggle = within(row!).getByLabelText('Disable rule'); + fireEvent.click(toggle); + + await new Promise((r) => setTimeout(r, 0)); + + expect(toggleSpy).toHaveBeenCalledWith(expect.anything(), 'book1', 'b1'); + expect(recreateSpy).toHaveBeenCalled(); + }); + + it('edits a book rule Find pattern and saves via updateRule', async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } }, + }); + + const rule: ProofreadRule = { + id: 'b1', + scope: 'book', + pattern: 'old-find', + replacement: 'r', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }; + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } }, + }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { viewSettings: { proofreadRules: [rule] } }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + const updateSpy = vi + .spyOn(useProofreadStore.getState(), 'updateRule') + .mockResolvedValue(undefined); + vi.spyOn(useReaderStore.getState(), 'recreateViewer').mockResolvedValue(undefined as never); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + await screen.findByRole('dialog'); + + const row = screen.getByText('old-find').closest('li'); + fireEvent.click(within(row!).getByLabelText('Edit')); + + const findInput = screen.getByDisplayValue('old-find') as HTMLInputElement; + expect(findInput.disabled).toBe(false); + fireEvent.change(findInput, { target: { value: 'new-find' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await new Promise((r) => setTimeout(r, 0)); + + expect(updateSpy).toHaveBeenCalledWith( + expect.anything(), + 'book1', + 'b1', + expect.objectContaining({ pattern: 'new-find' }), + ); + }); + + it('keeps Find read-only when editing a selection rule', async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } }, + }); + + const sel: ProofreadRule = { + id: 's1', + scope: 'selection', + pattern: 'sel-text', + replacement: 'r', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', + sectionHref: 'chapter1.html', + }; + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: [sel] } } }, + }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { viewSettings: { proofreadRules: [sel] } }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + await screen.findByRole('dialog'); + + const row = screen.getByText('sel-text').closest('li'); + fireEvent.click(within(row!).getByLabelText('Edit')); + + const findInput = screen.getByDisplayValue('sel-text') as HTMLInputElement; + expect(findInput.disabled).toBe(true); + }); + + it('warns and does not save when the edited regex is invalid', async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } }, + }); + + const rule: ProofreadRule = { + id: 'b1', + scope: 'book', + pattern: 'valid', + replacement: 'r', + enabled: true, + isRegex: true, + caseSensitive: true, + order: 1, + wholeWord: true, + }; + + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: [rule] } } }, + }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { viewSettings: { proofreadRules: [rule] } }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + const updateSpy = vi + .spyOn(useProofreadStore.getState(), 'updateRule') + .mockResolvedValue(undefined); + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch'); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + await screen.findByRole('dialog'); + + const row = screen.getByText('valid').closest('li'); + fireEvent.click(within(row!).getByLabelText('Edit')); + + const findInput = screen.getByDisplayValue('valid') as HTMLInputElement; + fireEvent.change(findInput, { target: { value: '(' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await new Promise((r) => setTimeout(r, 0)); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'warning' })); + }); + + it('renders a drag handle for each reorderable rule', async () => { + const selectionRule: ProofreadRule = { + id: 's1', + scope: 'selection', + pattern: 'only-once', + replacement: 'single-hit', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)', + sectionHref: 'chapter1.html', + }; + const bookRule: ProofreadRule = { + id: 'b1', + scope: 'book', + pattern: 'book-wide', + replacement: 'book-hit', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + }; + + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { ...DEFAULT_SYSTEM_SETTINGS, globalViewSettings: { proofreadRules: [] } }, + }); + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: [selectionRule, bookRule] } } }, + }); + (useBookDataStore.setState as unknown as (state: unknown) => void)({ + booksData: { + book1: { + id: 'book1', + book: null, + file: null, + config: { viewSettings: { proofreadRules: [selectionRule, bookRule] } }, + bookDoc: null, + isFixedLayout: false, + }, + }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + await screen.findByRole('dialog'); + + const handles = screen.getAllByLabelText('Drag to reorder'); + expect(handles.length).toBe(2); + }); + it('displays correct scope labels for different rule types', async () => { const selectionRule: ProofreadRule = { id: 's1', @@ -484,6 +864,116 @@ describe('ProofreadRulesManager', () => { expect(screen.getByText('No book-level replacement rules')).toBeTruthy(); }); + describe('Add Rule form', () => { + const openManagerForAdd = async () => { + (useSettingsStore.setState as unknown as (state: unknown) => void)({ + settings: { + ...DEFAULT_SYSTEM_SETTINGS, + globalViewSettings: { proofreadRules: [] }, + }, + }); + (useReaderStore.setState as unknown as (state: unknown) => void)({ + viewStates: { book1: { viewSettings: { proofreadRules: [] } } }, + }); + useSidebarStore.setState({ sideBarBookKey: 'book1' }); + + const addRuleSpy = vi + .spyOn(useProofreadStore.getState(), 'addRule') + .mockResolvedValue({} as ProofreadRule); + const recreateSpy = vi + .spyOn(useReaderStore.getState(), 'recreateViewer') + .mockResolvedValue(undefined as never); + + renderWithProviders(); + await Promise.resolve(); + setProofreadRulesVisibility(true); + await screen.findByRole('dialog'); + + return { addRuleSpy, recreateSpy }; + }; + + it('creates a book-scoped rule from typed pattern + replacement', async () => { + const { addRuleSpy } = await openManagerForAdd(); + + fireEvent.change(screen.getByPlaceholderText('Find...'), { target: { value: 'colour' } }); + fireEvent.change(screen.getByPlaceholderText('Replace with...'), { + target: { value: 'color' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Add Rule' })); + + await new Promise((r) => setTimeout(r, 0)); + + expect(addRuleSpy).toHaveBeenCalledWith( + expect.anything(), + 'book1', + expect.objectContaining({ + scope: 'book', + pattern: 'colour', + replacement: 'color', + isRegex: false, + }), + ); + }); + + it('creates a regex rule when the Regex toggle is on', async () => { + const { addRuleSpy } = await openManagerForAdd(); + + fireEvent.change(screen.getByPlaceholderText('Find...'), { target: { value: '\\d+' } }); + fireEvent.change(screen.getByPlaceholderText('Replace with...'), { + target: { value: '#' }, + }); + const regexLabel = screen.getByText('Regex:'); + const regexCheckbox = regexLabel + .closest('label')! + .querySelector('input[type="checkbox"]') as HTMLInputElement; + fireEvent.click(regexCheckbox); + + fireEvent.click(screen.getByRole('button', { name: 'Add Rule' })); + + await new Promise((r) => setTimeout(r, 0)); + + expect(addRuleSpy).toHaveBeenCalledWith( + expect.anything(), + 'book1', + expect.objectContaining({ pattern: '\\d+', isRegex: true }), + ); + }); + + it('disables the Add Rule button until a find pattern is entered', async () => { + const { addRuleSpy } = await openManagerForAdd(); + const button = screen.getByRole('button', { name: 'Add Rule' }) as HTMLButtonElement; + + expect(button.disabled).toBe(true); + fireEvent.click(button); + await new Promise((r) => setTimeout(r, 0)); + expect(addRuleSpy).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByPlaceholderText('Find...'), { target: { value: 'colour' } }); + expect(button.disabled).toBe(false); + }); + + it('warns and does not add when the regex is invalid', async () => { + const { addRuleSpy } = await openManagerForAdd(); + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch'); + + fireEvent.change(screen.getByPlaceholderText('Find...'), { target: { value: '(' } }); + const regexLabel = screen.getByText('Regex:'); + const regexCheckbox = regexLabel + .closest('label')! + .querySelector('input[type="checkbox"]') as HTMLInputElement; + fireEvent.click(regexCheckbox); + fireEvent.click(screen.getByRole('button', { name: 'Add Rule' })); + + await new Promise((r) => setTimeout(r, 0)); + + expect(addRuleSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith( + 'toast', + expect.objectContaining({ type: 'warning' }), + ); + }); + }); + it('merges book and library rules correctly in book section', async () => { const libraryRule: ProofreadRule = { id: 'l1', diff --git a/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx index 1db462bcb..b2e86233c 100644 --- a/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx +++ b/apps/readest-app/src/__tests__/components/ReadingRuler.test.tsx @@ -48,6 +48,25 @@ const makeLineRects = (count: number, pitch: number, height: number): RulerTestR height, })); +// A relocate range whose client rects are three vertical text columns (tall thin +// strips) in iframe-content coordinates. No frameElement -> rects map straight to +// overlay coordinates. Columns are listed right-to-left (reading order for +// vertical-rl), but the builder derives order from geometry + the rtl flag. +const makeVerticalColumnsRange = (): { + getClientRects: () => RulerTestRect[]; + startContainer: object; +} => { + const columnRects: RulerTestRect[] = [ + { top: 50, bottom: 950, left: 760, right: 776, width: 16, height: 900 }, // rightmost column + { top: 50, bottom: 950, left: 400, right: 416, width: 16, height: 900 }, // middle column + { top: 50, bottom: 950, left: 40, right: 56, width: 16, height: 900 }, // leftmost column + ]; + return { + startContainer: { ownerDocument: { defaultView: {} } }, + getClientRects: () => columnRects, + }; +}; + // A single visible section whose iframe is offset by `frameTop` along the scroll // axis (negative = scrolled down). `buildScrolledLineBoxes` walks these contents. const makeScrolledContents = ( @@ -299,6 +318,89 @@ describe('ReadingRuler', () => { expect(rulerTop()).toBeCloseTo(initialTop, 5); }); + // Regression: issue #4865 — for vertical-rl (Japanese vertical) books columns + // progress right-to-left, so advancing the ruler forward must move the band to + // the LEFT. It used to run backwards because rtl was false for vertical-rl. + it('advances the vertical-rl ruler band to the left on a forward move', async () => { + mockProgress = { range: makeVerticalColumnsRange(), pageinfo: { current: 0 } }; + const verticalSettings = { ...viewSettings } as ViewSettings; + + const { container } = render( + , + ); + const rulerLeft = () => + parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.left); + + // Mount snaps the band onto the first (rightmost) column, near the right edge. + let before = 0; + await waitFor(() => { + before = rulerLeft(); + expect(before).toBeGreaterThan(80); + }); + + const consumed = eventDispatcher.dispatchSync('reading-ruler-move', { + bookKey: 'book-1', + direction: 'forward', + }); + + expect(consumed).toBe(true); + // Forward => next column to the left => band's left percentage decreases. + await waitFor(() => { + expect(rulerLeft()).toBeLessThan(before); + }); + }); + + // The vertical-lr (Mongolian) counterpart moves the other way: forward => right. + it('advances the vertical-lr ruler band to the right on a forward move', async () => { + mockProgress = { range: makeVerticalColumnsRange(), pageinfo: { current: 0 } }; + const verticalSettings = { ...viewSettings } as ViewSettings; + + const { container } = render( + , + ); + const rulerLeft = () => + parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.left); + + // Mount snaps the band onto the first (leftmost) column, near the left edge. + let before = 100; + await waitFor(() => { + before = rulerLeft(); + expect(before).toBeLessThan(20); + }); + + const consumed = eventDispatcher.dispatchSync('reading-ruler-move', { + bookKey: 'book-1', + direction: 'forward', + }); + + expect(consumed).toBe(true); + await waitFor(() => { + expect(rulerLeft()).toBeGreaterThan(before); + }); + }); + it('still snaps the ruler to lines when advancing by click in scrolled mode', async () => { const lineRects = makeLineRects(50, 100, 40); mockProgress = { range: {}, pageinfo: { current: 0 } }; diff --git a/apps/readest-app/src/__tests__/components/StickyProgressBar.test.tsx b/apps/readest-app/src/__tests__/components/StickyProgressBar.test.tsx new file mode 100644 index 000000000..8f14283db --- /dev/null +++ b/apps/readest-app/src/__tests__/components/StickyProgressBar.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import StickyProgressBar from '@/app/reader/components/StickyProgressBar'; + +afterEach(cleanup); + +describe('StickyProgressBar', () => { + it('renders the fill width proportional to the reading fraction', () => { + const { container } = render(); + const fill = container.querySelector('.sticky-progress-fill') as HTMLElement; + expect(fill).not.toBeNull(); + expect(fill.style.width).toBe('50%'); + }); + + it('clamps the fraction to the 0..1 range', () => { + const over = render(); + expect((over.container.querySelector('.sticky-progress-fill') as HTMLElement).style.width).toBe( + '100%', + ); + cleanup(); + const under = render(); + expect( + (under.container.querySelector('.sticky-progress-fill') as HTMLElement).style.width, + ).toBe('0%'); + }); + + it('outlines the bar with a thin (1px) rounded border', () => { + const { container } = render(); + const track = container.querySelector('.sticky-progress-track') as HTMLElement; + expect(track).not.toBeNull(); + expect(track.classList.contains('border')).toBe(true); + expect(track.classList.contains('rounded-full')).toBe(true); + }); + + it('renders the ticks inside the clipping track so the rounded border crops them', () => { + const { container } = render(); + const track = container.querySelector('.sticky-progress-track') as HTMLElement; + expect(track.classList.contains('overflow-hidden')).toBe(true); + // Ticks must be descendants of the rounded, clipped track — not siblings — + // so ticks near the rounded ends are cropped and never exceed the border. + expect(track.querySelectorAll('.sticky-progress-tick').length).toBe(2); + }); + + it('renders one tick per chapter boundary at its start-edge position (LTR)', () => { + const { container } = render(); + const ticks = container.querySelectorAll('.sticky-progress-tick'); + expect(ticks.length).toBe(2); + expect((ticks[0] as HTMLElement).style.left).toBe('25%'); + expect((ticks[1] as HTMLElement).style.left).toBe('75%'); + }); + + it('positions fill and ticks from the right edge in RTL', () => { + const { container } = render(); + const fill = container.querySelector('.sticky-progress-fill') as HTMLElement; + expect(fill.style.right).toBe('0px'); + expect(fill.style.left).toBe(''); + const tick = container.querySelector('.sticky-progress-tick') as HTMLElement; + expect(tick.style.right).toBe('25%'); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/footer-band.test.ts b/apps/readest-app/src/__tests__/components/footer-band.test.ts new file mode 100644 index 000000000..ab66d1946 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/footer-band.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { footerInfoVisible, footerReservesBand } from '@/app/reader/utils/footerBand'; +import { DEFAULT_VIEW_CONFIG } from '@/services/constants'; +import type { ViewSettings } from '@/types/book'; + +// The book layout reserves a full-width bottom band (marginBottomPx of page +// margin / scroll padding) for the footer. That band read as a "solid bar" +// across the bottom of the screen: in scrolled mode the text clipped hard at +// its edge, and it lingered even when the footer had nothing to show. Rules: +// - scrolled mode never reserves the band; the info floats over the text +// in shrink-wrapped pills instead +// - paginated mode reserves it only while some widget is enabled +// - the sticky progress bar (always-visible, display-only) keeps its band +// Turning Show Footer off (Settings -> Layout) unmounts the footer and +// releases the band through the showFooter gate. +const settings = (overrides: Partial): ViewSettings => + ({ ...DEFAULT_VIEW_CONFIG, ...overrides }) as ViewSettings; + +describe('footerInfoVisible', () => { + it('is true with default settings (progress info shown)', () => { + expect(footerInfoVisible(settings({}))).toBe(true); + }); + + it('is false when every footer widget is disabled in settings', () => { + expect( + footerInfoVisible( + settings({ + showRemainingTime: false, + showRemainingPages: false, + showProgressInfo: false, + showCurrentTime: false, + showCurrentBatteryStatus: false, + }), + ), + ).toBe(false); + }); + + it('is true when any single widget is enabled', () => { + expect( + footerInfoVisible( + settings({ + showRemainingTime: false, + showRemainingPages: true, + showProgressInfo: false, + showCurrentTime: false, + showCurrentBatteryStatus: false, + }), + ), + ).toBe(true); + }); +}); + +describe('footerReservesBand', () => { + it('is false when Show Footer is off', () => { + expect(footerReservesBand(settings({ showFooter: false }))).toBe(false); + }); + + it('reserves the band in paginated mode while info is visible', () => { + expect(footerReservesBand(settings({ showFooter: true, scrolled: false }))).toBe(true); + }); + + it('releases the band in paginated mode when no widget is enabled', () => { + expect( + footerReservesBand( + settings({ + showFooter: true, + scrolled: false, + showRemainingTime: false, + showRemainingPages: false, + showProgressInfo: false, + showCurrentTime: false, + showCurrentBatteryStatus: false, + }), + ), + ).toBe(false); + }); + + it('never reserves the band in scrolled mode, even with info visible', () => { + expect(footerReservesBand(settings({ showFooter: true, scrolled: true }))).toBe(false); + }); + + it('keeps the band for the sticky progress bar, scrolled or not', () => { + expect( + footerReservesBand( + settings({ showFooter: true, scrolled: true, showStickyProgressBar: true }), + ), + ).toBe(true); + expect( + footerReservesBand( + settings({ + showFooter: true, + scrolled: false, + showStickyProgressBar: true, + showRemainingTime: false, + showRemainingPages: false, + showProgressInfo: false, + showCurrentTime: false, + showCurrentBatteryStatus: false, + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/library/NowPlayingBar.test.tsx b/apps/readest-app/src/__tests__/components/library/NowPlayingBar.test.tsx new file mode 100644 index 000000000..bfb2e7f5c --- /dev/null +++ b/apps/readest-app/src/__tests__/components/library/NowPlayingBar.test.tsx @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; + +const pushMock = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: pushMock }), +})); + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +vi.mock('@/store/themeStore', () => ({ + useThemeStore: () => ({ safeAreaInsets: { top: 0, right: 0, bottom: 20, left: 0 } }), +})); + +const getBookData = vi.fn(); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData }), +})); + +const navigateToReader = vi.fn(); +vi.mock('@/utils/nav', () => ({ + navigateToReader: (...args: unknown[]) => navigateToReader(...args), +})); + +const mockManager = vi.hoisted(() => { + class MockSessionManager extends EventTarget { + session: { + bookHash: string; + bookKey: string; + controller: { + state: string; + pause: ReturnType; + start: ReturnType; + }; + } | null = null; + sleepTimer: { timeoutSec: number; firesAt: number } | null = null; + stopActive = vi.fn().mockResolvedValue(undefined); + getActiveSession() { + return this.session; + } + getSleepTimer() { + return this.sleepTimer; + } + emitSessionChanged(reason: string) { + this.dispatchEvent( + new CustomEvent('session-changed', { detail: { session: this.session, reason } }), + ); + } + } + return new MockSessionManager(); +}); +vi.mock('@/services/tts', () => ({ + ttsSessionManager: mockManager, +})); + +import { eventDispatcher } from '@/utils/event'; +import NowPlayingBar from '@/app/library/components/NowPlayingBar'; + +const makeSession = (state = 'playing') => ({ + bookHash: 'hashA', + bookKey: 'hashA-r1', + controller: { + state, + pause: vi.fn().mockResolvedValue(true), + start: vi.fn().mockResolvedValue(undefined), + }, +}); + +describe('NowPlayingBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockManager.session = null; + mockManager.sleepTimer = null; + getBookData.mockReturnValue({ + book: { title: 'Alice in Wonderland', coverImageUrl: null }, + }); + }); + + afterEach(() => { + cleanup(); + }); + + test('renders nothing without an active session', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('shows the book title while a session is active', () => { + mockManager.session = makeSession(); + render(); + expect(screen.getByText('Alice in Wonderland')).toBeTruthy(); + expect(screen.getByRole('status')).toBeTruthy(); + }); + + test('hides in select mode even with an active session', () => { + mockManager.session = makeSession(); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('play/pause button drives the session controller without opening the book', () => { + const session = makeSession('playing'); + mockManager.session = session; + render(); + fireEvent.click(screen.getByLabelText('Pause')); + expect(session.controller.pause).toHaveBeenCalled(); + expect(navigateToReader).not.toHaveBeenCalled(); + }); + + test('glyph follows the relayed tts-playback-state channel', async () => { + mockManager.session = makeSession('playing'); + render(); + expect(screen.getByLabelText('Pause')).toBeTruthy(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: 'hashA-r1', + state: 'paused', + }); + }); + expect(screen.getByLabelText('Play')).toBeTruthy(); + }); + + test('stop hides the bar optimistically and stops the session', () => { + mockManager.session = makeSession(); + const { container } = render(); + fireEvent.click(screen.getByLabelText('Stop reading aloud')); + expect(mockManager.stopActive).toHaveBeenCalledWith('user'); + expect(container.firstChild).toBeNull(); + }); + + test('tapping the body opens the book in the SAME window', () => { + mockManager.session = makeSession(); + render(); + fireEvent.click(screen.getByText('Alice in Wonderland')); + expect(navigateToReader).toHaveBeenCalledWith(expect.anything(), ['hashA']); + }); + + test('disappears when the manager reports the session stopped', () => { + mockManager.session = makeSession(); + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + act(() => { + mockManager.session = null; + mockManager.emitSessionChanged('stopped'); + }); + expect(container.firstChild).toBeNull(); + }); + + test('shows a sleep timer countdown chip when a timer is armed', () => { + mockManager.session = makeSession(); + mockManager.sleepTimer = { timeoutSec: 600, firesAt: Date.now() + 90_000 }; + render(); + expect(screen.getByText(/^1:(2\d|30)$/)).toBeTruthy(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/settings/AnnotationToolbarCustomizerEink.test.tsx b/apps/readest-app/src/__tests__/components/settings/AnnotationToolbarCustomizerEink.test.tsx new file mode 100644 index 000000000..9c8a5eaf8 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/settings/AnnotationToolbarCustomizerEink.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; + +/** + * Regression guard for issue #4839 (display error under e-ink mode). + * + * The Customize Toolbar sub-page renders a content-width *preview* of the live + * selection popup. The preview surface uses `bg-gray-600 text-white` to mirror + * the real popup — but unlike the real popup (which earns its e-ink treatment + * from `.popup-container` in globals.css), the preview Zone is a plain div. With + * no e-ink override, the dark fill survives under `[data-eink='true']` and the + * whole row paints as an unreadable solid black bar. + * + * Invariant: the toolbar preview must carry `eink-bordered`, so e-ink swaps the + * dark fill for a `base-100` surface with a 1px `base-content` border — matching + * how the annotation toolbar renders in the reader. + */ + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: {}, appService: {} }), +})); + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ getViewSettings: () => undefined }), +})); + +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: () => ({ + settings: { globalViewSettings: { annotationToolbarItems: undefined } }, + }), +})); + +vi.mock('@/helpers/settings', () => ({ + saveViewSettings: vi.fn(), +})); + +vi.mock('@/utils/share', () => ({ + canShareText: () => true, +})); + +import AnnotationToolbarCustomizer from '@/components/settings/AnnotationToolbarCustomizer'; + +afterEach(() => { + cleanup(); +}); + +describe('AnnotationToolbarCustomizer e-ink toolbar preview', () => { + it('marks the toolbar preview eink-bordered so e-ink swaps the dark fill for a bordered base-100 surface', () => { + const { container } = render( {}} />); + const toolbarPreview = container.querySelector('.selection-popup') as HTMLElement; + expect(toolbarPreview).not.toBeNull(); + expect(toolbarPreview.classList.contains('eink-bordered')).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/settings/ThemeModeSelector.test.tsx b/apps/readest-app/src/__tests__/components/settings/ThemeModeSelector.test.tsx new file mode 100644 index 000000000..d6342cc8f --- /dev/null +++ b/apps/readest-app/src/__tests__/components/settings/ThemeModeSelector.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, screen } from '@testing-library/react'; + +/** + * Redesign guard for issue #4831 (theme switcher hit targets & spacing). + * + * The three theme-mode toggles used to be tiny `btn-circle btn-sm` icons + * separated by `gap-4`, so on mobile they were both hard to hit and easy to + * mis-hit. They are now a segmented control: an ARIA `radiogroup` of three + * adjacent `radio` segments, each with a comfortable tap target and no dead + * space between them. + */ + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +vi.mock('@/store/atmosphereStore', () => ({ + useAtmosphereStore: () => ({ + spinDirection: null, + shaking: false, + toggle: vi.fn(), + toggleWithShake: vi.fn(), + deactivate: vi.fn(), + }), +})); + +import ThemeModeSelector from '@/components/settings/color/ThemeModeSelector'; + +afterEach(() => cleanup()); + +describe('ThemeModeSelector segmented control', () => { + it('renders the three modes as a radiogroup of radio segments', () => { + render( {}} />); + + expect(screen.getByRole('radiogroup')).not.toBeNull(); + const segments = screen.getAllByRole('radio'); + expect(segments).toHaveLength(3); + }); + + it('marks the active segment via aria-checked', () => { + render( {}} />); + + expect(screen.getByRole('radio', { name: 'Dark Mode' }).getAttribute('aria-checked')).toBe( + 'true', + ); + expect(screen.getByRole('radio', { name: 'Light Mode' }).getAttribute('aria-checked')).toBe( + 'false', + ); + expect(screen.getByRole('radio', { name: 'Auto Mode' }).getAttribute('aria-checked')).toBe( + 'false', + ); + }); + + it('switches mode when an inactive segment is clicked', () => { + const onThemeModeChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('radio', { name: 'Auto Mode' })); + expect(onThemeModeChange).toHaveBeenCalledWith('auto'); + }); + + it('gives each segment a mobile-friendly tap target, not a tiny circle icon', () => { + render( {}} />); + + for (const segment of screen.getAllByRole('radio')) { + expect(segment.className).toContain('min-w-[2.75rem]'); + expect(segment.className).toContain('h-9'); + expect(segment.className).not.toContain('btn-circle'); + expect(segment.className).not.toContain('btn-sm'); + } + }); + + it('marks the active segment for e-ink with a solid fill, not a nested border', () => { + render( {}} />); + + // The track carries the outer e-ink border... + expect(screen.getByRole('radiogroup').className).toContain('eink-bordered'); + // ...and the active thumb inverts (solid base-content fill) rather than + // adding a second border that would nest inside the track's outline. + const active = screen.getByRole('radio', { name: 'Light Mode' }); + expect(active.className).toContain('eink-inverted'); + expect(active.className).not.toContain('eink-bordered'); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts new file mode 100644 index 000000000..e4e8245e4 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from 'vitest'; +import { withActiveCloudProvider } from '@/components/settings/integrations/cloudSync'; +import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings'; +import type { WebDAVSettings } from '@/types/settings'; +import { CLOUD_SYNC_REQUIRES_PREMIUM, isCloudSyncAllowed, isCloudSyncInPlan } from '@/utils/access'; +import type { SystemSettings } from '@/types/settings'; + +const base = { + webdav: { enabled: true, serverUrl: 'https://dav', username: 'u', password: 'p', rootPath: '/' }, + googleDrive: { enabled: true, accountLabel: 'a@b.com' }, +} as unknown as SystemSettings; + +describe('withActiveCloudProvider', () => { + test('enabling WebDAV disables Google Drive (exclusive)', () => { + const next = withActiveCloudProvider(base, 'webdav'); + expect(next.webdav.enabled).toBe(true); + expect(next.googleDrive.enabled).toBe(false); + }); + + test('enabling Google Drive disables WebDAV (exclusive)', () => { + const next = withActiveCloudProvider(base, 'gdrive'); + expect(next.webdav.enabled).toBe(false); + expect(next.googleDrive.enabled).toBe(true); + }); + + test('enabling S3 disables WebDAV and Google Drive (exclusive)', () => { + const withS3 = { + ...base, + s3: { enabled: false, endpoint: 'https://acc.r2.cloudflarestorage.com', bucket: 'b' }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(withS3, 's3'); + expect(next.s3.enabled).toBe(true); + expect(next.webdav.enabled).toBe(false); + expect(next.googleDrive.enabled).toBe(false); + // Activation hands S3 the book-file channel and anchors fleet detection. + expect(next.s3.syncBooks).toBe(true); + expect(next.s3.providerSelectedAt).toBeTruthy(); + // Config survives deactivation elsewhere; endpoint untouched here. + expect(next.s3.endpoint).toBe('https://acc.r2.cloudflarestorage.com'); + }); + + test('enabling WebDAV disables S3 (exclusive)', () => { + const withS3 = { ...base, s3: { enabled: true } } as unknown as SystemSettings; + const next = withActiveCloudProvider(withS3, 'webdav'); + expect(next.s3.enabled).toBe(false); + expect(next.webdav.enabled).toBe(true); + }); + + test('enabling OneDrive disables WebDAV, Google Drive, and S3 (exclusive)', () => { + const withOneDrive = { + ...base, + s3: { enabled: true }, + onedrive: { enabled: false }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(withOneDrive, 'onedrive'); + expect(next.onedrive.enabled).toBe(true); + expect(next.webdav.enabled).toBe(false); + expect(next.googleDrive.enabled).toBe(false); + expect(next.s3.enabled).toBe(false); + // Activation hands OneDrive the book-file channel and anchors fleet detection. + expect(next.onedrive.syncBooks).toBe(true); + expect(next.onedrive.providerSelectedAt).toBeTruthy(); + }); + + test('enabling WebDAV disables OneDrive (exclusive)', () => { + const withOneDrive = { ...base, onedrive: { enabled: true } } as unknown as SystemSettings; + const next = withActiveCloudProvider(withOneDrive, 'webdav'); + expect(next.onedrive.enabled).toBe(false); + expect(next.webdav.enabled).toBe(true); + }); + + test('null disables both', () => { + const next = withActiveCloudProvider(base, null); + expect(next.webdav.enabled).toBe(false); + expect(next.googleDrive.enabled).toBe(false); + }); + + test("'readest' behaves as deactivation of both third-party providers", () => { + const next = withActiveCloudProvider(base, 'readest'); + expect(next.webdav.enabled).toBe(false); + expect(next.googleDrive.enabled).toBe(false); + // Config survives so switching back needs no re-entry. + expect(next.webdav.serverUrl).toBe('https://dav'); + expect(next.googleDrive.accountLabel).toBe('a@b.com'); + }); + + test('leaves the rest of each provider config untouched', () => { + const next = withActiveCloudProvider(base, 'gdrive'); + expect(next.webdav.serverUrl).toBe('https://dav'); + expect(next.googleDrive.accountLabel).toBe('a@b.com'); + }); + + // Selecting a third-party provider hands it the book-file channel: + // native Readest Cloud uploads gate off, so without syncBooks the books + // would back up nowhere. Activation therefore turns syncBooks on. + describe('syncBooks auto-enable on activation', () => { + const inactive = { + webdav: { enabled: false, serverUrl: 'https://dav', syncBooks: false }, + googleDrive: { enabled: false, syncBooks: false }, + } as unknown as SystemSettings; + + test('activating a disabled provider turns its syncBooks on', () => { + const next = withActiveCloudProvider(inactive, 'webdav'); + expect(next.webdav.syncBooks).toBe(true); + expect(next.googleDrive.syncBooks).toBe(false); + }); + + test('activating gdrive turns only gdrive syncBooks on', () => { + const next = withActiveCloudProvider(inactive, 'gdrive'); + expect(next.googleDrive.syncBooks).toBe(true); + expect(next.webdav.syncBooks).toBe(false); + }); + + test('re-activating an already-active provider respects an explicit syncBooks opt-out', () => { + const active = { + webdav: { enabled: true, syncBooks: false }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(active, 'webdav'); + expect(next.webdav.syncBooks).toBe(false); + }); + + test('deactivating a provider leaves its syncBooks untouched', () => { + const active = { + webdav: { enabled: true, syncBooks: true }, + googleDrive: { enabled: false, syncBooks: false }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(active, null); + expect(next.webdav.syncBooks).toBe(true); + }); + + test('fresh WebDAV connect flow (builder + activation) auto-enables syncBooks', () => { + // Regression: the builder must not pre-set `enabled`, or the + // activation never sees a disabled -> enabled transition and the + // most common path keeps the books-backed-up-nowhere default. + const previous = { enabled: false, syncBooks: false } as WebDAVSettings; + const connected = { + webdav: buildWebDAVConnectSettings(previous, { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Readest', + }), + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(connected, 'webdav'); + expect(next.webdav.enabled).toBe(true); + expect(next.webdav.syncBooks).toBe(true); + }); + }); + + describe('providerSelectedAt stamp (mixed-fleet detection anchor)', () => { + const inactive = { + webdav: { enabled: false }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + + test('stamps the newly-activated provider only', () => { + const next = withActiveCloudProvider(inactive, 'webdav'); + expect(typeof next.webdav.providerSelectedAt).toBe('number'); + expect(next.webdav.providerSelectedAt!).toBeGreaterThan(0); + expect(next.googleDrive.providerSelectedAt).toBeUndefined(); + }); + + test('does not re-stamp an already-active provider', () => { + const active = { + webdav: { enabled: true, providerSelectedAt: 111 }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + expect(withActiveCloudProvider(active, 'webdav').webdav.providerSelectedAt).toBe(111); + }); + + test('deactivation leaves the stamp untouched', () => { + const active = { + webdav: { enabled: true, providerSelectedAt: 111 }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + expect(withActiveCloudProvider(active, null).webdav.providerSelectedAt).toBe(111); + }); + }); +}); + +describe('isCloudSyncInPlan', () => { + test('any paid plan can use cloud sync', () => { + expect(isCloudSyncInPlan('plus')).toBe(true); + expect(isCloudSyncInPlan('pro')).toBe(true); + expect(isCloudSyncInPlan('purchase')).toBe(true); // lifetime + }); + + test('free plan cannot', () => { + expect(isCloudSyncInPlan('free')).toBe(false); + }); +}); + +describe('isCloudSyncAllowed (premium paywall)', () => { + test('third-party cloud sync requires a paid plan', () => { + expect(CLOUD_SYNC_REQUIRES_PREMIUM).toBe(true); + expect(isCloudSyncAllowed('free')).toBe(false); + expect(isCloudSyncAllowed('plus')).toBe(true); + expect(isCloudSyncAllowed('pro')).toBe(true); + expect(isCloudSyncAllowed('purchase')).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/settings/cloudSyncStatus.test.ts b/apps/readest-app/src/__tests__/components/settings/cloudSyncStatus.test.ts new file mode 100644 index 000000000..2a6fcb8ae --- /dev/null +++ b/apps/readest-app/src/__tests__/components/settings/cloudSyncStatus.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'vitest'; +import { + getReadestCloudRowStatus, + getThirdPartyRowStatus, +} from '@/components/settings/integrations/cloudSyncStatus'; + +const _ = (key: string) => key; + +describe('getReadestCloudRowStatus', () => { + test('signed out wins over everything', () => { + expect( + getReadestCloudRowStatus(_, { signedIn: false, planLoading: true, selected: true }), + ).toBe('Not signed in'); + }); + + test('loading while the plan resolves', () => { + expect(getReadestCloudRowStatus(_, { signedIn: true, planLoading: true, selected: true })).toBe( + '…', + ); + }); + + test('active when signed in and selected', () => { + expect( + getReadestCloudRowStatus(_, { signedIn: true, planLoading: false, selected: true }), + ).toBe('Active'); + }); + + test('available when a third-party provider is selected instead', () => { + expect( + getReadestCloudRowStatus(_, { signedIn: true, planLoading: false, selected: false }), + ).toBe('Available'); + }); +}); + +describe('getThirdPartyRowStatus', () => { + const base = { + enabled: true, + configured: true, + syncing: false, + paused: false, + lastError: null, + syncBooks: true, + }; + + test('not connected / configured when inactive', () => { + expect(getThirdPartyRowStatus(_, { ...base, enabled: false, configured: false })).toBe( + 'Not connected', + ); + expect(getThirdPartyRowStatus(_, { ...base, enabled: false })).toBe('Configured'); + }); + + test('paused outranks syncing, errors, and warnings', () => { + expect( + getThirdPartyRowStatus(_, { ...base, paused: true, syncing: true, lastError: 'x' }), + ).toBe('Paused — plan required'); + }); + + test('syncing while a run is in flight', () => { + expect(getThirdPartyRowStatus(_, { ...base, syncing: true })).toBe('Syncing…'); + }); + + test('sync failed after a terminal error', () => { + expect(getThirdPartyRowStatus(_, { ...base, lastError: 'AUTH_FAILED' })).toBe('Sync failed'); + }); + + test('warns when book file uploads are off (books back up nowhere)', () => { + expect(getThirdPartyRowStatus(_, { ...base, syncBooks: false })).toBe( + 'Active · Book file uploads off', + ); + }); + + test('healthy active state', () => { + expect(getThirdPartyRowStatus(_, base)).toBe('Active'); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/settings/fileSyncFormSyncNow.test.tsx b/apps/readest-app/src/__tests__/components/settings/fileSyncFormSyncNow.test.tsx new file mode 100644 index 000000000..bd6e748df --- /dev/null +++ b/apps/readest-app/src/__tests__/components/settings/fileSyncFormSyncNow.test.tsx @@ -0,0 +1,97 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { SystemSettings } from '@/types/settings'; +import { useFileSyncStore } from '@/store/fileSyncStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useSettingsStore } from '@/store/settingsStore'; + +/** + * The manual "Sync now" must keep the provider health surfaces honest: the + * Cloud Sync chooser row and the SettingsMenu sync row read + * `lastErrorByKind`, so a completed manual run has to clear a stale error + * (server restarted, sync works again → "Sync failed" must not stick) and a + * failed one has to record it. + */ + +const syncLibrary = vi.fn(); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ + envConfig: { getAppService: async () => ({ loadLibraryBooks: async () => [] }) }, + appService: null, + }), +})); + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +vi.mock('@/services/sync/file/providerRegistry', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createFileSyncProvider: vi.fn(async () => ({}) as never) }; +}); + +vi.mock('@/services/sync/file/appLocalStore', () => ({ + createAppLocalStore: vi.fn(() => ({}) as never), +})); + +vi.mock('@/services/sync/file/engine', () => ({ + FileSyncEngine: vi.fn(function (this: Record) { + this['syncLibrary'] = syncLibrary; + }), +})); + +import FileSyncForm from '@/components/settings/integrations/FileSyncForm'; + +const stored = { + enabled: true, + deviceId: 'd1', + strategy: 'silent' as const, + syncBooks: false, +}; + +const renderForm = () => + render( {})} />); + +beforeEach(() => { + vi.clearAllMocks(); + syncLibrary.mockResolvedValue({ booksSynced: 1, failures: 0, totalBooks: 1, failedBooks: [] }); + useSettingsStore.setState({ + settings: { webdav: stored } as unknown as SystemSettings, + } as never); + useLibraryStore.setState({ library: [], libraryLoaded: true } as never); + useFileSyncStore.setState({ byKind: {}, activeKind: null, lastErrorByKind: {} }); +}); + +afterEach(() => { + cleanup(); +}); + +describe('FileSyncForm — Sync now health reporting', () => { + test('a completed run clears a stale lastError', async () => { + useFileSyncStore.getState().setLastError('webdav', 'server unreachable'); + renderForm(); + + fireEvent.click(screen.getByRole('button', { name: 'Sync now' })); + + await waitFor(() => { + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBeNull(); + }); + expect(syncLibrary).toHaveBeenCalledTimes(1); + // Mutex released. + expect(useFileSyncStore.getState().byKind.webdav?.isSyncing ?? false).toBe(false); + }); + + test('a failed run records lastError for the health surfaces', async () => { + syncLibrary.mockRejectedValueOnce(new Error('boom')); + renderForm(); + + fireEvent.click(screen.getByRole('button', { name: 'Sync now' })); + + await waitFor(() => { + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBe('boom'); + }); + expect(useFileSyncStore.getState().byKind.webdav?.isSyncing ?? false).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/GapChips.test.tsx b/apps/readest-app/src/__tests__/components/tts/GapChips.test.tsx new file mode 100644 index 000000000..c6271684b --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/GapChips.test.tsx @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +import GapChips from '@/app/reader/components/tts/GapChips'; + +describe('GapChips', () => { + afterEach(() => { + cleanup(); + }); + + test('renders the presets and marks the active gap', () => { + render(); + const active = screen.getByRole('radio', { checked: true }); + expect(active.textContent).toBe('0.15s'); + expect(screen.getAllByRole('radio').length).toBe(6); + }); + + test('an off-preset persisted gap appears as an extra active chip in order', () => { + render(); + const chips = screen.getAllByRole('radio'); + expect(chips.length).toBe(7); + const labels = chips.map((c) => c.textContent); + expect(labels.indexOf('0.2s')).toBe(labels.indexOf('0.15s') + 1); + expect(screen.getByRole('radio', { checked: true }).textContent).toBe('0.2s'); + }); + + test('selecting a chip reports the numeric gap', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole('radio', { name: '0.4s' })); + expect(onSelect).toHaveBeenCalledWith(0.4); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/SpeedChips.test.tsx b/apps/readest-app/src/__tests__/components/tts/SpeedChips.test.tsx new file mode 100644 index 000000000..c55a1f972 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/SpeedChips.test.tsx @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +import SpeedChips from '@/app/reader/components/tts/SpeedChips'; + +describe('SpeedChips', () => { + afterEach(() => { + cleanup(); + }); + + test('renders the presets and marks the active rate', () => { + render(); + const active = screen.getByRole('radio', { checked: true }); + expect(active.textContent).toBe('1×'); + expect(screen.getAllByRole('radio').length).toBe(11); + }); + + test('an off-preset persisted rate appears as an extra active chip in order', () => { + render(); + const chips = screen.getAllByRole('radio'); + expect(chips.length).toBe(12); + const labels = chips.map((c) => c.textContent); + expect(labels.indexOf('1.3×')).toBe(labels.indexOf('1.25×') + 1); + expect(screen.getByRole('radio', { checked: true }).textContent).toBe('1.3×'); + }); + + test('selecting a chip reports the numeric rate', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole('radio', { name: '1.5×' })); + expect(onSelect).toHaveBeenCalledWith(1.5); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSControl.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSControl.test.tsx new file mode 100644 index 000000000..5daba7245 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSControl.test.tsx @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +vi.mock('@/store/themeStore', () => ({ + useThemeStore: () => ({ safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 } }), +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + hoveredBookKey: '', + getViewSettings: () => ({ isEink: false, rtl: false }), + }), +})); + +const ttsState: Record = {}; +vi.mock('@/app/reader/hooks/useTTSControl', () => ({ + useTTSControl: () => ttsState, +})); + +vi.mock('@/app/reader/components/tts/TTSMiniPlayer', () => ({ + __esModule: true, + TTS_MINI_PLAYER_CLEARANCE: 64, + default: ({ onExpand }: { onExpand: () => void }) => ( +
+ ), +})); + +vi.mock('@/app/reader/components/tts/TTSPlayerSheet', () => ({ + __esModule: true, + default: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null, +})); + +import TTSControl from '@/app/reader/components/tts/TTSControl'; + +const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +describe('TTSControl', () => { + beforeEach(() => { + Object.assign(ttsState, { + isPlaying: true, + ttsLang: 'en', + ttsClientsInited: true, + showIndicator: true, + showBackToCurrentTTSLocation: false, + timeoutOption: 0, + timeoutTimestamp: 0, + chapterRemainingSec: null, + handleTogglePlay: vi.fn(), + handleBackward: vi.fn(), + handleForward: vi.fn(), + handleSetRate: vi.fn(), + handleGetVoices: vi.fn(), + handleSetVoice: vi.fn(), + handleGetVoiceId: vi.fn().mockReturnValue(''), + handleSelectTimeout: vi.fn(), + handleBackToCurrentTTSLocation: vi.fn(), + handleSeekTo: vi.fn(), + handleGetPlaybackInfo: vi.fn().mockReturnValue(null), + handleSetSentenceGap: vi.fn(), + handleSupportsPlaybackInfo: vi.fn().mockReturnValue(true), + handleSupportsGapControl: vi.fn().mockReturnValue(false), + refreshTtsLang: vi.fn(), + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('mounts the mini player while a session is active', () => { + render(); + expect(screen.getByTestId('mini-player')).toBeTruthy(); + expect(screen.queryByTestId('player-sheet')).toBeNull(); + }); + + test('renders nothing before the clients are initialized', () => { + Object.assign(ttsState, { showIndicator: false, ttsClientsInited: false }); + render(); + expect(screen.queryByTestId('mini-player')).toBeNull(); + expect(screen.queryByTestId('player-sheet')).toBeNull(); + }); + + test('expanding the mini player opens the sheet and hides the mini player', () => { + render(); + fireEvent.click(screen.getByTestId('mini-player')); + expect(screen.getByTestId('player-sheet')).toBeTruthy(); + // The two surfaces never show at the same time. + expect(screen.queryByTestId('mini-player')).toBeNull(); + }); + + test('shows the back-to-TTS-location pill when reading has drifted', () => { + Object.assign(ttsState, { showBackToCurrentTTSLocation: true }); + render(); + expect(screen.getByText('Back to TTS Location')).toBeTruthy(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSMiniPlayer.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSMiniPlayer.test.tsx new file mode 100644 index 000000000..a79c5aff6 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSMiniPlayer.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string, opts?: Record) => + opts ? Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) : key, +})); + +vi.mock('@/hooks/useResponsiveSize', () => ({ + useResponsiveSize: (size: number) => size, +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ + appService: { isMobile: false, hasSafeAreaInset: false }, + }), +})); + +const readerState = { hoveredBookKey: '', setHoveredBookKey: vi.fn() }; +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => readerState, +})); + +vi.mock('@/store/readerProgressStore', () => ({ + useBookProgress: () => ({ sectionLabel: 'Chapter 5' }), +})); + +const getBookData = vi.fn(); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData }), +})); + +import TTSMiniPlayer from '@/app/reader/components/tts/TTSMiniPlayer'; + +const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +const makeProps = (overrides: Record = {}) => ({ + bookKey: 'b1', + isPlaying: true, + isEink: false, + hasTimeline: true, + timeoutTimestamp: 0, + chapterRemainingSec: null as number | null, + gridInsets, + onTogglePlay: vi.fn(), + onBackward: vi.fn(), + onForward: vi.fn(), + onStop: vi.fn(), + onExpand: vi.fn(), + onGetPlaybackInfo: vi + .fn() + .mockReturnValue({ position: 10, duration: 100, measuredFraction: 0.4 }), + ...overrides, +}); + +describe('TTSMiniPlayer', () => { + beforeEach(() => { + readerState.hoveredBookKey = ''; + getBookData.mockReturnValue({ book: { title: 'Alice in Wonderland', coverImageUrl: null } }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('shows title, section label, and elapsed/remaining time', () => { + render(); + expect(screen.getByText('Alice in Wonderland')).toBeTruthy(); + expect(screen.getByText(/Chapter 5/)).toBeTruthy(); + expect(screen.getByText(/0:10/)).toBeTruthy(); + expect(screen.getByText(/-1:30/)).toBeTruthy(); + }); + + test('sentence skips and play/pause drive the transport callbacks', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Previous Sentence')); + expect(props.onBackward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Next Sentence')); + expect(props.onForward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Pause')); + expect(props.onTogglePlay).toHaveBeenCalled(); + expect(screen.getByLabelText('Next Sentence').closest('[dir="ltr"]')).toBeTruthy(); + }); + + test('stop button stops without expanding', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Stop reading aloud')); + expect(props.onStop).toHaveBeenCalled(); + expect(props.onExpand).not.toHaveBeenCalled(); + }); + + test('tapping the body expands the player sheet', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByText('Alice in Wonderland')); + expect(props.onExpand).toHaveBeenCalled(); + }); + + test('fades out while the footer bar is up for this book', () => { + readerState.hoveredBookKey = 'b1'; + render(); + expect(screen.getByRole('status').className).toContain('pointer-events-none'); + }); + + test('without a timeline shows the estimated chapter remaining instead', () => { + render( + , + ); + expect(screen.getByText(/5:00 left in chapter/)).toBeTruthy(); + expect(screen.queryByText(/-1:30/)).toBeNull(); + }); + + test('shows a countdown chip while a sleep timer is armed', () => { + vi.useFakeTimers(); + render(); + expect(screen.getByText(/^1:(2\d|30)$/)).toBeTruthy(); + vi.useRealTimers(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSPlayerSheet.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSPlayerSheet.test.tsx new file mode 100644 index 000000000..647ab69f0 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSPlayerSheet.test.tsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string, opts?: Record) => + opts ? Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) : key, +})); + +vi.mock('@/hooks/useResponsiveSize', () => ({ + useResponsiveSize: (size: number) => size, + useDefaultIconSize: () => 24, +})); + +vi.mock('@/components/Dialog', () => ({ + default: ({ + isOpen, + header, + children, + }: { + isOpen: boolean; + header?: React.ReactNode; + children: React.ReactNode; + }) => + isOpen ? ( +
+ {header} + {children} +
+ ) : null, +})); + +const envConfig = {}; +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig, appService: { hasHaptics: false } }), +})); + +const viewSettings: Record = {}; +const setViewSettings = vi.fn(); +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + getViewSettings: () => viewSettings, + setViewSettings, + }), +})); + +const settings = { globalViewSettings: { ttsRate: 1.0, ttsSentenceGap: 0.15 } }; +const saveSettings = vi.fn(); +const settingsState = { settings, setSettings: vi.fn(), saveSettings }; +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: Object.assign(() => settingsState, { getState: () => settingsState }), +})); + +const getBookData = vi.fn(); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData }), +})); + +vi.mock('@/store/readerProgressStore', () => ({ + useBookProgress: () => ({ sectionLabel: 'Chapter 5' }), +})); + +import TTSPlayerSheet from '@/app/reader/components/tts/TTSPlayerSheet'; + +const voiceGroups = [ + { + id: 'edge', + name: 'Edge TTS', + voices: [ + { id: 'ava', name: 'Ava', lang: 'en-US', disabled: false }, + { id: 'guy', name: 'Guy', lang: 'en-US', disabled: false }, + ], + }, +]; + +const makeProps = (overrides: Record = {}) => ({ + bookKey: 'b1', + isOpen: true, + ttsLang: 'en', + isPlaying: true, + hasTimeline: true, + hasGapControl: false, + timeoutOption: 0, + timeoutTimestamp: 0, + chapterRemainingSec: null as number | null, + onClose: vi.fn(), + onTogglePlay: vi.fn(), + onBackward: vi.fn(), + onForward: vi.fn(), + onSetRate: vi.fn(), + onSetSentenceGap: vi.fn(), + onSetParagraphGap: vi.fn(), + onGetVoices: vi.fn().mockResolvedValue(voiceGroups), + onSetVoice: vi.fn(), + onGetVoiceId: vi.fn().mockReturnValue('ava'), + onSelectTimeout: vi.fn(), + onSeek: vi.fn().mockResolvedValue(undefined), + onGetPlaybackInfo: vi + .fn() + .mockReturnValue({ position: 10, duration: 100, measuredFraction: 0.4 }), + ...overrides, +}); + +describe('TTSPlayerSheet', () => { + beforeEach(() => { + viewSettings['ttsRate'] = 1.0; + viewSettings['ttsSentenceGap'] = 0.15; + viewSettings['isEink'] = false; + getBookData.mockReturnValue({ + book: { title: 'Alice in Wonderland', coverImageUrl: null }, + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('shows title, chapter, scrubber, and transport on the main view', async () => { + render(); + expect(screen.getByText('Alice in Wonderland')).toBeTruthy(); + expect(screen.getByText('Chapter 5')).toBeTruthy(); + expect(screen.getByRole('slider')).toBeTruthy(); + expect(screen.getByLabelText('Previous Paragraph')).toBeTruthy(); + expect(screen.getByLabelText('Next Paragraph')).toBeTruthy(); + // Compact one-row controls: speed / voice / sleep timer buttons. + expect(screen.getByLabelText('Speed')).toBeTruthy(); + expect(screen.getByLabelText('Sleep Timer')).toBeTruthy(); + expect(await screen.findByText('Ava')).toBeTruthy(); // voice button caption + // The main view carries no header label (vertical space). + expect(screen.queryByText('Read Aloud')).toBeNull(); + }); + + test('degrades without a timeline: no scrubber, estimate text instead', () => { + render( + , + ); + expect(screen.queryByRole('slider')).toBeNull(); + expect(screen.getByText(/5:00 left in chapter/)).toBeTruthy(); + }); + + test('transport buttons pass paragraph/sentence semantics', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Previous Paragraph')); + expect(props.onBackward).toHaveBeenCalledWith(false); + fireEvent.click(screen.getByLabelText('Previous Sentence')); + expect(props.onBackward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Next Sentence')); + expect(props.onForward).toHaveBeenCalledWith(true); + fireEvent.click(screen.getByLabelText('Next Paragraph')); + expect(props.onForward).toHaveBeenCalledWith(false); + }); + + test('speed button drills into the chips and selecting persists the rate', () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Speed')); + fireEvent.click(screen.getByRole('radio', { name: '1.5×' })); + expect(props.onSetRate).toHaveBeenCalledWith(1.5); + expect(viewSettings['ttsRate']).toBe(1.5); + expect(settings.globalViewSettings.ttsRate).toBe(1.5); + expect(saveSettings).toHaveBeenCalled(); + }); + + test('gap control is absent for a non-Edge client (hasGapControl false)', () => { + const props = makeProps({ hasGapControl: false }); + render(); + fireEvent.click(screen.getByLabelText('Speed')); + expect(screen.queryByText(/Sentence Pause/)).toBeNull(); + expect(screen.queryByRole('radiogroup', { name: 'Sentence Pause' })).toBeNull(); + }); + + test('gap chips show for an Edge client and selecting persists the gap', () => { + const props = makeProps({ hasGapControl: true }); + render(); + fireEvent.click(screen.getByLabelText('Speed')); + expect(screen.getByText(/Sentence Pause/)).toBeTruthy(); + fireEvent.click(screen.getByRole('radio', { name: '0.4s' })); + expect(props.onSetSentenceGap).toHaveBeenCalledWith(0.4); + expect(viewSettings['ttsSentenceGap']).toBe(0.4); + expect(settings.globalViewSettings.ttsSentenceGap).toBe(0.4); + expect(saveSettings).toHaveBeenCalled(); + }); + + test('voice button drills into the voice list and selects a voice', async () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Voice')); + fireEvent.click(await screen.findByText('Guy')); + expect(props.onSetVoice).toHaveBeenCalledWith('guy', 'en-US'); + expect(viewSettings['ttsVoice']).toBe('guy'); + }); + + test('timer button drills into the timer list and selects a timeout', async () => { + const props = makeProps(); + render(); + fireEvent.click(screen.getByLabelText('Sleep Timer')); + // The translation mock interpolates, so options render as real labels. + fireEvent.click(await screen.findByText('30 minutes')); + expect(props.onSelectTimeout).toHaveBeenCalledWith('b1', 1800); + }); + + test('reopening the sheet returns to the main view', async () => { + const props = makeProps(); + const { rerender } = render(); + fireEvent.click(screen.getByLabelText('Voice')); + expect(await screen.findByText('Guy')).toBeTruthy(); + rerender(); + rerender(); + expect(screen.getByLabelText('Previous Paragraph')).toBeTruthy(); + expect(screen.queryByText('Guy')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/TTSScrubber.test.tsx b/apps/readest-app/src/__tests__/components/tts/TTSScrubber.test.tsx new file mode 100644 index 000000000..2d2fbd397 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/TTSScrubber.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string, opts?: Record) => + opts ? Object.entries(opts).reduce((s, [k, v]) => s.replace(`{{${k}}}`, String(v)), key) : key, +})); + +import { eventDispatcher } from '@/utils/event'; +import TTSScrubber from '@/app/reader/components/tts/TTSScrubber'; + +const makeGet = (position: number, duration = 100, measuredFraction = 0.4) => + vi.fn().mockReturnValue({ position, duration, measuredFraction }); + +describe('TTSScrubber', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('renders elapsed and remaining labels', () => { + render( + , + ); + expect(screen.getByText('0:10')).toBeTruthy(); + expect(screen.getByText('-1:30')).toBeTruthy(); + }); + + test('paints played and buffered regions into the track gradient', () => { + render( + , + ); + const slider = screen.getByRole('slider') as HTMLInputElement; + expect(slider.style.background).toContain('10%'); + expect(slider.style.background).toContain('40%'); + }); + + test('commits a seek on release and holds the thumb optimistically', () => { + const onSeek = vi.fn().mockReturnValue(new Promise(() => {})); + render( + , + ); + const slider = screen.getByRole('slider') as HTMLInputElement; + fireEvent.change(slider, { target: { value: '50' } }); + expect(onSeek).not.toHaveBeenCalled(); // drag in progress, no seek yet + fireEvent.touchEnd(slider); + expect(onSeek).toHaveBeenCalledWith(50); + expect(slider.value).toBe('50'); + }); + + test('failed seek restores the previous position and toasts', async () => { + const onSeek = vi.fn().mockRejectedValue(new Error('offline')); + const onToast = vi.fn(); + eventDispatcher.on('toast', onToast); + render( + , + ); + const slider = screen.getByRole('slider') as HTMLInputElement; + fireEvent.change(slider, { target: { value: '50' } }); + await act(async () => { + fireEvent.touchEnd(slider); + }); + // The rejection handler dispatches the toast asynchronously. + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ message: 'Failed to seek' }) }), + ); + }); + expect(slider.value).toBe('10'); + eventDispatcher.off('toast', onToast); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/tts/usePlaybackInfo.test.tsx b/apps/readest-app/src/__tests__/components/tts/usePlaybackInfo.test.tsx new file mode 100644 index 000000000..c50b96f4c --- /dev/null +++ b/apps/readest-app/src/__tests__/components/tts/usePlaybackInfo.test.tsx @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; + +import { eventDispatcher } from '@/utils/event'; +import { usePlaybackInfo } from '@/app/reader/components/tts/usePlaybackInfo'; + +const info = (position: number, duration = 100, measuredFraction = 0) => ({ + position, + duration, + measuredFraction, +}); + +describe('usePlaybackInfo', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test('reads immediately and polls every second', () => { + const get = vi.fn().mockReturnValue(info(10, 100, 0.3)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + expect(result.current.ready).toBe(true); + expect(result.current.position).toBe(10); + expect(result.current.total).toBe(100); + expect(result.current.measuredFraction).toBe(0.3); + + get.mockReturnValue(info(12)); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(12); + }); + + test('holds position monotonic against small backward drift, follows big jumps', () => { + const get = vi.fn().mockReturnValue(info(20)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(info(18.5)); // < 3s drift: estimate refinement + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(20); + + get.mockReturnValue(info(5)); // > 3s jump: deliberate (seek / chapter) + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(5); + }); + + test('quantizes the displayed total: follows only >2% moves', () => { + const get = vi.fn().mockReturnValue(info(10, 100)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(info(11, 101)); // 1% drift: hold + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.total).toBe(100); + + get.mockReturnValue(info(12, 110)); // 10% move: follow + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.total).toBe(110); + }); + + test('goes stale but keeps last values when the poll returns null', () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(null); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.stale).toBe(true); + expect(result.current.position).toBe(10); + }); + + test('setRefreshPaused freezes polling while dragging', () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + act(() => { + result.current.setRefreshPaused(true); + }); + get.mockReturnValue(info(30)); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.position).toBe(10); + }); + + test('applySeek lands optimistically, suppresses polls, rollback restores', () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: false, onGetPlaybackInfo: get }), + ); + let rollback: () => void; + act(() => { + rollback = result.current.applySeek(50); + }); + expect(result.current.position).toBe(50); + + get.mockReturnValue(info(11)); // stale server position inside suppression window + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.position).toBe(50); + + act(() => { + rollback!(); + }); + expect(result.current.position).toBe(10); + }); + + test('eink mode refreshes on sentence tts-position events, not on a timer', async () => { + const get = vi.fn().mockReturnValue(info(10)); + const { result } = renderHook(() => + usePlaybackInfo({ bookKey: 'b1', isEink: true, onGetPlaybackInfo: get }), + ); + get.mockReturnValue(info(15)); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current.position).toBe(10); + + await act(async () => { + await eventDispatcher.dispatch('tts-position', { bookKey: 'b1', kind: 'sentence' }); + }); + expect(result.current.position).toBe(15); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/webdav-browse-sort-filter.test.ts b/apps/readest-app/src/__tests__/components/webdav-browse-sort-filter.test.ts new file mode 100644 index 000000000..f70973f7c --- /dev/null +++ b/apps/readest-app/src/__tests__/components/webdav-browse-sort-filter.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'vitest'; +import { + filterWebDAVEntries, + sortWebDAVEntries, +} from '@/components/settings/integrations/webdavBrowseUtils'; +import type { WebDAVEntry } from '@/services/sync/providers/webdav/client'; + +/** + * Pure sort/filter helpers backing the WebDAV browser's sort + search + * controls. They operate on already-fetched `WebDAVEntry[]` (the pane + * lists a whole directory in one PROPFIND, so there's no pagination to + * worry about) which keeps them trivially unit-testable here. + */ + +const dir = (name: string, extra: Partial = {}): WebDAVEntry => ({ + name, + path: `/${name}`, + isDirectory: true, + ...extra, +}); + +const file = (name: string, extra: Partial = {}): WebDAVEntry => ({ + name, + path: `/${name}`, + isDirectory: false, + ...extra, +}); + +const names = (entries: WebDAVEntry[]): string[] => entries.map((e) => e.name); + +describe('sortWebDAVEntries', () => { + test('keeps directories grouped before files regardless of field/direction', () => { + const entries = [file('b.epub', { size: 10 }), dir('z-folder'), file('a.epub', { size: 20 })]; + const sorted = sortWebDAVEntries(entries, 'size', false); + // Directories first even though the field is size descending and the + // dir has no size; files follow, ordered by the field. + expect(sorted[0]!.isDirectory).toBe(true); + expect(names(sorted.filter((e) => !e.isDirectory))).toEqual(['a.epub', 'b.epub']); + }); + + test('sorts by name ascending and descending', () => { + const entries = [file('Charlie.epub'), file('alpha.epub'), file('Bravo.epub')]; + expect(names(sortWebDAVEntries(entries, 'name', true))).toEqual([ + 'alpha.epub', + 'Bravo.epub', + 'Charlie.epub', + ]); + expect(names(sortWebDAVEntries(entries, 'name', false))).toEqual([ + 'Charlie.epub', + 'Bravo.epub', + 'alpha.epub', + ]); + }); + + test('sorts by last modified date, newest-first when descending', () => { + const entries = [ + file('old.epub', { lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT' }), + file('new.epub', { lastModified: 'Wed, 01 Jan 2025 00:00:00 GMT' }), + file('mid.epub', { lastModified: 'Sat, 01 Jun 2024 00:00:00 GMT' }), + ]; + expect(names(sortWebDAVEntries(entries, 'modified', false))).toEqual([ + 'new.epub', + 'mid.epub', + 'old.epub', + ]); + expect(names(sortWebDAVEntries(entries, 'modified', true))).toEqual([ + 'old.epub', + 'mid.epub', + 'new.epub', + ]); + }); + + test('sorts by creation date', () => { + const entries = [ + file('second.epub', { created: '2024-05-01T00:00:00Z' }), + file('first.epub', { created: '2024-01-01T00:00:00Z' }), + ]; + expect(names(sortWebDAVEntries(entries, 'created', true))).toEqual([ + 'first.epub', + 'second.epub', + ]); + }); + + test('sorts by size', () => { + const entries = [file('big.epub', { size: 5000 }), file('small.epub', { size: 10 })]; + expect(names(sortWebDAVEntries(entries, 'size', true))).toEqual(['small.epub', 'big.epub']); + expect(names(sortWebDAVEntries(entries, 'size', false))).toEqual(['big.epub', 'small.epub']); + }); + + test('entries missing the sort field sort last in both directions, tie-broken by name', () => { + const entries = [ + file('has-date.epub', { lastModified: 'Wed, 01 Jan 2025 00:00:00 GMT' }), + file('no-date-b.epub'), + file('no-date-a.epub'), + ]; + // Ascending: dated entry first, then the undated pair alphabetically. + expect(names(sortWebDAVEntries(entries, 'modified', true))).toEqual([ + 'has-date.epub', + 'no-date-a.epub', + 'no-date-b.epub', + ]); + // Descending: dated entry still first; undated remain last (not flipped + // to the top) and stay name-ordered. + expect(names(sortWebDAVEntries(entries, 'modified', false))).toEqual([ + 'has-date.epub', + 'no-date-a.epub', + 'no-date-b.epub', + ]); + }); + + test('name sort follows the resolved display name when provided', () => { + // Under Readest/books the entry name is a content hash; the resolver + // maps it to the human title so "sort by name" matches what's shown. + const entries = [dir('hashZ'), dir('hashA')]; + const titles: Record = { hashZ: 'Alpha', hashA: 'Zulu' }; + const getName = (e: WebDAVEntry) => titles[e.name] ?? e.name; + expect(names(sortWebDAVEntries(entries, 'name', true, getName))).toEqual(['hashZ', 'hashA']); + }); + + test('does not mutate the input array', () => { + const entries = [file('b.epub'), file('a.epub')]; + const snapshot = names(entries); + sortWebDAVEntries(entries, 'name', true); + expect(names(entries)).toEqual(snapshot); + }); +}); + +describe('filterWebDAVEntries', () => { + const entries = [file('The Great Gatsby.epub'), file('mobydick.epub'), dir('hash123')]; + + test('returns every entry for an empty/whitespace query', () => { + expect(filterWebDAVEntries(entries, '')).toHaveLength(3); + expect(filterWebDAVEntries(entries, ' ')).toHaveLength(3); + }); + + test('matches file name case-insensitively as a substring', () => { + expect(names(filterWebDAVEntries(entries, 'GREAT'))).toEqual(['The Great Gatsby.epub']); + expect(names(filterWebDAVEntries(entries, 'dick'))).toEqual(['mobydick.epub']); + }); + + test('matches the resolved display title for hashed book directories', () => { + const getName = (e: WebDAVEntry) => (e.name === 'hash123' ? 'Crime and Punishment' : e.name); + expect(names(filterWebDAVEntries(entries, 'punishment', getName))).toEqual(['hash123']); + }); + + test('returns an empty array when nothing matches', () => { + expect(filterWebDAVEntries(entries, 'zzz-nope')).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-container-position.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-container-position.test.ts new file mode 100644 index 000000000..4990b669e --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-container-position.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +// Importing the module registers the custom element. +import 'foliate-js/fixed-layout.js'; + +describe('fixed-layout containerPosition (READEST-11)', () => { + const FixedLayout = customElements.get('foliate-fxl'); + + it('registers the custom element', () => { + expect(FixedLayout).toBeTruthy(); + }); + + it('exposes a writable containerPosition for scrolled fixed-layout autoscroll', () => { + const descriptor = Object.getOwnPropertyDescriptor(FixedLayout!.prototype, 'containerPosition'); + expect(typeof descriptor?.get).toBe('function'); + // Auto Scroll / middle-click autoscroll do `renderer.containerPosition += delta` + // in scrolled mode. A getter-only property crashed on the write for PDF / CBZ / + // fixed-EPUB books in scrolled mode (READEST-11); the setter must exist. + expect(typeof descriptor?.set).toBe('function'); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-paginated-scroll.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-paginated-scroll.test.ts new file mode 100644 index 000000000..ea9f2b775 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-paginated-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { computePaginatedScroll } from 'foliate-js/fixed-layout.js'; + +describe('fixed-layout paginated page-turn scroll reset', () => { + it('resets the vertical scroll to the top of the page on a page turn (#4683)', () => { + // A tall fit-width page leaves the host scrolled to the bottom; turning to + // the next page must start at the top, not inherit the previous offset. + expect( + computePaginatedScroll({ + elementWidth: 800, + containerWidth: 800, + scrollTop: 1200, + pageTurn: true, + }), + ).toEqual({ scrollLeft: 0, scrollTop: 0 }); + }); + + it('preserves the vertical scroll on a non-navigation re-render (resize/zoom/theme)', () => { + expect( + computePaginatedScroll({ + elementWidth: 800, + containerWidth: 800, + scrollTop: 1200, + pageTurn: false, + }), + ).toEqual({ scrollLeft: 0, scrollTop: 1200 }); + }); + + it('re-centers horizontally when the page is wider than the viewport', () => { + expect( + computePaginatedScroll({ + elementWidth: 1200, + containerWidth: 800, + scrollTop: 0, + pageTurn: true, + }), + ).toEqual({ scrollLeft: 200, scrollTop: 0 }); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-pinch-zoom.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-pinch-zoom.test.ts new file mode 100644 index 000000000..2ced5ca41 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-pinch-zoom.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { computeScrollPinchTransform } from 'foliate-js/fixed-layout.js'; + +describe('computeScrollPinchTransform', () => { + it('scales by the pinch ratio anchored at the viewport centre in both axes', () => { + // Anchoring the live preview at the viewport centre (in container + // coordinates) matches the post-pinch re-render, which restores the same + // centre point via the center anchor, so the zoom does not jump on release. + expect( + computeScrollPinchTransform({ + ratio: 1.5, + scrollLeft: 0, + scrollTop: 2000, + viewportWidth: 360, + viewportHeight: 640, + }), + ).toEqual({ + transform: 'scale(1.5)', + transformOrigin: '180px 2320px', + }); + }); + + it('offsets the origin by the current scroll position', () => { + expect( + computeScrollPinchTransform({ + ratio: 0.8, + scrollLeft: 90, + scrollTop: 0, + viewportWidth: 360, + viewportHeight: 640, + }), + ).toEqual({ + transform: 'scale(0.8)', + transformOrigin: '270px 320px', + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-portrait-single-page.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-portrait-single-page.test.ts new file mode 100644 index 000000000..17cb6c2d5 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-portrait-single-page.test.ts @@ -0,0 +1,58 @@ +// Regression test for readest/readest issue #4984. +// +// In auto-spread mode a portrait viewport shows only one page of the two-page +// spread. The renderer already hides the other page and scales the shown page +// as a single page, but it kept the spread-centering inline margin: a left page +// gets `margin-inline-start: auto` (which pushes it toward the spine on the +// right) and a right page gets `margin-inline-end: auto` (pushing it left). With +// only one page visible and no partner to meet, that one-sided auto margin +// strands the page in one half of the viewport whenever it is narrower than the +// viewport (any zoom below 100%, or a page whose fit-scaled width is less than +// the viewport width). It also breaks tapping: the off-center page sits over a +// page-turn tap zone instead of the center menu zone, so every tap turns the +// page. +// +// The fix: in portrait, center the lone visible page (both inline margins auto) +// instead of the one-sided margin. In landscape the two pages keep their +// one-sided margins so they meet at the spine and the pair stays centered. +// `computeSpreadInlineMargins(portrait)` returns the inline-margin style for the +// left and right pages. It sets both inline margins explicitly so a re-render +// after an orientation change fully overwrites the previous layout's margins +// (frames are re-styled in place, not recreated, on rotation). + +import { describe, expect, it } from 'vitest'; + +import { computeSpreadInlineMargins } from 'foliate-js/fixed-layout.js'; + +describe('computeSpreadInlineMargins (#4984)', () => { + it('pushes each page toward the spine in landscape so the pair stays centered', () => { + const { left, right } = computeSpreadInlineMargins(false); + // Left page hugs the right (spine) edge; right page hugs the left edge. + expect(left.marginInlineStart).toBe('auto'); + expect(left.marginInlineEnd).not.toBe('auto'); + expect(right.marginInlineEnd).toBe('auto'); + expect(right.marginInlineStart).not.toBe('auto'); + }); + + it('centers the lone visible page in portrait instead of stranding it to a side', () => { + const { left, right } = computeSpreadInlineMargins(true); + // Whichever page of the spread is shown, its margins are symmetric so it is + // centered rather than shoved into one half of the viewport. + for (const style of [left, right]) { + expect(style.marginInlineStart).toBe('auto'); + expect(style.marginInlineEnd).toBe('auto'); + } + }); + + it('always sets both inline margins so an orientation change fully re-lays-out', () => { + // Both modes must define both sides; otherwise a stale `auto` left over from + // the previous orientation lingers and mis-centers the page. + for (const portrait of [false, true]) { + const { left, right } = computeSpreadInlineMargins(portrait); + for (const style of [left, right]) { + expect(style).toHaveProperty('marginInlineStart'); + expect(style).toHaveProperty('marginInlineEnd'); + } + } + }); +}); diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-scroll-scheduler.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-scroll-scheduler.test.ts new file mode 100644 index 000000000..ce91fd680 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-scroll-scheduler.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { planScrollModePages } from 'foliate-js/fixed-layout.js'; + +// Page model the scheduler operates on. `visible` is set by the +// IntersectionObserver (true when the page is within the widened preload +// margin); `state` mirrors the load lifecycle. +const page = (index: number, state: string, visible: boolean) => ({ index, state, visible }); + +describe('planScrollModePages', () => { + it('loads the nearest visible idle pages first, within the concurrency budget', () => { + const pages = [ + page(8, 'idle', true), + page(9, 'idle', true), + page(10, 'loaded', true), + page(11, 'idle', true), + page(12, 'idle', true), + ]; + + const { load } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 8, + maxConcurrent: 2, + loadingCount: 0, + }); + + // Nearest to index 10 are 9 and 11; both idle+visible; budget is 2. + expect(load).toEqual([9, 11]); + }); + + it('subtracts in-flight loads from the concurrency budget', () => { + const pages = [page(9, 'idle', true), page(11, 'idle', true), page(12, 'idle', true)]; + + const { load } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 8, + maxConcurrent: 3, + loadingCount: 2, + }); + + // 3 concurrent allowed, 2 already loading -> budget of 1 -> nearest only. + expect(load).toEqual([9]); + }); + + it('never starts a load when the budget is already spent', () => { + const pages = [page(9, 'idle', true), page(11, 'idle', true)]; + + const { load } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 8, + maxConcurrent: 2, + loadingCount: 2, + }); + + expect(load).toEqual([]); + }); + + it('ignores idle pages outside the visible preload band', () => { + const pages = [page(9, 'idle', false), page(11, 'idle', true)]; + + const { load } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 8, + maxConcurrent: 4, + loadingCount: 0, + }); + + // Page 9 is not visible, so only the visible page 11 loads. + expect(load).toEqual([11]); + }); + + it('does not re-load pages that are already loading or loaded', () => { + const pages = [page(9, 'loading', true), page(10, 'loaded', true), page(11, 'idle', true)]; + + const { load } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 8, + maxConcurrent: 4, + loadingCount: 1, + }); + + expect(load).toEqual([11]); + }); + + it('never re-loads a page in the terminal error state', () => { + // A page whose load failed is parked in 'error' so the post-completion + // reschedule cannot retry it forever in a tight async loop. + const pages = [page(10, 'error', true), page(11, 'idle', true)]; + + const { load } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 8, + maxConcurrent: 4, + loadingCount: 0, + }); + + expect(load).toEqual([11]); + }); + + it('evicts the farthest non-visible loaded pages once over the in-memory cap', () => { + const pages = [ + page(2, 'loaded', false), + page(20, 'loaded', false), + page(9, 'loaded', true), + page(10, 'loaded', true), + page(11, 'loaded', true), + ]; + + const { evict } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 3, + maxConcurrent: 2, + loadingCount: 0, + }); + + // 5 loaded, cap 3 -> evict the 2 farthest from index 10: pages 20 and 2. + expect(evict.sort((a: number, b: number) => a - b)).toEqual([2, 20]); + }); + + it('never evicts a visible page even when over the cap', () => { + const pages = [page(9, 'loaded', true), page(10, 'loaded', true), page(11, 'loaded', true)]; + + const { evict } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 1, + maxConcurrent: 2, + loadingCount: 0, + }); + + // All three are on screen; none may be torn out from under the reader. + expect(evict).toEqual([]); + }); + + it('does not evict when loaded pages are within the cap', () => { + const pages = [page(9, 'loaded', true), page(10, 'loaded', true)]; + + const { evict } = planScrollModePages({ + pages, + currentIndex: 10, + maxLoaded: 8, + maxConcurrent: 2, + loadingCount: 0, + }); + + expect(evict).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-scroll-wheel.browser.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-scroll-wheel.browser.test.ts new file mode 100644 index 000000000..79da9ab06 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-scroll-wheel.browser.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +// Registers the custom element (the fixed-layout / PDF renderer). +import 'foliate-js/fixed-layout.js'; + +// Regression test for readest#4727: in fixed-layout scroll mode, a wheel tick +// that lands on a page iframe (during the brief idle window where the iframe is +// interactive) must NOT be scrolled by JS. The browser already chains the wheel +// to the host scroller natively; if the in-iframe wheel handler also scrolls the +// host, the page travels twice as far in an instant lurch instead of one smooth +// notch. +// +// This needs a real browser: the bug is the interaction between native wheel +// scrolling and a programmatic scrollBy, and jsdom has no layout/scroll. We +// mount the real renderer and assert the JS-side handler does not move the host +// scroller on its own (the synthetic wheel does not trigger native scrolling, +// so any movement we observe is purely the JS handler — which must be zero). + +const PAGE_HTML = ``; + +const makeBook = (sectionCount: number) => ({ + dir: 'ltr', + rendition: { viewport: { width: 600, height: 1000 }, spread: 'none' }, + sections: Array.from({ length: sectionCount }, () => ({ + // `src` only has to be truthy — when `data` is present the renderer loads it + // via srcdoc, which keeps the iframe same-origin so its document (and the + // wheel listener attached to it) is reachable. + load: async () => ({ src: 'srcdoc', data: PAGE_HTML }), + linear: 'yes', + })), +}); + +const waitFor = async (fn: () => T | null | undefined, timeout = 4000): Promise => { + const start = performance.now(); + for (;;) { + const value = fn(); + if (value) return value; + if (performance.now() - start > timeout) throw new Error('waitFor timed out'); + await new Promise((r) => setTimeout(r, 30)); + } +}; + +let renderer: HTMLElement | null = null; + +afterEach(() => { + renderer?.remove(); + renderer = null; +}); + +describe('fixed-layout scroll mode wheel handling (readest#4727)', () => { + it('does not double-scroll the host when a wheel lands on a page', async () => { + renderer = document.createElement('foliate-fxl'); + renderer.style.width = '600px'; + renderer.style.height = '400px'; + renderer.setAttribute('flow', 'scrolled'); + document.body.append(renderer); + + (renderer as unknown as { open(book: unknown): void }).open(makeBook(3)); + + // The first page loads via the IntersectionObserver; wait until its iframe + // has rendered (display flipped from 'none') — by then the wheel listener + // that #loadScrollPage attaches to the iframe document is in place. + const iframe = await waitFor(() => { + const f = renderer!.shadowRoot?.querySelector('.scroll-page iframe'); + return f && f.style.display && f.style.display !== 'none' && f.contentDocument ? f : null; + }); + + const scroller = renderer as unknown as HTMLElement; + + // A wheel over the page iframe. With the bug, the iframe handler runs + // `host.scrollBy({ top: deltaY, behavior: 'instant' })`, an *instant* + // (synchronous) scroll that lands by ~120px before dispatchEvent() returns. + // With the fix the handler only drops pointer-events and never scrolls. + // + // Measure the scroll position synchronously around dispatchEvent() — with no + // await in between — so we capture only the wheel handler's own effect. Do + // NOT await/settle here: as sibling pages finish loading, the renderer runs + // #restoreScrollModeAnchor asynchronously, which snaps scrollTop to a page's + // offsetTop (the 4px --scroll-page-gap). A post-dispatch delay races that + // re-anchoring and observed scrollTop === 4 instead of 0 on slow CI runners + // (readest CI flake). The buggy scrollBy is synchronous, so a synchronous + // before/after comparison still catches it while being immune to the race. + const before = scroller.scrollTop; + iframe.contentDocument!.dispatchEvent( + new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }), + ); + const after = scroller.scrollTop; + + expect(after).toBe(before); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/fixed-layout-spine-seam.test.ts b/apps/readest-app/src/__tests__/document/fixed-layout-spine-seam.test.ts new file mode 100644 index 000000000..6fb123785 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/fixed-layout-spine-seam.test.ts @@ -0,0 +1,55 @@ +// Regression test for readest/readest issue #4857. +// +// In a fixed-layout (EPUB or PDF) two-page spread at a fractional device pixel +// ratio (Windows display scale 150% -> devicePixelRatio 1.5), a one-pixel white +// seam appeared down the middle of the spread at the spine. +// +// Root cause: the two page iframes are independent compositor layers, each +// scaled by a (usually non-integer) factor. At a fractional devicePixelRatio the +// spine between them lands on a fractional device pixel, so each layer's edge +// there is anti-aliased against transparency and the reader background bleeds +// through as a thin white seam. (Filling the page canvas to its box, #4587, does +// not help: the soft edge comes from scaling the layer, not from the content +// stopping short of its box.) +// +// The fix overlaps the (top-most) right page onto the left by exactly one device +// pixel, so each soft edge sits over the neighbour's opaque content instead of +// the background. The shift is visual-only (translateX), leaving the centred +// spread layout untouched. `computeSpreadSpineOverlap` returns that translateX +// (in CSS px) for the right page, or 0 for layouts with no touching spine. + +import { describe, expect, it } from 'vitest'; + +import { computeSpreadSpineOverlap } from 'foliate-js/fixed-layout.js'; + +describe('computeSpreadSpineOverlap (#4857)', () => { + it('overlaps the right page by exactly one device pixel on a real two-page spread', () => { + // Windows 150% scale: one device pixel is 1 / 1.5 CSS px. + expect(computeSpreadSpineOverlap({ devicePixelRatio: 1.5 })).toBeCloseTo(-1 / 1.5, 10); + // Retina / mobile: one device pixel is 0.5 CSS px. + expect(computeSpreadSpineOverlap({ devicePixelRatio: 2 })).toBeCloseTo(-0.5, 10); + // No scaling: one device pixel is one CSS px. + expect(computeSpreadSpineOverlap({ devicePixelRatio: 1 })).toBeCloseTo(-1, 10); + }); + + it('defaults a missing/zero devicePixelRatio to 1 device pixel', () => { + expect(computeSpreadSpineOverlap({})).toBeCloseTo(-1, 10); + expect(computeSpreadSpineOverlap({ devicePixelRatio: 0 })).toBeCloseTo(-1, 10); + }); + + it('does not overlap layouts that have no touching spine', () => { + // A single centred page (cover / odd page). + expect(computeSpreadSpineOverlap({ center: true, devicePixelRatio: 1.5 })).toBe(0); + // Portrait device: only one page of the spread is shown. + expect(computeSpreadSpineOverlap({ portrait: true, devicePixelRatio: 1.5 })).toBe(0); + // A spread slot padded with a blank page shows a single real page. + expect(computeSpreadSpineOverlap({ leftBlank: true, devicePixelRatio: 1.5 })).toBe(0); + expect(computeSpreadSpineOverlap({ rightBlank: true, devicePixelRatio: 1.5 })).toBe(0); + }); + + it('still overlaps a two-page spread when zoomed below 100%', () => { + // Zoom is intentionally not an input: the pages stay adjacent at every zoom, + // so a sub-100% spread gets the same overlap as a 100% one. + expect(computeSpreadSpineOverlap({ devicePixelRatio: 2 })).toBeCloseTo(-0.5, 10); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/foliate-paginator-null-guards.test.ts b/apps/readest-app/src/__tests__/document/foliate-paginator-null-guards.test.ts new file mode 100644 index 000000000..572a322c2 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/foliate-paginator-null-guards.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getDirection } from 'foliate-js/paginator.js'; + +// A view's iframe document is blank/detached while a section loads or the view +// is torn down: body is null, and getComputedStyle(null) throws +// "parameter 1 is not of type 'Element'" (READEST-2X). +describe('getDirection null-document guard (READEST-2X)', () => { + it('falls back to horizontal-ltr instead of calling getComputedStyle(null)', () => { + const getComputedStyle = vi.fn((el: Element | null) => { + if (!el) { + throw new TypeError( + "Failed to execute 'getComputedStyle' on 'Window': parameter 1 is not of type 'Element'.", + ); + } + return { writingMode: 'horizontal-tb', direction: 'ltr' } as CSSStyleDeclaration; + }); + const doc = { + defaultView: { getComputedStyle }, + body: null, + documentElement: { dir: '' }, + } as unknown as Document; + + expect(() => getDirection(doc)).not.toThrow(); + expect(getDirection(doc)).toEqual({ vertical: false, rtl: false }); + expect(getComputedStyle).not.toHaveBeenCalled(); + }); + + it('still reads the writing mode from a present body', () => { + const getComputedStyle = vi.fn(() => ({ writingMode: 'vertical-rl', direction: 'ltr' })); + const doc = { + defaultView: { getComputedStyle }, + body: { dir: '', querySelector: () => null }, + documentElement: { dir: '' }, + } as unknown as Document; + + expect(getDirection(doc)).toEqual({ vertical: true, rtl: true }); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/paginator-background-anim-perf.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-background-anim-perf.browser.test.ts new file mode 100644 index 000000000..645fb2d50 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-background-anim-perf.browser.test.ts @@ -0,0 +1,226 @@ +// Performance regression test for the swipe page-turn frame drops at chapter +// boundaries (readest/readest#4785). +// +// In paginated mode the paginator keeps the per-section backgrounds glued to the +// content during an animated page turn by repainting them every animation frame +// (the #replaceBackground / syncBackground loop). The original implementation +// rebuilt the whole paint *context* every frame: a getComputedStyle() on the +// primary section's plus one getBoundingClientRect() per rendered view. +// Those are forced style/layout reads, and their cost scales with the number of +// loaded views — which peaks exactly when adjacent sections are preloaded at a +// chapter boundary, so the swipe animation dropped frames there. +// +// Everything that read context is invariant for the duration of a single scroll +// animation (theme/texture, the background+container geometry, each view's size +// and resolved background) — only the scroll offset changes. So the context must +// be snapshotted once when the animation starts and reused every frame; the +// per-frame work must NOT re-run getComputedStyle on the section document. +// +// This test drives a real animated turn and asserts the primary section's +// getComputedStyle is read at most a small constant number of times across the +// whole turn (not once per frame). +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; + +let book: BookDoc; + +const loadEPUB = async () => { + const resp = await fetch(EPUB_URL); + const buffer = await resp.arrayBuffer(); + const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 10000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const waitForFillComplete = async (el: Renderer, timeout = 10000) => { + const start = Date.now(); + let lastCount = -1; + let stableFor = 0; + while (Date.now() - start < timeout) { + const count = el.getContents().length; + if (count === lastCount) { + stableFor += 100; + if (stableFor >= 500) return; + } else { + stableFor = 0; + lastCount = count; + } + await new Promise((r) => setTimeout(r, 100)); + } +}; + +describe('Paginator animated background repaint (browser)', () => { + let paginator: Renderer; + + const suppressHandler = (e: ErrorEvent) => { + if (e.message?.includes('getComputedStyle')) e.preventDefault(); + }; + + beforeAll(async () => { + window.addEventListener('error', suppressHandler); + book = await loadEPUB(); + await import('foliate-js/paginator.js'); + }, 30000); + + afterAll(() => { + window.removeEventListener('error', suppressHandler); + }); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as Renderer; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + // Enable the snap/smooth animation path (syncBackground per-frame loop). + el.setAttribute('animated', ''); + document.body.appendChild(el); + return el; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + }); + + it('snapshots the paint context once per animated turn instead of every frame', async () => { + // A multi-page section so next() animates a page turn that stays within the + // section (the within-section snap/smooth path runs the per-frame loop). + const longIdx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 4000); + const idx = longIdx >= 0 ? longIdx : book.sections!.findIndex((s) => s.linear !== 'no'); + + paginator = createPaginator(); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: idx }); + await stabilized; + await waitForFillComplete(paginator); + + const primary = paginator.getContents().find((c) => c.index === paginator.primaryIndex); + expect(primary).toBeDefined(); + const doc = primary!.doc; + const win = doc.defaultView as Window & typeof globalThis; + const htmlEl = doc.documentElement; + + // Count getComputedStyle reads against the primary section's — the + // forced style read #replaceBackground performs to resolve the theme/texture + // background. The per-frame paint must not repeat it. + let htmlStyleReads = 0; + const origGetComputedStyle = win.getComputedStyle.bind(win); + win.getComputedStyle = ((element: Element, pseudo?: string | null) => { + if (element === htmlEl) htmlStyleReads++; + return origGetComputedStyle(element, pseudo ?? undefined); + }) as typeof win.getComputedStyle; + + try { + await paginator.next(); + // Let any trailing syncBackground RAF frames flush. + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + } finally { + win.getComputedStyle = origGetComputedStyle; + } + + // A 300ms animation at ~60fps repaints ~18 frames. The pre-fix code read the + // computed style on every one of those frames; the fixed code reads it + // a small constant number of times (context build + settle repaints). + expect(htmlStyleReads).toBeLessThanOrEqual(3); + }); + + it('reuses the drag snapshot across swipe-drag frames instead of rebuilding', async () => { + const longIdx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 4000); + const idx = longIdx >= 0 ? longIdx : book.sections!.findIndex((s) => s.linear !== 'no'); + + paginator = createPaginator(); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: idx }); + await stabilized; + await waitForFillComplete(paginator); + + const primary = paginator.getContents().find((c) => c.index === paginator.primaryIndex); + expect(primary).toBeDefined(); + const doc = primary!.doc; + const win = doc.defaultView as Window & typeof globalThis; + const htmlEl = doc.documentElement; + + const nextFrame = () => + new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); + + const mkTouch = (x: number) => + new Touch({ + identifier: 1, + target: paginator, + screenX: x, + screenY: 100, + clientX: x, + clientY: 100, + }); + const mkEvent = (type: string, x: number) => + new TouchEvent(type, { + bubbles: true, + cancelable: true, + changedTouches: [mkTouch(x)], + touches: type === 'touchend' ? [] : [mkTouch(x)], + }); + + // Begin the gesture — #onTouchStart snapshots the paint context once. + let x = 400; + paginator.dispatchEvent(mkEvent('touchstart', x)); + + // Install the spy AFTER the touchstart snapshot so we measure only the + // per-drag-frame repaints, not the one-time snapshot read. + let htmlStyleReads = 0; + const origGetComputedStyle = win.getComputedStyle.bind(win); + win.getComputedStyle = ((element: Element, pseudo?: string | null) => { + if (element === htmlEl) htmlStyleReads++; + return origGetComputedStyle(element, pseudo ?? undefined); + }) as typeof win.getComputedStyle; + + try { + // Move the finger leftward in steps, one per frame, so each scrollBy emits + // its own (non-coalesced) container scroll event → one drag repaint each. + for (let i = 0; i < 6; i++) { + x -= 30; + paginator.dispatchEvent(mkEvent('touchmove', x)); + await nextFrame(); + } + paginator.dispatchEvent(mkEvent('touchend', x)); + await nextFrame(); + } finally { + win.getComputedStyle = origGetComputedStyle; + } + + // Each drag frame repainted the per-view backgrounds; pre-fix that rebuilt the + // whole context (a getComputedStyle) every frame. The drag snapshot + // must make those frames read-free. + expect(htmlStyleReads).toBeLessThanOrEqual(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/paginator-scrolled.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-scrolled.browser.test.ts index cda43113e..64a669ef5 100644 --- a/apps/readest-app/src/__tests__/document/paginator-scrolled.browser.test.ts +++ b/apps/readest-app/src/__tests__/document/paginator-scrolled.browser.test.ts @@ -370,4 +370,125 @@ describe('Paginator scrolled mode (browser)', () => { } expect(paginator.getContents().map((c) => c.index)).toContain(firstBefore - 1); }); + + // Regression for issue #4436: in scrolled mode the header chapter title is + // wrong while transitioning between sections. When the tail of section K is + // a thin sliver at the very top of the viewport but section K+1 occupies the + // centre (and the majority) of the screen, the relocate event must report + // K+1 — the section the reader is actually reading — not K. The reported + // `index` is what `view.js` feeds to TOCProgress to resolve the header + // title, so reporting K here is exactly the mismatch users see versus + // paginated mode (where each page belongs to a single section). + it('should report the section occupying the viewport centre, not the sliver at the top', async () => { + paginator = createPaginator(); + paginator.open(book); + paginator.setAttribute('flow', 'scrolled'); + + const sections = book.sections!; + const isLinear = (i: number) => i >= 0 && i < sections.length && sections[i]!.linear !== 'no'; + // Two adjacent tall linear sections: K leaves a text-bearing sliver at the + // top while K+1 is tall enough to span the centre and most of the screen. + let K = -1; + for (let i = 0; i < sections.length - 1; i++) { + if ( + isLinear(i) && + isLinear(i + 1) && + (sections[i]!.size ?? 0) > 8000 && + (sections[i + 1]!.size ?? 0) > 8000 + ) { + K = i; + break; + } + } + expect(K).toBeGreaterThanOrEqual(0); + const next = K + 1; + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: K, anchor: 0 }); + await stabilized; + await waitForFillComplete(paginator); + + // Both sections must be loaded to exercise the boundary. + if (!paginator.getContents().some((c) => c.index === next)) return; + + // Freeze the loaded set so preload/scroll-compensation can no longer shift + // view offsets out from under the absolute scrollTop we set below. + paginator.setAttribute('no-preload', ''); + + const container = paginator.shadowRoot!.getElementById('container')!; + + // Map each loaded view element to its section index and its offset within + // the scroll content (independent of the current scrollTop). + const viewBoxes = () => { + const contents = paginator.getContents(); + const cTop = container.getBoundingClientRect().top; + const scrollTop = container.scrollTop; + const boxes: { index: number; top: number; bottom: number; height: number }[] = []; + for (const child of Array.from(container.children) as HTMLElement[]) { + const iframe = child.querySelector('iframe'); + if (!iframe) continue; + const match = contents.find((x) => x.doc === iframe.contentDocument); + if (!match) continue; + const r = child.getBoundingClientRect(); + const top = r.top - cTop + scrollTop; + boxes.push({ index: match.index as number, top, bottom: top + r.height, height: r.height }); + } + return boxes; + }; + + const boxK = viewBoxes().find((b) => b.index === K); + const boxNext = viewBoxes().find((b) => b.index === next); + expect(boxK).toBeDefined(); + expect(boxNext).toBeDefined(); + + const size = paginator.size; + // Section K must be tall enough to leave a clear (text-bearing) sliver at + // the top while the viewport centre lands inside K+1. + if (boxK!.height <= 0.4 * size) return; + + // Position the boundary in the top ~35% of the viewport: a sliver of K at + // the top, K+1 across the centre and most of the screen. + const target = boxNext!.top - 0.35 * size; + expect(target).toBeGreaterThan(boxK!.top); + + // Sanity: with this scroll position the top-of-viewport section is K — the + // exact regime where the old "first overlapping view" heuristic mislabels + // the header. + const topIndexAt = (scrollTop: number) => { + const center = scrollTop + size / 2; + let topIdx: number | null = null; + for (const b of viewBoxes()) { + if (b.bottom <= scrollTop + 1 || b.top >= scrollTop + size) continue; + if (topIdx === null) topIdx = b.index; + if (center >= b.top && center < b.bottom) return { topIdx, centerIdx: b.index }; + } + return { topIdx, centerIdx: topIdx }; + }; + + const relocates: { reason: string; index: number }[] = []; + const onReloc = (e: Event) => { + const d = (e as CustomEvent).detail as { reason: string; index: number }; + if (d.reason === 'scroll') relocates.push(d); + }; + paginator.addEventListener('relocate', onReloc as EventListener); + + // The first debounced scroll after a navigation just clears #justAnchored; + // nudge a few times (all inside the target regime) so a genuine + // afterScroll('scroll') fires and emits a relocate we can read. + const nudges = [target, target - 2, target]; + for (const top of nudges) { + container.scrollTop = top; + container.dispatchEvent(new Event('scroll')); + await new Promise((r) => setTimeout(r, 350)); + } + paginator.removeEventListener('relocate', onReloc as EventListener); + + const { topIdx, centerIdx } = topIndexAt(container.scrollTop); + expect(topIdx).toBe(K); // sliver of K at the top + expect(centerIdx).toBe(next); // K+1 owns the centre + + const last = relocates.at(-1); + expect(last).toBeDefined(); + expect(last!.index).toBe(next); + }); }); diff --git a/apps/readest-app/src/__tests__/document/paginator-table-toc-clip.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-table-toc-clip.browser.test.ts new file mode 100644 index 000000000..e5a8bf22e --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-table-toc-clip.browser.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { ViewSettings } from '@/types/book'; +import type { Renderer } from '@/types/view'; +import { getStyles } from '@/utils/style'; +import { applyScrollableStyle, SCROLL_WRAPPER_CLASS } from '@/utils/scrollable'; +import { + DEFAULT_BOOK_FONT, + DEFAULT_BOOK_LAYOUT, + DEFAULT_BOOK_STYLE, + DEFAULT_BOOK_LANGUAGE, + DEFAULT_VIEW_CONFIG, + DEFAULT_TTS_CONFIG, + DEFAULT_TRANSLATOR_CONFIG, + DEFAULT_ANNOTATOR_CONFIG, + DEFAULT_SCREEN_CONFIG, +} from '@/services/constants'; + +// repro-4439.epub: a decorative table-of-contents page laid out as nested +// tables. The inner contents table has a negative top margin and the CONTENTS +// heading uses line-height:1em. Wrapping the table in a `.scroll-wrapper` +// (#4400) turned it into an overflow:auto clip box, and the negative margin bled +// the top of the heading above that box, cutting the glyphs in half (#4439). +const TOC_EPUB_URL = new URL('../fixtures/data/repro-4439.epub', import.meta.url).href; +// The contents page is the second spine item (cover, contents, ch1). +const TOC_SECTION_INDEX = 1; +const TOLERANCE_PX = 2; + +const loadEPUB = async (url: string, name: string) => { + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const file = new File([buffer], name, { type: 'application/epub+zip' }); + const { book } = await new DocumentLoader(file).open(); + return book; +}; + +const makeViewSettings = (): ViewSettings => + ({ + ...DEFAULT_BOOK_FONT, + ...DEFAULT_BOOK_LAYOUT, + ...DEFAULT_BOOK_STYLE, + ...DEFAULT_BOOK_LANGUAGE, + ...DEFAULT_VIEW_CONFIG, + ...DEFAULT_TTS_CONFIG, + ...DEFAULT_TRANSLATOR_CONFIG, + ...DEFAULT_ANNOTATOR_CONFIG, + ...DEFAULT_SCREEN_CONFIG, + }) as ViewSettings; + +const waitForStabilized = (el: HTMLElement, timeout = 10000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const getSectionDoc = (paginator: Renderer, index: number): Document | null => + paginator.getContents().find((c) => c.index === index)?.doc ?? null; + +// Nearest ancestor that clips its overflow (the scroll-wrapper, when scrollable). +const nearestClipAncestor = (el: HTMLElement): HTMLElement | null => { + let cur: HTMLElement | null = el.parentElement; + const win = el.ownerDocument.defaultView!; + while (cur) { + if (win.getComputedStyle(cur).overflow !== 'visible') return cur; + cur = cur.parentElement; + } + return null; +}; + +let tocBook: BookDoc; + +describe('Paginator decorative TOC table', () => { + let paginator: Renderer; + + beforeAll(async () => { + tocBook = await loadEPUB(TOC_EPUB_URL, 'repro-4439.epub'); + await import('foliate-js/paginator.js'); + }, 30000); + + afterEach(() => { + if (paginator) { + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + }); + + it('does not clip the top of a heading inside a wrapped layout table', async () => { + const el = document.createElement('foliate-paginator') as Renderer; + Object.assign(el.style, { + width: '320px', + height: '800px', + position: 'absolute', + left: '0', + top: '0', + }); + document.body.appendChild(el); + paginator = el; + + paginator.open(tocBook); + paginator.setStyles?.(getStyles(makeViewSettings(), undefined, [])); + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: TOC_SECTION_INDEX }); + await stabilized; + + const doc = getSectionDoc(paginator, TOC_SECTION_INDEX); + expect(doc, 'contents section doc').toBeTruthy(); + paginator.setStyles?.(getStyles(makeViewSettings(), undefined, [])); + applyScrollableStyle(doc!); + + const heading = [...doc!.querySelectorAll('p.title')].find((p) => + /CONTENTS/.test(p.textContent ?? ''), + ); + expect(heading, 'CONTENTS heading').toBeTruthy(); + + // It must sit inside a scroll-wrapper (otherwise the test would not be + // exercising the clip path that #4400 introduced). + expect(heading!.closest(`.${SCROLL_WRAPPER_CLASS}`), 'heading is wrapped').toBeTruthy(); + + const clip = nearestClipAncestor(heading!); + if (clip) { + const headTop = heading!.getBoundingClientRect().top; + const clipTop = clip.getBoundingClientRect().top; + expect( + headTop, + `CONTENTS top (${headTop.toFixed(1)}) is clipped above its overflow box top (${clipTop.toFixed(1)})`, + ).toBeGreaterThanOrEqual(clipTop - TOLERANCE_PX); + } + }, 60000); +}); diff --git a/apps/readest-app/src/__tests__/document/paginator-turn-styles.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-turn-styles.browser.test.ts new file mode 100644 index 000000000..a46c39ef1 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-turn-styles.browser.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +// Tests for readest#555: Apple Books style page-turn animations. The `slide` +// and `curl` turn styles layer a View Transitions snapshot of the outgoing +// page over the live incoming page, so the page underneath stays still while +// the top page slides away or curls open. When the View Transitions API is +// unavailable the paginator falls back to the existing push animation. + +const LTR_EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; +const VERTICAL_EPUB_URL = new URL('../fixtures/data/sample-vertical-rl.epub', import.meta.url).href; + +let ltrBook: BookDoc; +let verticalBook: BookDoc; + +const loadEPUB = async (url: string, name: string) => { + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const file = new File([buffer], name, { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 10000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('Page turn styles (browser)', () => { + let paginator: Renderer; + + beforeAll(async () => { + ltrBook = await loadEPUB(LTR_EPUB_URL, 'sample-alice.epub'); + verticalBook = await loadEPUB(VERTICAL_EPUB_URL, 'sample-vertical-rl.epub'); + await import('foliate-js/paginator.js'); + }, 30000); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as Renderer; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + document.body.appendChild(el); + return el; + }; + + const setup = async (book: BookDoc, style: string, index = 3) => { + paginator = createPaginator(); + paginator.setAttribute('animated', ''); + paginator.setAttribute('turn-style', style); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index }); + await stabilized; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + // A transition may still be running; let it finish before the next test. + await wait(600); + }); + + /** + * Sample the live view-transition animations mid-turn. Animation objects on + * the ::view-transition pseudos only exist while a transition is actually + * running (unlike getComputedStyle, which reports matched rules even + * without an active transition), and a layer styled `animation: none` has + * no entry at all — proving it sits still. + */ + const sampleTransition = async (timeout = 600) => { + const t0 = performance.now(); + while (performance.now() - t0 < timeout) { + const animations = document + .getAnimations() + .filter((a) => + (a.effect as KeyframeEffect | null)?.pseudoElement?.includes('(foliate-turn)'), + ); + if (animations.length) { + const byPseudo: Record = {}; + for (const a of animations) { + const pseudo = (a.effect as KeyframeEffect).pseudoElement!; + byPseudo[pseudo.replace('(foliate-turn)', '')] = (a as CSSAnimation).animationName; + } + return { + oldAnim: byPseudo['::view-transition-old'] ?? 'none', + newAnim: byPseudo['::view-transition-new'] ?? 'none', + }; + } + await new Promise((r) => requestAnimationFrame(r)); + } + return null; + }; + + it('slide keeps the incoming page still while the outgoing page slides away', async () => { + await setup(ltrBook, 'slide'); + const size = paginator.size; + const before = paginator.containerPosition; + + const turn = paginator.next(); + const sampled = await sampleTransition(); + expect(sampled).not.toBeNull(); + // Forward: the outgoing snapshot animates out; the incoming page has no + // motion of its own (it sits still underneath). + expect(sampled!.oldAnim).toContain('foliate-turn-slide-out'); + expect(sampled!.newAnim).toBe('none'); + await turn; + // The live content jumped to the destination under the snapshot. + expect(paginator.containerPosition).toBe(before + size); + + const back = paginator.prev(); + const sampledBack = await sampleTransition(); + expect(sampledBack).not.toBeNull(); + // Backward: the incoming snapshot slides in over the still outgoing page. + expect(sampledBack!.newAnim).toContain('foliate-turn-slide-in'); + expect(sampledBack!.oldAnim).toBe('none'); + await back; + expect(paginator.containerPosition).toBe(before); + }); + + it('curl folds the outgoing page open over the incoming page', async () => { + await setup(ltrBook, 'curl'); + const before = paginator.containerPosition; + const size = paginator.size; + + const turn = paginator.next(); + const sampled = await sampleTransition(); + expect(sampled).not.toBeNull(); + // Forward: the outgoing page folds away (an animated clip edge sweeps + // toward the spine); the incoming page sits still underneath. + expect(sampled!.oldAnim).toContain('foliate-turn-curl-fold'); + expect(sampled!.newAnim).toBe('none'); + // The fold visibly travels: the animated gradient stop re-rasterizes the + // mask, so the computed mask image changes over time. + const maskOf = () => + getComputedStyle(document.documentElement, '::view-transition-old(foliate-turn)').maskImage; + const maskA = maskOf(); + await wait(120); + const maskB = maskOf(); + expect(maskA).toContain('radial-gradient'); + expect(maskB).not.toBe(maskA); + await turn; + expect(paginator.containerPosition).toBe(before + size); + + const back = paginator.prev(); + const sampledBack = await sampleTransition(); + expect(sampledBack).not.toBeNull(); + // Backward: the outgoing page recedes from the spine side (Chrome does + // not paint masks on the live new layer), revealing the previous page. + expect(sampledBack!.oldAnim).toContain('foliate-turn-curl-fold'); + expect(sampledBack!.newAnim).toBe('none'); + await back; + expect(paginator.containerPosition).toBe(before); + }); + + it('works for vertical-rl books where pages stack along the scroll axis', async () => { + await setup(verticalBook, 'slide', 0); + const size = paginator.size; + const before = paginator.containerPosition; + + const turn = paginator.next(); + const sampled = await sampleTransition(); + expect(sampled).not.toBeNull(); + expect(sampled!.oldAnim).toContain('foliate-turn-slide-out'); + await turn; + expect(paginator.containerPosition).toBe(before + size); + }); + + const makeTouch = (x: number, y: number) => + new Touch({ identifier: 1, target: paginator, screenX: x, screenY: y, clientX: x, clientY: y }); + + const fireTouch = (type: string, x: number, y: number) => + paginator.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [makeTouch(x, y)], + changedTouches: [makeTouch(x, y)], + }), + ); + + /** The scrubbed turn's paused animations, keyed for inspection. */ + const scrubbedAnimations = () => + document + .getAnimations() + .filter((a) => + (a.effect as KeyframeEffect | null)?.pseudoElement?.includes('(foliate-turn)'), + ); + + it('tracks the finger: the paused snapshot follows the drag and commits on release', async () => { + await setup(ltrBook, 'slide'); + const page = paginator.page; + + // ltr: finger moves LEFT to go forward. + let x = 700; + fireTouch('touchstart', x, 300); + for (let i = 0; i < 6; i++) { + x -= 30; + fireTouch('touchmove', x, 300); + await wait(16); + } + // Mid-drag: the transition exists, is paused, and its progress tracks the + // finger (~180px of an 800px-wide page). + const anims = scrubbedAnimations(); + expect(anims.length).toBeGreaterThan(0); + expect(anims.every((a) => a.playState === 'paused')).toBe(true); + const timeA = Number(anims[0]!.currentTime); + expect(timeA).toBeGreaterThan(0); + x -= 60; + fireTouch('touchmove', x, 300); + await wait(30); + const timeB = Number(anims[0]!.currentTime); + expect(timeB).toBeGreaterThan(timeA); + + fireTouch('touchend', x, 300); + const t0 = performance.now(); + while (paginator.page !== page + 1 && performance.now() - t0 < 2000) await wait(50); + expect(paginator.page).toBe(page + 1); + }); + + it('tracks the finger: a mostly-returned drag reverses without turning', async () => { + await setup(ltrBook, 'slide'); + const page = paginator.page; + const before = paginator.containerPosition; + + let x = 700; + fireTouch('touchstart', x, 300); + for (let i = 0; i < 6; i++) { + x -= 30; + fireTouch('touchmove', x, 300); + await wait(16); + } + expect(scrubbedAnimations().length).toBeGreaterThan(0); + // Finger returns, rests, lifts: cancel. + for (let i = 0; i < 5; i++) { + x += 30; + fireTouch('touchmove', x, 300); + await wait(16); + } + await wait(150); + fireTouch('touchend', x, 300); + await wait(700); + expect(paginator.page).toBe(page); + expect(paginator.containerPosition).toBe(before); + expect(scrubbedAnimations().length).toBe(0); + }); + + it('falls back to the push animation when view transitions are unavailable', async () => { + const original = document.startViewTransition; + // @ts-expect-error simulate an engine without the View Transitions API + document.startViewTransition = undefined; + try { + await setup(ltrBook, 'slide'); + const container = paginator.shadowRoot!.getElementById('container')!; + const before = paginator.containerPosition; + const size = paginator.size; + + const turn = paginator.next(); + // The push fallback animates the strip with per-view transforms. + let sawTransform = false; + const t0 = performance.now(); + while (performance.now() - t0 < 500) { + const child = container.children[0] as HTMLElement | undefined; + const transform = child && getComputedStyle(child).transform; + if (transform && transform !== 'none') { + sawTransform = true; + break; + } + await new Promise((r) => requestAnimationFrame(r)); + } + expect(sawTransform).toBe(true); + await turn; + expect(paginator.containerPosition).toBe(before + size); + } finally { + document.startViewTransition = original; + } + }); +}); diff --git a/apps/readest-app/src/__tests__/document/paginator-vertical-rl.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-vertical-rl.browser.test.ts new file mode 100644 index 000000000..2f09b0853 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-vertical-rl.browser.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +// Regression tests for readest#624: books with writing-mode: vertical-rl +// (Japanese/Chinese vertical text) must turn pages horizontally like printed +// vertical books. CSS fragmentation stacks vertical-rl pages top-to-bottom, so +// the scroll axis stays vertical internally, but the page-turn INPUTS are +// horizontal (swipe right = next for vertical-rl) and turns swap instantly +// instead of sliding along the vertical scroll axis. + +const VERTICAL_EPUB_URL = new URL('../fixtures/data/sample-vertical-rl.epub', import.meta.url).href; +const HORIZONTAL_EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href; + +// The paginator's swipe entry point isn't part of the app-facing Renderer type. +type SwipePaginator = Renderer & { + snap: (vx: number, vy: number, dx: number, dy: number, dt: number) => void; +}; + +let verticalBook: BookDoc; +let horizontalBook: BookDoc; +let getDirection: (doc: Document) => { vertical: boolean; rtl: boolean }; + +const loadEPUB = async (url: string, name: string) => { + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const file = new File([buffer], name, { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 10000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Poll until the paginator lands on the expected page or timeout. */ +const waitForPage = async (el: Renderer, page: number, timeout = 3000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (el.page === page) return; + await wait(50); + } +}; + +describe('Vertical-rl pagination (browser)', () => { + let paginator: SwipePaginator; + + beforeAll(async () => { + verticalBook = await loadEPUB(VERTICAL_EPUB_URL, 'sample-vertical-rl.epub'); + horizontalBook = await loadEPUB(HORIZONTAL_EPUB_URL, 'sample-alice.epub'); + ({ getDirection } = (await import('foliate-js/paginator.js')) as unknown as { + getDirection: (doc: Document) => { vertical: boolean; rtl: boolean }; + }); + }, 30000); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as SwipePaginator; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + document.body.appendChild(el); + return el; + }; + + const setup = async (book: BookDoc, index = 0) => { + paginator = createPaginator(); + paginator.open(book); + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index }); + await stabilized; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + }); + + it('marks vertical-rl documents as rtl in getDirection', async () => { + await setup(verticalBook); + const contents = paginator.getContents(); + const doc = contents[0]!.doc; + expect(getDirection(doc)).toEqual({ vertical: true, rtl: true }); + }); + + const makeTouch = (x: number, y: number) => + new Touch({ identifier: 1, target: paginator, screenX: x, screenY: y, clientX: x, clientY: y }); + + const fireTouch = (type: string, x: number, y: number) => + paginator.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [makeTouch(x, y)], + changedTouches: [makeTouch(x, y)], + }), + ); + + const viewTransform = () => { + const container = paginator.shadowRoot!.getElementById('container')!; + const child = container.children[0] as HTMLElement | undefined; + const transform = child && getComputedStyle(child).transform; + return transform && transform !== 'none' ? new DOMMatrix(transform) : null; + }; + + it('tracks the finger horizontally during a drag', async () => { + await setup(verticalBook); + paginator.setAttribute('animated', ''); + const page = paginator.page; + + let x = 700; + fireTouch('touchstart', x, 400); + for (let i = 0; i < 5; i++) { + x += 30; + fireTouch('touchmove', x, 400); + await wait(16); + } + // Mid-drag the page follows the finger to the right, horizontally only. + const midDrag = viewTransform(); + expect(midDrag).not.toBeNull(); + expect(midDrag!.m41).toBeGreaterThan(0); + expect(midDrag!.m42).toBe(0); + + fireTouch('touchend', x, 400); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); + + it('reverts an aborted drag without turning the page', async () => { + await setup(verticalBook); + paginator.setAttribute('animated', ''); + const page = paginator.page; + + let x = 700; + fireTouch('touchstart', x, 400); + for (let i = 0; i < 5; i++) { + x += 30; + fireTouch('touchmove', x, 400); + await wait(16); + } + expect(viewTransform()?.m41 ?? 0).toBeGreaterThan(0); + // Finger mostly returns, rests, then lifts: the drag is below the commit + // threshold and there is no flick, so the page must not turn. + for (let i = 0; i < 4; i++) { + x -= 30; + fireTouch('touchmove', x, 400); + await wait(16); + } + await wait(150); + fireTouch('touchend', x, 400); + await wait(400); + expect(paginator.page).toBe(page); + expect(viewTransform()?.m41 ?? 0).toBe(0); + }); + + /** + * Sample the mid-flight view transforms of a page turn, tagging each sample + * with the scroll offset it was taken at. The two-phase slide jumps the + * scroll at its midpoint, so samples at the starting offset belong to the + * exit phase and samples at any other offset to the enter phase. + */ + const sampleTurn = async (startPos: number, timeout = 700) => { + const container = paginator.shadowRoot!.getElementById('container')!; + const exit: DOMMatrix[] = []; + const enter: DOMMatrix[] = []; + const t0 = performance.now(); + while (performance.now() - t0 < timeout) { + const child = container.children[0] as HTMLElement | undefined; + const transform = child && getComputedStyle(child).transform; + if (transform && transform !== 'none') { + const m = new DOMMatrix(transform); + if (m.m41 !== 0 || m.m42 !== 0) { + (paginator.containerPosition === startPos ? exit : enter).push(m); + } + } + await new Promise((r) => requestAnimationFrame(r)); + } + return { exit, enter }; + }; + + it('slides pages horizontally (not along the scroll axis) when animated', async () => { + await setup(verticalBook); + paginator.setAttribute('animated', ''); + const size = paginator.size; + const before = paginator.containerPosition; + + const turn = paginator.next(); + const forward = await sampleTurn(before); + // Forward in vertical-rl reads right-to-left: the outgoing page exits to + // the right and the incoming page enters from the left, horizontal only. + const forwardSamples = [...forward.exit, ...forward.enter]; + expect(forwardSamples.length).toBeGreaterThan(0); + expect(forwardSamples.every((m) => m.m42 === 0)).toBe(true); + expect(forward.exit.every((m) => m.m41 > 0)).toBe(true); + expect(forward.enter.every((m) => m.m41 < 0)).toBe(true); + await turn; + expect(paginator.containerPosition).toBe(before + size); + + const back = paginator.prev(); + const backward = await sampleTurn(before + size); + const backwardSamples = [...backward.exit, ...backward.enter]; + expect(backwardSamples.length).toBeGreaterThan(0); + expect(backwardSamples.every((m) => m.m42 === 0)).toBe(true); + expect(backward.exit.every((m) => m.m41 < 0)).toBe(true); + expect(backward.enter.every((m) => m.m41 > 0)).toBe(true); + await back; + expect(paginator.containerPosition).toBe(before); + }); + + it('swaps pages instantly when animation is disabled', async () => { + await setup(verticalBook); + const size = paginator.size; + const before = paginator.containerPosition; + await paginator.next(); + expect(paginator.containerPosition).toBe(before + size); + }); + + it('turns to the next page on a rightward swipe (vertical-rl reads right-to-left)', async () => { + await setup(verticalBook); + const page = paginator.page; + // Finger moves right: vx/dx negative in the paginator's swipe deltas. + paginator.snap(-1.2, 0, -150, 0, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); + + it('turns back on a leftward swipe in vertical-rl', async () => { + await setup(verticalBook); + const page = paginator.page; + paginator.snap(-1.2, 0, -150, 0, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + + // Finger moves left: vx/dx positive. + paginator.snap(1.2, 0, 150, 0, 120); + await waitForPage(paginator, page); + expect(paginator.page).toBe(page); + }); + + it('still pages forward on the legacy upward swipe for vertical books', async () => { + await setup(verticalBook); + const page = paginator.page; + // Finger moves up: vy/dy positive. + paginator.snap(0, 1.2, 0, 150, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); + + it('keeps leftward-swipe-to-advance for horizontal ltr books', async () => { + // Index 3 is the first chapter (multi-page); earlier spine items are + // single-page cover/title sections a swipe would step across. + await setup(horizontalBook, 3); + const page = paginator.page; + // Finger moves left: vx/dx positive → next page in ltr. + paginator.snap(1.2, 0, 150, 0, 120); + await waitForPage(paginator, page + 1); + expect(paginator.page).toBe(page + 1); + }); +}); diff --git a/apps/readest-app/src/__tests__/document/pdf-spread-seam.test.ts b/apps/readest-app/src/__tests__/document/pdf-spread-seam.test.ts new file mode 100644 index 000000000..4a92063de --- /dev/null +++ b/apps/readest-app/src/__tests__/document/pdf-spread-seam.test.ts @@ -0,0 +1,134 @@ +// Regression test for readest/readest issue #4587. +// +// In a PDF two-page spread at a fractional device pixel ratio (Windows display +// scale 150% -> devicePixelRatio 1.5), a one-pixel white bar appeared in the +// middle of the spread on certain zoom levels. +// +// Root cause: `render()` sized the page canvas only via its bitmap dimensions +// (`canvas.width = viewport.width`). A canvas bitmap width is an integer, so +// the fractional `viewport.width` (= pageWidthCss * devicePixelRatio) was +// truncated. The iframe content is displayed scaled by `1 / devicePixelRatio`, +// so the truncated bitmap rendered up to ~1 device pixel narrower than the page +// box. The left page's canvas therefore stopped short of the spine, exposing +// the background as a thin white seam. +// +// The fix gives the canvas an explicit CSS size equal to the *un-truncated* +// viewport dimensions, so the bitmap is scaled to fill the page box exactly and +// the left page reaches the spine regardless of bitmap truncation. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// US Letter, the size used by the test fixture PDFs. +const PAGE_W = 612; +const PAGE_H = 792; + +// Minimal stand-in for the vendored pdf.js build. foliate-js/pdf.js imports it +// for the side effect of setting globalThis.pdfjsLib, then reads from that +// global — so the mock installs a controllable fake there. +vi.mock('@pdfjs/pdf.min.mjs', () => { + class PDFDataRangeTransport { + requestDataRange!: (begin: number, end: number) => void; + onDataRange = vi.fn(); + constructor( + public length: number, + public initialData: unknown, + ) {} + } + const makePage = () => ({ + getViewport: ({ scale }: { scale: number }) => ({ + width: PAGE_W * scale, + height: PAGE_H * scale, + scale, + }), + render: () => ({ promise: Promise.resolve(), cancel: () => {} }), + streamTextContent: () => ({}), + getTextContent: async () => ({ items: [] }), + getAnnotations: async () => [], + cleanup: () => {}, + }); + const fakePdf = { + numPages: 3, + getPage: vi.fn(async () => makePage()), + getMetadata: vi.fn(async () => ({ metadata: undefined, info: {} })), + getOutline: vi.fn(async () => null), + getDestination: vi.fn(), + getPageIndex: vi.fn(), + destroy: vi.fn(), + }; + class TextLayer { + render = async () => {}; + } + class AnnotationLayer { + render = async () => {}; + } + (globalThis as unknown as { pdfjsLib: unknown }).pdfjsLib = { + GlobalWorkerOptions: {}, + PDFDataRangeTransport, + getDocument: vi.fn(() => ({ promise: Promise.resolve(fakePdf) })), + TextLayer, + AnnotationLayer, + }; + return {}; +}); + +beforeEach(() => { + // Windows display scale 150%. + vi.stubGlobal('devicePixelRatio', 1.5); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ text: async () => '' })), + ); + URL.createObjectURL = vi.fn(() => 'blob:mock'); + URL.revokeObjectURL = vi.fn(); + // jsdom has no 2D context; render() only forwards it to the mocked + // page.render(), so a null context is fine. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('PDF spread canvas seam (#4587)', () => { + it('sizes the page canvas to fill its box exactly at fractional devicePixelRatio', async () => { + const { makePDF } = await import('foliate-js/pdf.js'); + + const file = { size: 1024, slice: () => ({ arrayBuffer: async () => new ArrayBuffer(0) }) }; + const book = (await makePDF(file as unknown as File)) as unknown as { + sections: { load: () => Promise<{ onZoom: (arg: unknown) => Promise }> }[]; + }; + const { onZoom } = await book.sections[0]!.load(); + + // render() bails out when the document has no `defaultView`, so drive it + // through a real iframe document (which has one) rather than a detached one. + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const doc = iframe.contentDocument!; + doc.body.innerHTML = + '
'; + + // A zoom whose page-box width forces the device-pixel viewport width to be + // fractional: 612 * zoom * 1.5 must not be an integer. + const zoom = 0.8523; + const pageBoxWidth = PAGE_W * zoom; // CSS px width of the page box + const viewportWidth = pageBoxWidth * 1.5; // un-truncated device-pixel width + + await onZoom({ doc, scale: zoom, pageColors: null }); + + const canvas = doc.querySelector('#canvas canvas') as HTMLCanvasElement; + expect(canvas).toBeTruthy(); + + // Bitmap width is an integer and is truncated below the ideal device size — + // this is the truncation that, left to drive layout, produced the seam. + expect(Number.isInteger(canvas.width)).toBe(true); + expect(canvas.width).toBeLessThan(viewportWidth); + + // The fix: an explicit CSS size equal to the un-truncated viewport width, so + // the page fills its box exactly (displayed width = box width) and the left + // page reaches the spine — no white seam. + expect(canvas.style.width).not.toBe(''); + expect(parseFloat(canvas.style.width)).toBeCloseTo(viewportWidth, 3); + expect(parseFloat(canvas.style.width) / 1.5).toBeCloseTo(pageBoxWidth, 3); + }); +}); diff --git a/apps/readest-app/src/__tests__/fixtures/data/repro-4439.epub b/apps/readest-app/src/__tests__/fixtures/data/repro-4439.epub new file mode 100644 index 000000000..8bbd4efaa Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/repro-4439.epub differ diff --git a/apps/readest-app/src/__tests__/fixtures/data/sample-vertical-rl.epub b/apps/readest-app/src/__tests__/fixtures/data/sample-vertical-rl.epub new file mode 100644 index 000000000..1b4168833 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/sample-vertical-rl.epub differ diff --git a/apps/readest-app/src/__tests__/foliate-calibre-user-metadata.test.ts b/apps/readest-app/src/__tests__/foliate-calibre-user-metadata.test.ts new file mode 100644 index 000000000..5d7ab2cb7 --- /dev/null +++ b/apps/readest-app/src/__tests__/foliate-calibre-user-metadata.test.ts @@ -0,0 +1,154 @@ +// Feature test for surfacing Calibre custom columns (readest#4811). +// +// Calibre embeds its per-library custom columns ("user metadata") into the +// OPF when polishing / sending books, and the Readest calibre plugin embeds +// them on push. Two encodings exist (see calibre's opf2.py / opf3.py): +// +// OPF 2: one +// element per column +// OPF 3: a single element whose +// text is a JSON dict of all columns keyed by "#label" +// +// The per-column JSON carries the value in `#value#` (series index in +// `#extra#`); datetimes are wrapped as +// {"__class__": "datetime.datetime", "__value__": ""} and unset dates +// are 0101-01-01. Embedded files include EVERY column of the library, so +// empty values must be dropped at parse time. +import { describe, expect, it } from 'vitest'; + +import { parseEpubMetadataFromXML } from 'foliate-js/epub.js'; +import { BookMetadata } from '@/libs/document'; + +// foliate-js is plain JS; its inferred metadata type does not carry the +// dynamically attached calibreColumns field (same cast as tauriEpubBridge.ts). +const parse = parseEpubMetadataFromXML as unknown as (xml: string) => { metadata: BookMetadata }; + +const opf2 = (metas: string) => ` + + + Test Book + Test Author + 11111111-2222-3333-4444-555555555555 + en + ${metas} + + + +`; + +const opf3 = (metas: string) => ` + + + Test Book + urn:uuid:11111111-2222-3333-4444-555555555555 + en + 2024-01-01T00:00:00Z + ${metas} + + + +`; + +const RECOMMENDS_OPF2 = ``; + +describe('Calibre custom columns from OPF 2 legacy metas', () => { + it('parses per-column metas into metadata.calibreColumns', () => { + const xml = opf2(`${RECOMMENDS_OPF2} + `); + const { metadata } = parse(xml); + expect(metadata.calibreColumns).toEqual([ + { + label: 'recommends', + name: 'Recommends', + datatype: 'text', + value: ['TOD', 'Grandma'], + }, + { label: 'myrating', name: 'My Rating', datatype: 'rating', value: 8 }, + ]); + }); + + it('drops empty-valued columns (embedded files carry every library column)', () => { + const xml = opf2(`${RECOMMENDS_OPF2} + + + + `); + const { metadata } = parse(xml); + expect(metadata.calibreColumns?.map((c) => c.label)).toEqual(['recommends']); + }); + + it('ignores malformed column JSON and non-column calibre metas', () => { + const xml = opf2(` + + ${RECOMMENDS_OPF2}`); + const { metadata } = parse(xml); + expect(metadata.calibreColumns?.map((c) => c.label)).toEqual(['recommends']); + }); + + it('leaves calibreColumns undefined when no user metadata is embedded', () => { + const { metadata } = parse(opf2('')); + expect(metadata.calibreColumns).toBeUndefined(); + }); +}); + +describe('Calibre custom columns from the OPF 3 property meta', () => { + it('parses the single user_metadata dict, unwrapping datetime values', () => { + const dict = JSON.stringify({ + '#read': { name: 'Read', label: 'read', datatype: 'bool', '#value#': true, '#extra#': null }, + '#lastread': { + name: 'Last Read', + label: 'lastread', + datatype: 'datetime', + '#value#': { __class__: 'datetime.datetime', __value__: '2024-03-01T10:00:00+00:00' }, + '#extra#': null, + }, + '#saga': { + name: 'My Saga', + label: 'saga', + datatype: 'series', + '#value#': 'Cool Saga', + '#extra#': 2.0, + }, + '#solo': { + name: 'Solo Tag', + label: 'solo', + datatype: 'text', + is_multiple: ',', + '#value#': ['Only'], + '#extra#': null, + }, + '#undated': { + name: 'Undated', + label: 'undated', + datatype: 'datetime', + '#value#': { __class__: 'datetime.datetime', __value__: '0101-01-01T00:00:00+00:00' }, + '#extra#': null, + }, + '#unset': { name: 'Unset', label: 'unset', datatype: 'text', '#value#': null }, + }); + const xml = opf3(`${dict}`); + const { metadata } = parse(xml); + expect(metadata.calibreColumns).toEqual([ + { label: 'read', name: 'Read', datatype: 'bool', value: true }, + { + label: 'lastread', + name: 'Last Read', + datatype: 'datetime', + value: '2024-03-01T10:00:00+00:00', + }, + { label: 'saga', name: 'My Saga', datatype: 'series', value: 'Cool Saga', extra: 2 }, + // a single-element multi-value column must stay an array + { label: 'solo', name: 'Solo Tag', datatype: 'text', value: ['Only'] }, + ]); + }); + + it('prefers the OPF 3 dict over legacy per-column metas, like calibre does', () => { + const dict = JSON.stringify({ + '#new': { name: 'New', label: 'new', datatype: 'text', '#value#': 'from-opf3' }, + }); + const xml = opf3(`${dict} + `); + const { metadata } = parse(xml); + expect(metadata.calibreColumns?.map((c) => c.label)).toEqual(['new']); + }); +}); diff --git a/apps/readest-app/src/__tests__/foliate-search-modes.test.ts b/apps/readest-app/src/__tests__/foliate-search-modes.test.ts new file mode 100644 index 000000000..d6dbf5206 --- /dev/null +++ b/apps/readest-app/src/__tests__/foliate-search-modes.test.ts @@ -0,0 +1,109 @@ +// Tests for the Calibre-parity search modes added in #4560: +// - regex : JS RegExp over the section's joined text +// - nearby-words : whole words occurring within N words of each other +// +// These exercise `search(strs, query, opts)` directly (the same unit seam the +// existing excerpt regression test uses), where `strs` is the per-text-node +// string array `textWalker` produces. +import { describe, expect, it } from 'vitest'; + +import { search } from 'foliate-js/search.js'; + +interface Excerpt { + pre: string; + match: string; + post: string; + segments?: { text: string; emphasized: boolean }[]; +} +interface FlatRange { + startIndex: number; + startOffset: number; + endIndex: number; + endOffset: number; +} +interface Result { + range: FlatRange; + excerpt: Excerpt; + subRanges?: FlatRange[]; +} + +const run = (strs: string[], query: string, opts: Record): Result[] => + [...search(strs, query, opts)] as Result[]; + +describe('regex mode', () => { + it('matches a pattern and reports the matched text', () => { + const r = run(['the cat sat on the mat'], 'c.t', { mode: 'regex' }); + expect(r.map((x) => x.excerpt.match)).toEqual(['cat']); + }); + + it('is case-insensitive by default and case-sensitive with matchCase', () => { + expect(run(['Cat cat'], 'cat', { mode: 'regex' }).length).toBe(2); + const cased = run(['Cat cat'], 'cat', { mode: 'regex', matchCase: true }); + expect(cased.length).toBe(1); + expect(cased[0]!.excerpt.match).toBe('cat'); + }); + + it('maps a match spanning multiple text nodes back to a range', () => { + const r = run(['the qu', 'ick fox'], 'qu.ck', { mode: 'regex' }); + expect(r.length).toBe(1); + expect(r[0]!.excerpt.match).toBe('quick'); + expect(r[0]!.range.startIndex).toBe(0); + expect(r[0]!.range.endIndex).toBe(1); + }); + + it('does not loop forever on a zero-width pattern', () => { + expect(run(['abc'], 'x*', { mode: 'regex' })).toEqual([]); + }); + + it('throws a typed error on an invalid pattern', () => { + expect(() => run(['abc'], '(', { mode: 'regex' })).toThrow(/regular expression/i); + try { + run(['abc'], '(', { mode: 'regex' }); + } catch (e) { + expect((e as { code?: string }).code).toBe('INVALID_REGEX'); + } + }); +}); + +describe('nearby-words mode', () => { + const opts = (nearbyWords: number) => ({ mode: 'nearby-words', nearbyWords }); + + it('matches when all words fall within N words of each other', () => { + const r = run(['alpha one two three beta end'], 'alpha beta', opts(10)); + expect(r.length).toBe(1); + expect(r[0]!.excerpt.match).toContain('alpha'); + expect(r[0]!.excerpt.match).toContain('beta'); + expect(r[0]!.subRanges?.length).toBe(2); + }); + + it('rejects clusters whose word span exceeds N', () => { + expect(run(['alpha one two three beta'], 'alpha beta', opts(2)).length).toBe(0); + }); + + it('is order independent', () => { + const r = run(['beta x y alpha'], 'alpha beta', opts(10)); + expect(r.length).toBe(1); + }); + + it('emits one cluster per non-overlapping occurrence pair', () => { + const r = run(['alpha beta gamma delta alpha beta'], 'alpha beta', opts(3)); + expect(r.length).toBe(2); + }); + + it('produces a segmented excerpt emphasizing only the matched words', () => { + const r = run(['alpha one beta'], 'alpha beta', opts(10)); + const segments = r[0]!.excerpt.segments ?? []; + const emphasized = segments.filter((s) => s.emphasized).map((s) => s.text); + expect(emphasized).toEqual(['alpha', 'beta']); + expect(segments.some((s) => !s.emphasized && s.text.includes('one'))).toBe(true); + }); + + it('throws when fewer than two distinct words are given', () => { + expect(() => run(['alpha alpha'], 'alpha', opts(10))).toThrow(); + try { + run(['alpha'], 'alpha', opts(10)); + } catch (e) { + expect((e as { code?: string }).code).toBe('NEARBY_NEEDS_TWO_WORDS'); + } + }); +}); diff --git a/apps/readest-app/src/__tests__/helpers/auth.test.ts b/apps/readest-app/src/__tests__/helpers/auth.test.ts index b9d17763a..d4f61c2a1 100644 --- a/apps/readest-app/src/__tests__/helpers/auth.test.ts +++ b/apps/readest-app/src/__tests__/helpers/auth.test.ts @@ -14,7 +14,55 @@ vi.mock('@/utils/supabase', () => ({ }, })); -import { handleAuthCallback } from '@/helpers/auth'; +import { handleAuthCallback, parseOAuthCallbackUrl } from '@/helpers/auth'; + +describe('parseOAuthCallbackUrl', () => { + it('should extract error params from a failed deeplink callback', () => { + // Real-world failing callback from issue #4881 + const url = + 'readest://auth-callback?error=server_error&error_code=unexpected_failure&error_description=Unable+to+exchange+external+code%3A+c578' + + '#error=server_error&error_code=unexpected_failure&error_description=Unable+to+exchange+external+code%253A+c578&sb='; + + const params = parseOAuthCallbackUrl(url); + + expect(params.error).toBe('server_error'); + expect(params.errorCode).toBe('unexpected_failure'); + expect(params.errorDescription).toContain('Unable to exchange external code'); + expect(params.accessToken).toBeNull(); + expect(params.refreshToken).toBeNull(); + }); + + it('should extract tokens from a successful implicit-flow callback', () => { + const url = + 'readest://auth-callback#access_token=abc123&refresh_token=def456&type=magiclink&next=/user'; + + const params = parseOAuthCallbackUrl(url); + + expect(params.accessToken).toBe('abc123'); + expect(params.refreshToken).toBe('def456'); + expect(params.type).toBe('magiclink'); + expect(params.next).toBe('/user'); + expect(params.error).toBeNull(); + }); + + it('should extract error params when present only in the query string', () => { + const url = + 'readest://auth-callback?error=access_denied&error_code=denied&error_description=nope'; + + const params = parseOAuthCallbackUrl(url); + + expect(params.error).toBe('access_denied'); + expect(params.errorCode).toBe('denied'); + expect(params.accessToken).toBeNull(); + }); + + it('should return all-null params for a callback with no data', () => { + const params = parseOAuthCallbackUrl('readest://auth-callback'); + + expect(params.accessToken).toBeNull(); + expect(params.error).toBeNull(); + }); +}); describe('handleAuthCallback', () => { let mockLogin: ReturnType void>>; diff --git a/apps/readest-app/src/__tests__/helpers/open-with.test.ts b/apps/readest-app/src/__tests__/helpers/open-with.test.ts index dfd12938e..5111019c1 100644 --- a/apps/readest-app/src/__tests__/helpers/open-with.test.ts +++ b/apps/readest-app/src/__tests__/helpers/open-with.test.ts @@ -19,7 +19,7 @@ vi.mock('@tauri-apps/plugin-cli', () => ({ getMatches: () => mockGetMatches(), })); -import { parseOpenWithFiles } from '@/helpers/openWith'; +import { parseOpenWithFiles, shouldOpenTransient } from '@/helpers/openWith'; // Helper type matching the AppService subset used in openWith interface MockAppService { @@ -151,6 +151,23 @@ describe('parseOpenWithFiles', () => { expect(result).toBeNull(); }); + + test('degrades to intent when CLI arg parsing rejects (READEST-Y)', async () => { + // sentry-minidump relaunches the app with `--crash-reporter-server`, which + // the file-only CLI schema rejects. A rejected getMatches() must not leak + // an unhandled rejection; fall through to the intent path instead. + mockHasCli = true; + mockGetMatches.mockRejectedValue( + new Error( + "failed to parse arguments: error: unexpected argument '--crash-reporter-server' found", + ), + ); + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(result).toBeNull(); + }); }); // ── Intent / Deep Link ──────────────────────────────────────── @@ -260,3 +277,26 @@ describe('parseOpenWithFiles', () => { }); }); }); + +describe('shouldOpenTransient', () => { + // Android "Open with" (VIEW) is the only intent that can open a file as a + // transient book, and only when the user has turned auto-import off. + test('VIEW with auto-import off opens transiently', () => { + expect(shouldOpenTransient('VIEW', false)).toBe(true); + }); + + test('VIEW with auto-import on imports into the library', () => { + expect(shouldOpenTransient('VIEW', true)).toBe(false); + }); + + // Share-sheet SEND captures always import to the library regardless. + test('SEND always imports into the library', () => { + expect(shouldOpenTransient('SEND', false)).toBe(false); + expect(shouldOpenTransient('SEND', true)).toBe(false); + }); + + test('undefined action always imports into the library', () => { + expect(shouldOpenTransient(undefined, false)).toBe(false); + expect(shouldOpenTransient(undefined, true)).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/helpers/settings.test.ts b/apps/readest-app/src/__tests__/helpers/settings.test.ts index 1530e7c65..54a30b2b6 100644 --- a/apps/readest-app/src/__tests__/helpers/settings.test.ts +++ b/apps/readest-app/src/__tests__/helpers/settings.test.ts @@ -32,7 +32,7 @@ vi.mock('@/utils/style', () => ({ getStyles: vi.fn(() => ''), })); -import { saveViewSettings } from '@/helpers/settings'; +import { getLibraryViewSettings, saveViewSettings } from '@/helpers/settings'; import { useSettingsStore } from '@/store/settingsStore'; import type { EnvConfigType } from '@/services/environment'; import type { SystemSettings } from '@/types/settings'; @@ -55,6 +55,61 @@ beforeEach(() => { } as unknown as ReturnType); }); +describe('getLibraryViewSettings', () => { + const makeTextureSettings = (overrides: Partial = {}): SystemSettings => + ({ + globalViewSettings: { + backgroundTextureId: 'paper', + backgroundOpacity: 0.6, + backgroundSize: 'cover', + }, + ...overrides, + }) as unknown as SystemSettings; + + test('inherits the reader/global texture when no library override is set', () => { + const result = getLibraryViewSettings(makeTextureSettings()); + + expect(result.backgroundTextureId).toBe('paper'); + expect(result.backgroundOpacity).toBe(0.6); + expect(result.backgroundSize).toBe('cover'); + }); + + test('uses the library overrides when they are set', () => { + const result = getLibraryViewSettings( + makeTextureSettings({ + libraryBackgroundTextureId: 'none', + libraryBackgroundOpacity: 0.3, + libraryBackgroundSize: 'contain', + }), + ); + + expect(result.backgroundTextureId).toBe('none'); + expect(result.backgroundOpacity).toBe(0.3); + expect(result.backgroundSize).toBe('contain'); + }); + + test("tolerates the store's initial empty settings (no globalViewSettings yet)", () => { + // useSettingsStore starts as `{} as SystemSettings`; the library page's + // texture effect can resolve before appService.loadSettings() populates it. + // It must yield a usable "no texture" result instead of throwing. + const result = getLibraryViewSettings({} as SystemSettings); + + expect(result.backgroundTextureId).toBe('none'); + }); + + test('resolves each field independently — an unset field still inherits', () => { + // Only the texture id is decoupled; opacity/size were never touched and + // must keep tracking the reader/global values. + const result = getLibraryViewSettings( + makeTextureSettings({ libraryBackgroundTextureId: 'sand' }), + ); + + expect(result.backgroundTextureId).toBe('sand'); + expect(result.backgroundOpacity).toBe(0.6); + expect(result.backgroundSize).toBe('cover'); + }); +}); + describe('saveViewSettings', () => { test('global write swaps the settings reference so replicaSettingsSync subscribers fire', async () => { // Mirrors the gating subscriber installed by replicaSettingsSync.initSettingsSync. diff --git a/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts b/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts index f5d0cedc9..090461f8f 100644 --- a/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts +++ b/apps/readest-app/src/__tests__/helpers/shortcuts.test.ts @@ -56,6 +56,14 @@ describe('TTS navigation shortcuts', () => { }); }); +describe('Proofread selection shortcut (#4717)', () => { + it('binds alt+p alongside ctrl+p/cmd+p so it avoids the print conflict', async () => { + const shortcuts = await getDefaults(); + expect(shortcuts.onProofreadSelection.keys).toContain('alt+p'); + expect(shortcuts.onProofreadSelection.keys).toEqual(['ctrl+p', 'cmd+p', 'alt+p']); + }); +}); + describe('No identical keybinding lists across actions (#3675)', () => { // Pre-existing pairs where two actions intentionally share the exact // same key list — both handlers guard on runtime context. diff --git a/apps/readest-app/src/__tests__/helpers/updater.test.ts b/apps/readest-app/src/__tests__/helpers/updater.test.ts index a1308e02c..e887fe3a0 100644 --- a/apps/readest-app/src/__tests__/helpers/updater.test.ts +++ b/apps/readest-app/src/__tests__/helpers/updater.test.ts @@ -256,6 +256,24 @@ describe('updater', () => { ); }); + test('auto-check swallows Android update failure (READEST-22)', async () => { + mockOsType.mockReturnValue('android'); + mockTauriFetch.mockRejectedValue(new Error('Network error')); + + // Auto-check runs fire-and-forget on mount; a network failure must resolve + // false, not reject (which would become an unhandled rejection). + await expect(checkForAppUpdates(dummyTranslate, true)).resolves.toBe(false); + }); + + test('auto-check swallows desktop update failure (READEST-J)', async () => { + mockOsType.mockReturnValue('macos'); + mockCheck.mockRejectedValue( + new Error('error sending request for url (releases/latest/download/latest.json)'), + ); + + await expect(checkForAppUpdates(dummyTranslate, true)).resolves.toBe(false); + }); + test('returns false for unsupported OS types', async () => { mockOsType.mockReturnValue('ios'); diff --git a/apps/readest-app/src/__tests__/hooks/useAppRouter.test.ts b/apps/readest-app/src/__tests__/hooks/useAppRouter.test.ts new file mode 100644 index 000000000..bfdd9344e --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useAppRouter.test.ts @@ -0,0 +1,43 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useAppRouter } from '@/hooks/useAppRouter'; + +const transitionRouter = { push: vi.fn(), replace: vi.fn(), back: vi.fn() }; +const plainRouter = { push: vi.fn(), replace: vi.fn(), back: vi.fn() }; + +vi.mock('next-view-transitions', () => ({ + useTransitionRouter: () => transitionRouter, +})); +vi.mock('next/navigation', () => ({ + useRouter: () => plainRouter, +})); + +const useEnvMock = vi.fn(); +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => useEnvMock(), +})); + +afterEach(() => { + useEnvMock.mockReset(); +}); + +describe('useAppRouter', () => { + it('routes through the View Transition router when the engine has the API', () => { + useEnvMock.mockReturnValue({ appService: { supportsViewTransitionsAPI: true } }); + const { result } = renderHook(() => useAppRouter()); + expect(result.current).toBe(transitionRouter); + }); + + it('falls back to the plain router when the engine lacks the View Transitions API', () => { + useEnvMock.mockReturnValue({ appService: { supportsViewTransitionsAPI: false } }); + const { result } = renderHook(() => useAppRouter()); + expect(result.current).toBe(plainRouter); + }); + + it('falls back to the plain router before the app service is ready', () => { + useEnvMock.mockReturnValue({ appService: null }); + const { result } = renderHook(() => useAppRouter()); + expect(result.current).toBe(plainRouter); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useAutoImportFolders.test.tsx b/apps/readest-app/src/__tests__/hooks/useAutoImportFolders.test.tsx new file mode 100644 index 000000000..d4b478caa --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useAutoImportFolders.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; + +// useWindowActiveChanged reads appService.isDesktopApp to choose its +// subscription; false -> the DOM 'visibilitychange' path (jsdom-friendly). +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: { isDesktopApp: false } }), +})); + +import { useAutoImportFolders } from '@/app/library/hooks/useAutoImportFolders'; + +const DEBOUNCE_MS = 800; + +// Let the async window-active subscription settle so its listener is attached. +const settle = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('useAutoImportFolders', () => { + test('scans once on mount after the debounce window', async () => { + const scan = vi.fn(async () => {}); + renderHook(() => + useAutoImportFolders({ enabled: true, folders: ['/books'], scanAndImport: scan }), + ); + await settle(); + expect(scan).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).toHaveBeenCalledTimes(1); + expect(scan).toHaveBeenCalledWith(['/books']); + }); + + test('does not scan when disabled', async () => { + const scan = vi.fn(async () => {}); + renderHook(() => + useAutoImportFolders({ enabled: false, folders: ['/books'], scanAndImport: scan }), + ); + await settle(); + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).not.toHaveBeenCalled(); + }); + + test('does not scan when there are no folders', async () => { + const scan = vi.fn(async () => {}); + renderHook(() => useAutoImportFolders({ enabled: true, folders: [], scanAndImport: scan })); + await settle(); + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).not.toHaveBeenCalled(); + }); + + test('re-scans when the app becomes visible again', async () => { + const scan = vi.fn(async () => {}); + renderHook(() => + useAutoImportFolders({ enabled: true, folders: ['/books'], scanAndImport: scan }), + ); + await settle(); + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).toHaveBeenCalledTimes(1); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).toHaveBeenCalledTimes(2); + }); + + test('coalesces triggers while a scan is in flight', async () => { + let resolveScan: () => void = () => {}; + const scan = vi.fn( + () => + new Promise((r) => { + resolveScan = r; + }), + ); + renderHook(() => + useAutoImportFolders({ enabled: true, folders: ['/books'], scanAndImport: scan }), + ); + await settle(); + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).toHaveBeenCalledTimes(1); // pending + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).toHaveBeenCalledTimes(1); // still in flight -> no second run + await act(async () => { + resolveScan(); + await Promise.resolve(); + }); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(DEBOUNCE_MS); + }); + expect(scan).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx b/apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx index d08335908..9fe9a43ed 100644 --- a/apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx @@ -107,4 +107,25 @@ describe('useBackgroundTexture', () => { expect(useCustomTextureStore.getState().getTexture(SAVED_TEXTURE.id)).toBeUndefined(); }); + + test('unmounts any mounted texture when textureId is "none"', async () => { + // Library/reader share the single #background-texture style element. When a + // page whose texture is "none" becomes active (e.g. returning to a + // None-background library after reading a textured book), applying it must + // actively clear the leftover texture, not silently leave it mounted. + const { mountBackgroundTexture, unmountBackgroundTexture } = await import('@/styles/textures'); + vi.mocked(mountBackgroundTexture).mockClear(); + vi.mocked(unmountBackgroundTexture).mockClear(); + + const { result } = renderHook(() => useBackgroundTexture()); + result.current.applyBackgroundTexture( + createMockEnvConfig(), + makeViewSettings({ backgroundTextureId: 'none' }), + ); + + await vi.waitFor(() => { + expect(unmountBackgroundTexture).toHaveBeenCalled(); + }); + expect(mountBackgroundTexture).not.toHaveBeenCalled(); + }); }); diff --git a/apps/readest-app/src/__tests__/hooks/useBooksManager.test.tsx b/apps/readest-app/src/__tests__/hooks/useBooksManager.test.tsx new file mode 100644 index 000000000..82ba5dea6 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useBooksManager.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { eventDispatcher } from '@/utils/event'; + +// initViewState rejects with "Book not found" when a library reload drops the +// in-memory entry (readerStore). appendBook / openBookInReader call it +// fire-and-forget, so the rejection surfaced as an unhandled rejection +// (READEST-1V). The hook must catch it and surface a toast instead. +const h = vi.hoisted(() => ({ + initViewStateMock: vi.fn(() => Promise.resolve()), + setBookKeysMock: vi.fn(), + setSideBarBookKeyMock: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => ({ toString: () => '' }), +})); +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: {} }), +})); +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); +vi.mock('@/store/readerStore', () => ({ + useReaderStore: Object.assign( + () => ({ bookKeys: [], setBookKeys: h.setBookKeysMock, initViewState: h.initViewStateMock }), + { getState: () => ({ getView: () => null, setPreviewMode: vi.fn() }) }, + ), +})); +vi.mock('@/store/sidebarStore', () => ({ + useSidebarStore: () => ({ sideBarBookKey: null, setSideBarBookKey: h.setSideBarBookKeyMock }), +})); +vi.mock('@/store/parallelViewStore', () => ({ + useParallelViewStore: () => ({ setParallel: vi.fn() }), +})); +vi.mock('@/utils/nav', () => ({ navigateToReader: vi.fn() })); + +import useBooksManager from '@/app/reader/hooks/useBooksManager'; + +describe('useBooksManager open-failure handling', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('toasts instead of leaking an unhandled rejection when the book is missing (READEST-1V)', async () => { + h.initViewStateMock.mockReturnValueOnce(Promise.reject(new Error('Book not found'))); + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch'); + + const { result } = renderHook(() => useBooksManager()); + + await act(async () => { + result.current.appendBook('missing-hash', true, false); + // Flush the rejected initViewState microtask chain. + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(dispatchSpy).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'error' })); + dispatchSpy.mockRestore(); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useCapturedTurn-scrollLock.test.ts b/apps/readest-app/src/__tests__/hooks/useCapturedTurn-scrollLock.test.ts new file mode 100644 index 000000000..d3998eaa6 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useCapturedTurn-scrollLock.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, renderHook } from '@testing-library/react'; +import type { FoliateView } from '@/types/view'; +import type { ViewSettings } from '@/types/book'; +import type { TouchDetail } from '@/app/reader/hooks/useTouchInterceptor'; + +// The captured page-turn (slide/curl) swipe is handled by an app-side touch +// interceptor because `no-swipe` disables the paginator's own swipe. Push mode +// stays on the paginator's native swipe, which bows out while `scrollLocked` +// is set (instant highlight engaged). This test pins the captured turn to the +// same gate so a hold-then-swipe extends the highlight instead of paginating. +const h = vi.hoisted(() => ({ + controller: { + turn: vi.fn(async () => {}), + beginDrag: vi.fn(async () => true), + moveDrag: vi.fn(), + endDrag: vi.fn(async () => {}), + dispose: vi.fn(), + }, + renderer: { + scrollLocked: false, + atEnd: false, + atStart: false, + hasAttribute: () => false, + setAttribute: () => {}, + removeAttribute: () => {}, + }, + viewSettings: { + pageTurnStyle: 'curl', + animated: true, + scrolled: false, + disableSwipe: false, + isEink: false, + rtl: false, + } as ViewSettings, +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ getViewSettings: () => h.viewSettings }), +})); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData: () => ({ isFixedLayout: false }) }), +})); +vi.mock('@/utils/bridge', () => ({ captureWebviewRegion: vi.fn() })); +vi.mock('@/utils/viewTransition', () => ({ detectViewTransitionGroup: () => false })); +vi.mock('@/app/reader/utils/capturedTurn', () => ({ + CapturedPageTurn: class { + constructor() { + Object.assign(this, h.controller); + } + }, +})); + +import { useCapturedTurn } from '@/app/reader/hooks/useCapturedTurn'; +import { dispatchTouchInterceptors } from '@/app/reader/hooks/useTouchInterceptor'; + +const makeView = () => + ({ renderer: h.renderer, prev: vi.fn(), next: vi.fn() }) as unknown as FoliateView; + +const detail = (phase: TouchDetail['phase'], deltaX = 0, deltaY = 0): TouchDetail => ({ + phase, + touch: { screenX: 0, screenY: 0 }, + touchStart: { screenX: 0, screenY: 0 }, + deltaX, + deltaY, + deltaT: 16, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + h.renderer.scrollLocked = false; + h.renderer.atEnd = false; + h.renderer.atStart = false; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + cleanup(); +}); + +describe('useCapturedTurn scroll-lock gate', () => { + test('a horizontal swipe starts the captured turn when scroll is not locked', () => { + renderHook(() => useCapturedTurn('book-1', { current: makeView() })); + + dispatchTouchInterceptors('book-1', detail('start')); + const consumed = dispatchTouchInterceptors('book-1', detail('move', -60, 3)); + + expect(consumed).toBe(true); + expect(h.controller.beginDrag).toHaveBeenCalled(); + }); + + test('scroll lock (instant highlight engaged) leaves the swipe to the highlight', () => { + renderHook(() => useCapturedTurn('book-1', { current: makeView() })); + + // Instant highlight has engaged after the still-hold: it locks scrolling. + h.renderer.scrollLocked = true; + dispatchTouchInterceptors('book-1', detail('start')); + const consumed = dispatchTouchInterceptors('book-1', detail('move', -60, 3)); + + expect(consumed).toBe(false); + expect(h.controller.beginDrag).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts b/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts new file mode 100644 index 000000000..184be4da8 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useCapturedTurn.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { applyPageTurnAttributes, getCapturedTurnStyle } from '@/app/reader/hooks/useCapturedTurn'; +import type { FoliateView } from '@/types/view'; +import type { ViewSettings } from '@/types/book'; + +// The DOM lib types startViewTransition as always present; go through a +// loose shape so the stub can also remove it. +type VTDocument = { startViewTransition?: () => void }; + +// iOS 18 WebKit has startViewTransition but crashes the WebContent process on +// the layered turns (#555); engines with nested view-transition groups +// (Chrome/WebView 140+) are the ones known to run them reliably. +const stubEngine = ({ + startViewTransition, + nestedGroups, +}: { + startViewTransition: boolean; + nestedGroups: boolean; +}) => { + const doc = document as unknown as VTDocument; + if (startViewTransition) doc.startViewTransition = () => {}; + else delete doc.startViewTransition; + vi.stubGlobal('CSS', { + supports: (property: string, value: string) => + nestedGroups && property === 'view-transition-group' && value === 'nearest', + }); +}; + +const makeView = () => { + const renderer = document.createElement('foliate-paginator'); + return { view: { renderer } as unknown as FoliateView, renderer }; +}; + +const settings = (pageTurnStyle: ViewSettings['pageTurnStyle']) => + ({ + pageTurnStyle, + animated: true, + scrolled: false, + disableSwipe: false, + isEink: false, + }) as ViewSettings; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + delete (document as unknown as VTDocument).startViewTransition; +}); + +describe('getCapturedTurnStyle', () => { + it('captures the slide on Tauri when the engine cannot layer View Transitions', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + expect(getCapturedTurnStyle(settings('slide'), false)).toBe('slide'); + }); + + it('leaves the slide to View Transitions on fully supporting engines', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + stubEngine({ startViewTransition: true, nestedGroups: true }); + expect(getCapturedTurnStyle(settings('slide'), false)).toBeNull(); + }); + + it('never captures outside Tauri platforms', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'web'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + expect(getCapturedTurnStyle(settings('slide'), false)).toBeNull(); + expect(getCapturedTurnStyle(settings('curl'), false)).toBeNull(); + }); +}); + +describe('applyPageTurnAttributes', () => { + it('keeps the View Transition slide on fully supporting engines', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'web'); + stubEngine({ startViewTransition: true, nestedGroups: true }); + const { view, renderer } = makeView(); + applyPageTurnAttributes(view, settings('slide'), false); + expect(renderer.getAttribute('turn-style')).toBe('slide'); + }); + + it('falls back to push on web engines without full View Transitions support', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'web'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + const { view, renderer } = makeView(); + renderer.setAttribute('turn-style', 'slide'); + applyPageTurnAttributes(view, settings('slide'), false); + expect(renderer.hasAttribute('turn-style')).toBe(false); + }); + + it('hands the slide to the capture pipeline on Tauri without full support', () => { + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + stubEngine({ startViewTransition: true, nestedGroups: false }); + const { view, renderer } = makeView(); + applyPageTurnAttributes(view, settings('slide'), false); + // The app slides the captured page itself: the paginator must not run + // its own View Transition nor its swipe tracking. + expect(renderer.hasAttribute('turn-style')).toBe(false); + expect(renderer.hasAttribute('no-swipe')).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useOpenAnnotationLink.test.ts b/apps/readest-app/src/__tests__/hooks/useOpenAnnotationLink.test.ts new file mode 100644 index 000000000..8c7605a95 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useOpenAnnotationLink.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, renderHook } from '@testing-library/react'; + +const navigateToReaderMock = vi.fn(); +const getCurrentMock = vi.fn(async () => [] as string[]); + +const libraryState = { + libraryLoaded: true, + getBookByHash: (hash: string) => (hash.startsWith('book') ? { hash } : undefined), +}; + +type ViewLike = { goTo: (cfi: string) => void }; +type ReaderState = { + bookKeys: string[]; + viewStates: Record; + setPreviewMode: ReturnType; +}; +// A reader is mounted; bookKeys lists only the currently-displayed book(s). +// viewStates may carry stale entries for books switched away from. +const readerState: ReaderState = { + bookKeys: ['bookA-1'], + viewStates: {}, + setPreviewMode: vi.fn(), +}; + +vi.mock('@tauri-apps/plugin-deep-link', () => ({ + getCurrent: () => getCurrentMock(), +})); +vi.mock('@/services/environment', async (orig) => { + const actual = await orig(); + return { ...actual, isTauriAppPlatform: () => true }; +}); +vi.mock('@/context/EnvContext', () => ({ useEnv: () => ({ appService: {} }) })); +vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (k: string) => k })); +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock('@/utils/nav', () => ({ + navigateToReader: (...a: unknown[]) => navigateToReaderMock(...a), +})); +vi.mock('@/store/libraryStore', () => { + const useLibraryStore = ((selector: (s: typeof libraryState) => unknown) => + selector(libraryState)) as unknown as { + (selector: (s: typeof libraryState) => unknown): unknown; + getState: () => typeof libraryState; + }; + useLibraryStore.getState = () => libraryState; + return { useLibraryStore }; +}); +vi.mock('@/store/readerStore', () => { + const useReaderStore = (() => readerState) as unknown as { + (): ReaderState; + getState: () => ReaderState; + }; + useReaderStore.getState = () => readerState; + return { useReaderStore }; +}); + +import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; +import { eventDispatcher } from '@/utils/event'; + +const CFI = 'epubcfi(/6/4!/4/2)'; +const urlFor = (hash: string) => + `readest://book/${hash}/annotation/note1?cfi=${encodeURIComponent(CFI)}`; + +const collectSwitch = () => { + const switched = vi.fn(); + const handler = (e: Event) => switched((e as CustomEvent).detail); + eventDispatcher.on('open-book-in-reader', handler); + return { + switched, + stop: () => eventDispatcher.off('open-book-in-reader', handler), + }; +}; + +describe('useOpenAnnotationLink — reader already mounted', () => { + beforeEach(() => { + navigateToReaderMock.mockReset(); + readerState.setPreviewMode.mockReset(); + readerState.bookKeys = ['bookA-1']; + readerState.viewStates = { 'bookA-1': { view: { goTo: vi.fn() }, inited: true } }; + window.history.replaceState({}, '', '/reader?ids=bookA'); + }); + afterEach(() => { + cleanup(); + window.history.replaceState({}, '', '/'); + }); + + it('switches the book in place instead of calling the no-op navigateToReader', async () => { + const { switched, stop } = collectSwitch(); + renderHook(() => useOpenAnnotationLink()); + await eventDispatcher.dispatch('app-incoming-url', { urls: [urlFor('bookB')] }); + await Promise.resolve(); + stop(); + + // navigateToReader('/reader?ids=bookB&cfi=...') does not re-init an + // already-mounted reader, so the reader would stay on bookA. The fix routes + // through the in-place switch event carrying the target cfi. + expect(navigateToReaderMock).not.toHaveBeenCalled(); + expect(switched).toHaveBeenCalledWith(expect.objectContaining({ bookHash: 'bookB', cfi: CFI })); + }); + + it('jumps in place when the target book is the currently displayed one', async () => { + const goTo = vi.fn(); + readerState.bookKeys = ['bookB-1']; + readerState.viewStates = { 'bookB-1': { view: { goTo }, inited: true } }; + + renderHook(() => useOpenAnnotationLink()); + await eventDispatcher.dispatch('app-incoming-url', { urls: [urlFor('bookB')] }); + await Promise.resolve(); + + expect(goTo).toHaveBeenCalledWith(CFI); + expect(readerState.setPreviewMode).toHaveBeenCalledWith('bookB-1', true); + expect(navigateToReaderMock).not.toHaveBeenCalled(); + }); + + it('switches back to a book that was opened before but is no longer displayed (#4887)', async () => { + // Simulates opening A -> B -> A all by deep link. After A -> B, bookA-1 is + // still in viewStates with a now-detached view (never cleared on switch), + // while bookKeys reflects only bookB-2. A deep link back to book A must + // switch in place, NOT goTo the stale detached bookA view. + const staleGoTo = vi.fn(); + readerState.bookKeys = ['bookB-2']; + readerState.viewStates = { + 'bookA-1': { view: { goTo: staleGoTo }, inited: true }, + 'bookB-2': { view: { goTo: vi.fn() }, inited: true }, + }; + + const { switched, stop } = collectSwitch(); + renderHook(() => useOpenAnnotationLink()); + await eventDispatcher.dispatch('app-incoming-url', { urls: [urlFor('bookA')] }); + await Promise.resolve(); + stop(); + + expect(staleGoTo).not.toHaveBeenCalled(); + expect(navigateToReaderMock).not.toHaveBeenCalled(); + expect(switched).toHaveBeenCalledWith(expect.objectContaining({ bookHash: 'bookA', cfi: CFI })); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx index a72a4fbc4..697cc1027 100644 --- a/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useProgressSync.test.tsx @@ -37,11 +37,15 @@ const h = vi.hoisted(() => { user: { id: 'u1' }, syncConfigsMock: vi.fn(async () => {}), syncBooksMock: vi.fn(async () => {}), + saveConfigMock: vi.fn(async () => {}), + setViewSettingsMock: vi.fn(), + recreateViewerMock: vi.fn(), cfiCompareMock: vi.fn((_a: string, _b: string) => 0), view: { renderer: { getContents: () => [], primaryIndex: 0 }, goTo: vi.fn() }, state: { syncedConfigs: [] as unknown[] | null, progress: { location: 'cfi-loc' } as { location: string } | null, + viewSettings: { proofreadRules: [] } as { proofreadRules: unknown[] } | null, }, eventListeners: new Map void>>(), }; @@ -63,10 +67,15 @@ vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string) => s, })); +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: {} }), +})); + vi.mock('@/store/bookDataStore', () => ({ useBookDataStore: h.makeStore({ getConfig: () => h.config, setConfig: vi.fn(), + saveConfig: h.saveConfigMock, getBookData: () => ({ book: h.book }), }), })); @@ -75,6 +84,9 @@ vi.mock('@/store/readerStore', () => ({ useReaderStore: h.makeStore({ getView: () => h.view, getProgress: () => h.state.progress, + getViewSettings: () => h.state.viewSettings, + setViewSettings: h.setViewSettingsMock, + recreateViewer: h.recreateViewerMock, setHoveredBookKey: vi.fn(), getViewState: () => ({ previewMode: false }), }), @@ -141,11 +153,15 @@ beforeEach(() => { vi.useFakeTimers(); h.syncConfigsMock.mockClear(); h.syncBooksMock.mockClear(); + h.saveConfigMock.mockClear(); + h.setViewSettingsMock.mockClear(); + h.recreateViewerMock.mockClear(); h.view.goTo.mockClear(); h.cfiCompareMock.mockReset(); h.cfiCompareMock.mockReturnValue(0); h.state.syncedConfigs = []; h.state.progress = { location: 'cfi-loc' }; + h.state.viewSettings = { proofreadRules: [] }; h.eventListeners.clear(); }); @@ -287,6 +303,55 @@ describe('useProgressSync', () => { expect(pullCallCount()).toBe(callsBeforeRefresh + 2); }); + test('merges synced book-scope proofread rules into local config by id', async () => { + // Local has a book rule; the remote config carries a different book rule + // plus a library-scope rule (which must be ignored — it syncs separately + // via the settings replica). After the pull both book rules should be + // merged into local viewSettings, persisted, and the live view refreshed. + h.cfiCompareMock.mockReturnValue(0); + h.state.viewSettings = { + proofreadRules: [{ id: 'local', scope: 'book', pattern: 'a', updatedAt: 100 }], + }; + h.state.syncedConfigs = [ + { + bookHash: 'h1', + metaHash: 'm1', + viewSettings: { + proofreadRules: [ + { id: 'remote', scope: 'book', pattern: 'b', updatedAt: 200 }, + { id: 'lib', scope: 'library', pattern: 'c', updatedAt: 200 }, + ], + }, + }, + ]; + renderHook(() => useProgressSync('h1-view1')); + await advance(0); + + expect(h.setViewSettingsMock).toHaveBeenCalledTimes(1); + const mergedRules = ( + h.setViewSettingsMock.mock.calls[0]![1] as { proofreadRules: { id: string }[] } + ).proofreadRules; + // Both book rules merged; the library-scope rule is excluded. + expect(mergedRules.map((r) => r.id).sort()).toEqual(['local', 'remote']); + expect(h.saveConfigMock).toHaveBeenCalledTimes(1); + expect(h.recreateViewerMock).toHaveBeenCalledTimes(1); + }); + + test('does not touch the view when synced proofread rules match local', async () => { + h.cfiCompareMock.mockReturnValue(0); + const rule = { id: 'same', scope: 'book', pattern: 'a', updatedAt: 100 }; + h.state.viewSettings = { proofreadRules: [rule] }; + h.state.syncedConfigs = [ + { bookHash: 'h1', metaHash: 'm1', viewSettings: { proofreadRules: [rule] } }, + ]; + renderHook(() => useProgressSync('h1-view1')); + await advance(0); + + expect(h.setViewSettingsMock).not.toHaveBeenCalled(); + expect(h.saveConfigMock).not.toHaveBeenCalled(); + expect(h.recreateViewerMock).not.toHaveBeenCalled(); + }); + test('sync-book-progress flushes the pending cloud push on book close', async () => { // Reproduces issue #4532: the reader is closed inside the 3s auto-sync // debounce window, so the pending Readest cloud push would otherwise be diff --git a/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx b/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx new file mode 100644 index 000000000..7def34f1e --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; + +const h = vi.hoisted(() => ({ + hasScreenBrightness: true, + autoScreenBrightness: false, + screenBrightness: -1, + setScreenBrightness: vi.fn(), +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: { hasScreenBrightness: h.hasScreenBrightness } }), +})); +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: () => ({ + settings: { + autoScreenBrightness: h.autoScreenBrightness, + screenBrightness: h.screenBrightness, + }, + }), +})); +vi.mock('@/store/deviceStore', () => ({ + useDeviceControlStore: () => ({ setScreenBrightness: h.setScreenBrightness }), +})); + +import { useScreenBrightness } from '@/app/reader/hooks/useScreenBrightness'; + +function Wrapper() { + useScreenBrightness(); + return null; +} + +const setup = () => render(); + +describe('useScreenBrightness', () => { + beforeEach(() => { + h.hasScreenBrightness = true; + h.autoScreenBrightness = false; + h.screenBrightness = -1; + h.setScreenBrightness.mockReset(); + }); + afterEach(() => cleanup()); + + it('applies the saved manual brightness when auto is off', () => { + h.autoScreenBrightness = false; + h.screenBrightness = 40; + setup(); + expect(h.setScreenBrightness).toHaveBeenCalledWith(0.4); + }); + + it('releases control to the system when auto brightness is on', () => { + h.autoScreenBrightness = true; + h.screenBrightness = 40; + setup(); + expect(h.setScreenBrightness).toHaveBeenCalledWith(-1); + }); + + it('releases control when no manual brightness has been set', () => { + h.autoScreenBrightness = false; + h.screenBrightness = -1; + setup(); + expect(h.setScreenBrightness).toHaveBeenCalledWith(-1); + }); + + it('releases control on unmount', () => { + h.autoScreenBrightness = false; + h.screenBrightness = 40; + const utils = setup(); + h.setScreenBrightness.mockClear(); + utils.unmount(); + expect(h.setScreenBrightness).toHaveBeenCalledWith(-1); + }); + + it('re-applies the manual brightness when switching off auto brightness', () => { + h.autoScreenBrightness = true; + h.screenBrightness = 40; + const utils = setup(); + h.setScreenBrightness.mockClear(); + h.autoScreenBrightness = false; + utils.rerender(); + expect(h.setScreenBrightness).toHaveBeenLastCalledWith(0.4); + }); + + it('is inert when the platform lacks screen brightness control', () => { + h.hasScreenBrightness = false; + h.screenBrightness = 40; + setup(); + expect(h.setScreenBrightness).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useSync-cursor.test.ts b/apps/readest-app/src/__tests__/hooks/useSync-cursor.test.ts new file mode 100644 index 000000000..8da726178 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useSync-cursor.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { computeMaxTimestamp } from '@/hooks/useSync'; +import type { BookDataRecord } from '@/types/book'; + +const iso = (ms: number) => new Date(ms).toISOString(); + +// Issue #4678: the incremental-pull cursor is decoupled from updated_at. The +// server stamps a `synced_at` on every books write, so the client watermark +// keys on synced_at and ignores updated_at (the client event time / sort key) +// and deleted_at (a delete bumps synced_at too). +describe('computeMaxTimestamp (synced_at pull cursor)', () => { + it('keys on synced_at, ignoring updated_at and deleted_at', () => { + const max = computeMaxTimestamp([ + { synced_at: iso(5000), updated_at: iso(1000), deleted_at: null }, + { synced_at: iso(3000), updated_at: iso(9999), deleted_at: iso(8000) }, + ] as unknown as BookDataRecord[]); + expect(max).toBe(5000); + }); + + it('falls back to updated_at/deleted_at when synced_at is absent (old server)', () => { + const max = computeMaxTimestamp([ + { updated_at: iso(1000), deleted_at: iso(4000) }, + { updated_at: iso(2000), deleted_at: null }, + ] as unknown as BookDataRecord[]); + expect(max).toBe(4000); + }); + + it('returns 0 for empty input', () => { + expect(computeMaxTimestamp([])).toBe(0); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useSync-stale-settings-clobber.test.tsx b/apps/readest-app/src/__tests__/hooks/useSync-stale-settings-clobber.test.tsx new file mode 100644 index 000000000..301ad5106 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useSync-stale-settings-clobber.test.tsx @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import type { SystemSettings } from '@/types/settings'; + +/** + * Issue #4780 — "WebDAV integration doesn't connect persistently". + * + * `useSync.pullChanges` re-reads the live store settings inside its `try` + * (the `setSettings` path) but its `finally` historically persisted the + * STALE hook-closure `settings` captured at the render that created the + * pull. On a slow connection (Android) a settings change that lands while + * a pull is in flight — most visibly a WebDAV connect, which is the only + * integration NOT re-hydrated from the server replica on next launch — is + * silently overwritten on disk when the pull completes, so the connection + * looks "nullified" after the app is closed and reopened. + * + * This test drives the real hook: it captures a `pullChanges` while the + * store holds the pre-connect settings (so its closure binds them), then + * swaps in the post-connect settings (enabled WebDAV) to model the connect + * landing mid-pull, and finally resolves the pull. The settings persisted + * by the pull must reflect the live (connected) state, not the stale one. + */ + +const h = vi.hoisted(() => { + const baseSettings = (): SystemSettings => + ({ + version: 1, + keepLogin: true, + lastSyncedAtBooks: 0, + lastSyncedAtConfigs: 0, + lastSyncedAtNotes: 0, + webdav: { + enabled: false, + serverUrl: '', + username: '', + password: '', + rootPath: '/', + }, + }) as unknown as SystemSettings; + + // The live store object. `settings` is reassigned (new ref) to model a + // component calling setSettings with a fresh object, exactly like the + // WebDAV connect handler does. + const storeState = { + settings: baseSettings(), + setSettings: vi.fn((s: SystemSettings) => { + storeState.settings = s; + }), + saveSettings: vi.fn(async (_env: unknown, _settings: SystemSettings) => {}), + }; + + return { baseSettings, storeState }; +}); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: { id: 'env' } }), +})); + +const syncClientMock = vi.hoisted(() => ({ + pullChanges: vi.fn(), + pushChanges: vi.fn(async () => ({ books: null, configs: null, notes: null })), +})); + +vi.mock('@/context/SyncContext', () => ({ + useSyncContext: () => ({ syncClient: syncClientMock }), +})); + +vi.mock('@/services/sync/syncCategories', () => ({ + isSyncCategoryEnabled: () => true, +})); + +vi.mock('@/store/settingsStore', () => { + const useSettingsStore = ((selector?: (s: typeof h.storeState) => unknown) => + selector ? selector(h.storeState) : h.storeState) as unknown as { + (): typeof h.storeState; + getState: () => typeof h.storeState; + }; + useSettingsStore.getState = () => h.storeState; + return { useSettingsStore }; +}); + +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getConfig: () => null, setConfig: vi.fn() }), +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ setIsSyncing: vi.fn() }), +})); + +vi.mock('@/utils/nav', () => ({ navigateToLogin: vi.fn() })); + +vi.mock('@/utils/transform', () => ({ + transformBookFromDB: (x: unknown) => x, + transformBookNoteFromDB: (x: unknown) => x, + transformBookConfigFromDB: (x: unknown) => x, +})); + +import { useSync } from '@/hooks/useSync'; + +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; + +beforeEach(() => { + h.storeState.settings = h.baseSettings(); + h.storeState.setSettings.mockClear(); + h.storeState.saveSettings.mockClear(); + syncClientMock.pullChanges.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('useSync pull persistence (issue #4780)', () => { + test('does not clobber a WebDAV connect that lands during an in-flight pull', async () => { + // A pull whose network round-trip we control by hand. + let resolvePull: (value: unknown) => void = () => {}; + syncClientMock.pullChanges.mockImplementation( + () => + new Promise((res) => { + resolvePull = res; + }), + ); + + const { result } = renderHook(() => useSync()); + + // Capture the pull while the store still holds the pre-connect settings, + // so its closure binds the disabled-WebDAV snapshot (the stale value). + const pull = result.current.pullChanges; + + let pullDone!: Promise; + await act(async () => { + pullDone = pull('books', 0, vi.fn(), vi.fn()); + await flush(); + }); + + // The user connects WebDAV mid-pull: a fresh settings object with the + // connection enabled replaces the live store value. + const connected = h.baseSettings(); + connected.webdav = { + enabled: true, + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'secret', + rootPath: '/', + } as SystemSettings['webdav']; + h.storeState.settings = connected; + + // The network completes and the pull finalises. + await act(async () => { + resolvePull({ books: [] }); + await pullDone; + await flush(); + }); + + expect(h.storeState.saveSettings).toHaveBeenCalled(); + const lastSaved = h.storeState.saveSettings.mock.calls.at(-1)![1]; + // The pull must persist the live (connected) settings, never the stale + // pre-connect snapshot — otherwise the WebDAV connection is wiped on disk + // and reads back as "Not connected" after a restart. + expect(lastSaved.webdav.enabled).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx index 268133099..ff66fbc88 100644 --- a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx @@ -57,7 +57,6 @@ const mockViewSettings = { ttsRate: 1, ttsHighlightOptions: { style: 'highlight', color: '#ffff00' }, isEink: false, - showTTSBar: false, ttsMediaMetadata: 'sentence', translationEnabled: false, ttsReadAloudText: 'source', @@ -71,6 +70,7 @@ const mockBookData = { vi.mock('@/store/readerStore', () => { const store = { hoveredBookKey: null, + bookKeys: ['book-1'], getView: () => mockView, getProgress: () => mockProgress, getViewSettings: () => mockViewSettings, @@ -133,13 +133,17 @@ vi.mock('@/services/tts', () => ({ ), initViewTTS: vi.fn().mockResolvedValue(undefined), updateHighlightOptions: vi.fn(), + setHighlightGranularity: vi.fn(), setLang: vi.fn(), setRate: vi.fn(), + setSentenceGap: vi.fn(), + supportsGapControl: vi.fn().mockReturnValue(false), setVoice: vi.fn(), setTargetLang: vi.fn(), speak: vi.fn(), pause: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), shutdown: vi.fn().mockResolvedValue(undefined), forward: vi.fn().mockResolvedValue(undefined), @@ -147,6 +151,14 @@ vi.mock('@/services/tts', () => ({ getVoices: vi.fn().mockResolvedValue([]), getVoiceId: vi.fn().mockReturnValue(''), redispatchPosition: vi.fn(), + ensureTimeline: vi.fn().mockResolvedValue(null), + getPlaybackInfo: vi.fn().mockReturnValue(null), + seekToTime: vi.fn().mockResolvedValue(undefined), + detachView: vi.fn(), + attachView: vi.fn().mockResolvedValue(undefined), + getSpeakingLang: vi.fn().mockReturnValue('en'), + terminated: false, + isViewAttached: true, state: 'idle', addEventListener: vi.fn(), removeEventListener: vi.fn(), @@ -154,10 +166,33 @@ vi.mock('@/services/tts', () => ({ }); ttsControllerInstances.push(this); }), + ensureSharedAudioContext: vi.fn().mockResolvedValue(undefined), })); vi.mock('@/libs/mediaSession', () => ({ TauriMediaSession: class {}, + getMediaSession: vi.fn(() => null), +})); + +const { mockSessionManager } = vi.hoisted(() => ({ + mockSessionManager: { + claim: vi.fn(), + detach: vi.fn(), + release: vi.fn(), + adopt: vi.fn(), + getSessionByHash: vi.fn((_hash: string) => null as unknown), + getActiveSession: vi.fn(() => null as unknown), + stopActive: vi.fn().mockResolvedValue(undefined), + setSleepTimer: vi.fn(), + getSleepTimer: vi.fn(() => null), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, +})); + +vi.mock('@/services/tts/TTSSessionManager', () => ({ + getBookHashFromKey: (key: string) => key.split('-')[0]!, + ttsSessionManager: mockSessionManager, })); vi.mock('@/utils/ssml', () => ({ @@ -175,6 +210,7 @@ vi.mock('@/utils/cfi', () => ({ vi.mock('@/utils/misc', () => ({ getLocale: () => 'en', + stubTranslation: (key: string) => key, })); vi.mock('@/utils/ttsMetadata', () => ({ @@ -198,19 +234,18 @@ vi.mock('@/utils/ttsTime', () => ({ }), })); -vi.mock('@/app/reader/hooks/useTTSMediaSession', () => ({ - useTTSMediaSession: () => ({ - mediaSessionRef: { current: null }, - unblockAudio: vi.fn(), - releaseUnblockAudio: vi.fn(), - initMediaSession: vi.fn().mockResolvedValue(undefined), - deinitMediaSession: vi.fn().mockResolvedValue(undefined), - }), -})); - // Imports must come AFTER vi.mock calls so they pick up the mocked modules. import { useTTSControl } from '@/app/reader/hooks/useTTSControl'; +import { ttsMediaBridge } from '@/services/tts/ttsMediaBridge'; import { eventDispatcher } from '@/utils/event'; +import { useReaderStore } from '@/store/readerStore'; + +const getSetTTSEnabledMock = () => + ( + useReaderStore as unknown as { + getState: () => { setTTSEnabled: ReturnType }; + } + ).getState().setTTSEnabled; const Harness = () => { useTTSControl({ bookKey: 'book-1' }); @@ -324,6 +359,83 @@ describe('useTTSControl tts-sync-request (mode-entry replay)', () => { }); }); +describe('useTTSControl handleStop resilience (#4676)', () => { + beforeEach(() => { + ttsControllerInstances.length = 0; + pendingInitResolvers.length = 0; + }); + + afterEach(() => { + cleanup(); + }); + + const startSession = async () => { + render(); + await act(async () => { + const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!(); + await p; + }); + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + return ttsControllerInstances[0] as { shutdown: ReturnType }; + }; + + it('disables TTS even when controller.shutdown rejects', async () => { + // Regression: a native teardown that throws (observed with iOS system TTS) + // must not skip the state resets that turn the TTS icon off. + const controller = await startSession(); + const setTTSEnabled = getSetTTSEnabledMock(); + setTTSEnabled.mockClear(); + controller.shutdown.mockRejectedValueOnce(new Error('native teardown failed')); + + await act(async () => { + await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' }); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + + expect(setTTSEnabled).toHaveBeenCalledWith('book-1', false); + }); + + it('disables TTS even when controller.shutdown never resolves', async () => { + // The state resets must run before (not after) the teardown await, so a + // hung native teardown can never leave the TTS icon stuck on. + const controller = await startSession(); + const setTTSEnabled = getSetTTSEnabledMock(); + setTTSEnabled.mockClear(); + controller.shutdown.mockReturnValueOnce(new Promise(() => {})); + + await act(async () => { + // Do not await the dispatch: handleStop intentionally never settles here. + eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' }); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + + expect(setTTSEnabled).toHaveBeenCalledWith('book-1', false); + }); + + it('tears down the media session even when controller.shutdown never resolves', async () => { + // Regression for the lock-screen Now Playing lingering with iOS system TTS: + // the media-session teardown must not be gated behind the controller's own + // shutdown, which can stall. The media session is owned by ttsMediaBridge + // now; the teardown is its unbind(). + const unbindSpy = vi.spyOn(ttsMediaBridge, 'unbind'); + const controller = await startSession(); + unbindSpy.mockClear(); + controller.shutdown.mockReturnValueOnce(new Promise(() => {})); + + await act(async () => { + eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' }); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + + expect(unbindSpy).toHaveBeenCalled(); + unbindSpy.mockRestore(); + }); +}); + describe('useTTSControl handleHighlightMark cross-section navigation', () => { beforeEach(() => { ttsControllerInstances.length = 0; @@ -332,6 +444,10 @@ describe('useTTSControl handleHighlightMark cross-section navigation', () => { mockView.renderer.goTo.mockClear(); mockView.goTo.mockClear(); mockView.resolveCFI.mockReset(); + // Reset renderer state a test may override (a throwing assertion can skip + // an inline restore, leaking into later tests). + mockView.renderer.getContents = () => [{ index: 0, doc: document as unknown as Document }]; + mockView.renderer.scrolled = false; mockViewSettings.ttsLocation = null; }); @@ -389,4 +505,257 @@ describe('useTTSControl handleHighlightMark cross-section navigation', () => { expect(mockView.renderer.scrollToAnchor).toHaveBeenCalledTimes(1); expect(mockView.goTo).not.toHaveBeenCalled(); }); + + it('does not throw when the renderer has no loaded contents (READEST-19)', async () => { + const handler = await setupAndCaptureHighlightHandler(); + // getContents() can be empty mid-relocate (section still loading / view + // torn down); the mark handler must not destructure `doc` off undefined. + mockView.renderer.getContents = () => []; + mockView.resolveCFI.mockReturnValue({ index: 0, anchor: () => new Range() }); + + expect(() => { + handler(new CustomEvent('tts-highlight-mark', { detail: { cfi: 'epubcfi(/6/4!/4/2)' } })); + }).not.toThrow(); + }); + + it('bails without scrolling when the cfi resolves to a null range (READEST-21)', async () => { + const handler = await setupAndCaptureHighlightHandler(); + // In-section (index 0 == primaryIndex 0) so the follow-scroll path runs, + // but the anchor cannot resolve to a range in the doc. + mockView.resolveCFI.mockReturnValue({ index: 0, anchor: () => null }); + + expect(() => { + handler(new CustomEvent('tts-highlight-mark', { detail: { cfi: 'epubcfi(/6/4!/4/2)' } })); + }).not.toThrow(); + // A null range must never reach scrollToAnchor (foliate reads + // range.startContainer inside it -> the READEST-21 crash). + expect(mockView.renderer.scrollToAnchor).not.toHaveBeenCalled(); + }); + + it('does not throw in scrolled mode when the range is null (READEST-21)', async () => { + const handler = await setupAndCaptureHighlightHandler(); + mockView.renderer.scrolled = true; + mockView.resolveCFI.mockReturnValue({ index: 0, anchor: () => null }); + + expect(() => { + handler(new CustomEvent('tts-highlight-mark', { detail: { cfi: 'epubcfi(/6/4!/4/2)' } })); + }).not.toThrow(); + }); +}); + +describe('useTTSControl background session lifecycle', () => { + type ControllerMock = { + shutdown: ReturnType; + detachView: ReturnType; + attachView: ReturnType; + terminated: boolean; + state: string; + }; + + beforeEach(() => { + ttsControllerInstances.length = 0; + pendingInitResolvers.length = 0; + mockSessionManager.claim.mockClear(); + mockSessionManager.detach.mockClear(); + mockSessionManager.release.mockClear(); + mockSessionManager.adopt.mockClear(); + mockSessionManager.stopActive.mockClear(); + mockSessionManager.getSessionByHash.mockReturnValue(null); + mockSessionManager.getActiveSession.mockReturnValue(null); + }); + + afterEach(() => { + cleanup(); + }); + + const startSession = async () => { + render(); + await act(async () => { + const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!(); + await p; + }); + return ttsControllerInstances[0] as ControllerMock; + }; + + it('claims the session at controller birth', async () => { + await startSession(); + expect(mockSessionManager.claim).toHaveBeenCalledWith( + 'book-1', + ttsControllerInstances[0], + expect.objectContaining({ bookKey: 'book-1', title: 'T' }), + ); + }); + + it('unmount while the session lives transfers ownership (detach, no shutdown)', async () => { + const controller = await startSession(); + controller.state = 'playing'; + controller.terminated = false; + mockSessionManager.getSessionByHash.mockReturnValue({ + bookHash: 'book', + bookKey: 'book-1', + controller, + }); + cleanup(); // unmounts the hook + expect(mockSessionManager.detach).toHaveBeenCalledWith('book'); + expect(controller.shutdown).not.toHaveBeenCalled(); + }); + + it('unmount after termination shuts down and releases', async () => { + const controller = await startSession(); + controller.terminated = true; + mockSessionManager.getSessionByHash.mockReturnValue({ + bookHash: 'book', + bookKey: 'book-1', + controller, + }); + cleanup(); + expect(controller.shutdown).toHaveBeenCalled(); + expect(mockSessionManager.release).toHaveBeenCalledWith('book'); + }); + + it('tts-close-book detaches a live session; tts-stop stays a hard stop', async () => { + const controller = await startSession(); + controller.terminated = false; + await act(async () => { + await eventDispatcher.dispatch('tts-close-book', { bookKey: 'book-1' }); + }); + expect(mockSessionManager.detach).toHaveBeenCalledWith('book'); + expect(controller.shutdown).not.toHaveBeenCalled(); + + await act(async () => { + await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' }); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + expect(controller.shutdown).toHaveBeenCalled(); + expect(mockSessionManager.release).toHaveBeenCalledWith('book'); + }); + + it('mounting a book stops an active session of a different, unmounted book', async () => { + mockSessionManager.getActiveSession.mockReturnValue({ + bookHash: 'otherhash', + bookKey: 'otherhash-r9', + controller: {}, + }); + render(); + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + expect(mockSessionManager.stopActive).toHaveBeenCalledWith('replaced'); + }); + + it('adopts a live session for the same book without constructing a controller', async () => { + const liveController = { + state: 'playing', + terminated: false, + isViewAttached: false, + shutdown: vi.fn(), + detachView: vi.fn(), + attachView: vi.fn().mockResolvedValue(undefined), + getSpeakingLang: vi.fn().mockReturnValue('en'), + getCurrentHighlightCfi: vi.fn().mockReturnValue(null), + getSpokenSentence: vi.fn().mockReturnValue(null), + updateHighlightOptions: vi.fn(), + setHighlightGranularity: vi.fn(), + getVoiceId: vi.fn().mockReturnValue(''), + setTargetLang: vi.fn(), + setLang: vi.fn(), + setRate: vi.fn(), + pause: vi.fn().mockResolvedValue(true), + resume: vi.fn().mockResolvedValue(true), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + forward: vi.fn().mockResolvedValue(undefined), + backward: vi.fn().mockResolvedValue(undefined), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + redispatchPosition: vi.fn(), + }; + mockSessionManager.getSessionByHash.mockReturnValue({ + bookHash: 'book', + bookKey: 'book-old', + controller: liveController, + }); + render(); + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); + expect(ttsControllerInstances).toHaveLength(0); // no new controller + expect(mockSessionManager.adopt).toHaveBeenCalledWith( + 'book-1', + expect.objectContaining({ bookKey: 'book-1' }), + ); + expect(liveController.attachView).toHaveBeenCalledWith( + mockView, + expect.objectContaining({ bookKey: 'book-1' }), + ); + }); +}); + +describe('useTTSControl gap control (handleSetSentenceGap / handleSupportsGapControl)', () => { + let hookResult: ReturnType | null = null; + + const CaptureHarness = () => { + hookResult = useTTSControl({ bookKey: 'book-1' }); + return null; + }; + + beforeEach(() => { + ttsControllerInstances.length = 0; + pendingInitResolvers.length = 0; + hookResult = null; + mockSessionManager.claim.mockClear(); + mockSessionManager.getSessionByHash.mockReturnValue(null); + mockSessionManager.getActiveSession.mockReturnValue(null); + }); + + afterEach(() => { + cleanup(); + }); + + const startSession = async () => { + render(); + await act(async () => { + const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!(); + await p; + }); + return ttsControllerInstances[0] as { + setSentenceGap: ReturnType; + supportsGapControl: ReturnType; + stop: ReturnType; + start: ReturnType; + state: string; + }; + }; + + it('handleSetSentenceGap calls controller.setSentenceGap directly, without stop/start', async () => { + const controller = await startSession(); + controller.state = 'playing'; + + act(() => { + hookResult!.handleSetSentenceGap(0.5); + }); + + expect(controller.setSentenceGap).toHaveBeenCalledWith(0.5); + expect(controller.stop).not.toHaveBeenCalled(); + expect(controller.start).not.toHaveBeenCalled(); + }); + + it('handleSupportsGapControl reflects controller.supportsGapControl()', async () => { + const controller = await startSession(); + + controller.supportsGapControl.mockReturnValue(false); + expect(hookResult!.handleSupportsGapControl()).toBe(false); + + controller.supportsGapControl.mockReturnValue(true); + expect(hookResult!.handleSupportsGapControl()).toBe(true); + }); + + it('handleSupportsGapControl returns false when no controller exists yet', () => { + render(); + expect(hookResult!.handleSupportsGapControl()).toBe(false); + }); }); diff --git a/apps/readest-app/src/__tests__/hooks/useTouchEvent.test.tsx b/apps/readest-app/src/__tests__/hooks/useTouchEvent.test.tsx new file mode 100644 index 000000000..4bd273e01 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useTouchEvent.test.tsx @@ -0,0 +1,115 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; + +// Shared mock state so each test can configure the stores/renderer and inspect +// the calls the hook makes back into them. +const mocks = vi.hoisted(() => { + return { + hoveredBookKey: null as string | null, + setHoveredBookKey: vi.fn(), + getViewSettings: vi.fn(), + getView: vi.fn(), + getBookData: vi.fn(), + dispatch: vi.fn(), + }; +}); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + hoveredBookKey: mocks.hoveredBookKey, + setHoveredBookKey: mocks.setHoveredBookKey, + getViewSettings: mocks.getViewSettings, + getView: mocks.getView, + }), +})); + +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData: mocks.getBookData }), +})); + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { dispatch: mocks.dispatch }, +})); + +import { useTouchEvent } from '@/app/reader/hooks/useIframeEvents'; + +type Touch = { clientX: number; clientY: number; screenX: number; screenY: number }; +const touch = (screenX: number, screenY: number): Touch => ({ + clientX: screenX, + clientY: screenY, + screenX, + screenY, +}); +const touchEvent = (touches: Touch[], timeStamp = 0) => ({ timeStamp, targetTouches: touches }); + +type Handlers = ReturnType; + +const renderTouchHook = () => { + const ref: { current: Handlers | null } = { current: null }; + function Wrapper() { + ref.current = useTouchEvent('book-1'); + return null; + } + render(); + return ref as { current: Handlers }; +}; + +describe('useTouchEvent pinch vs two-finger scroll', () => { + let pinchZoom: ReturnType; + let pinchEnd: ReturnType; + + beforeEach(() => { + pinchZoom = vi.fn(); + pinchEnd = vi.fn(); + mocks.hoveredBookKey = null; + mocks.getBookData.mockReturnValue({ isFixedLayout: true }); + mocks.getViewSettings.mockReturnValue({ zoomLevel: 100, scrolled: true, vertical: false }); + mocks.getView.mockReturnValue({ renderer: { pinchZoom, pinchEnd } }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test('two fingers moving in the same direction (scroll) do not zoom', () => { + const h = renderTouchHook(); + // Two fingers land ~100px apart. + h.current.onTouchStart(touchEvent([touch(100, 300), touch(200, 300)], 0)); + // Both fingers travel ~80px upward. Human scroll is not perfectly parallel, + // so the finger spacing drifts from 100px to 120px — enough that a naive + // ratio (currentDist/initialDist = 1.2) would zoom to 120%. + h.current.onTouchMove(touchEvent([touch(95, 220), touch(215, 220)], 16)); + h.current.onTouchEnd(touchEvent([], 32)); + + expect(pinchZoom).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalledWith('pinch-zoom', expect.anything()); + }); + + test('two fingers moving in opposite directions (pinch) zoom in', () => { + const h = renderTouchHook(); + h.current.onTouchStart(touchEvent([touch(150, 300), touch(250, 300)], 0)); + // Spread apart: midpoint stays fixed, separation grows 100 -> 160 -> 200. + h.current.onTouchMove(touchEvent([touch(120, 300), touch(280, 300)], 16)); + h.current.onTouchMove(touchEvent([touch(100, 300), touch(300, 300)], 32)); + h.current.onTouchEnd(touchEvent([], 48)); + + expect(pinchZoom).toHaveBeenCalled(); + expect(pinchEnd).toHaveBeenCalled(); + const dispatched = mocks.dispatch.mock.calls.find((c) => c[0] === 'pinch-zoom'); + expect(dispatched).toBeTruthy(); + // Zoomed in beyond the starting 100%. + expect(dispatched![1].zoomLevel).toBeGreaterThan(100); + }); + + test('tiny finger jitter below the deadzone does not zoom', () => { + const h = renderTouchHook(); + h.current.onTouchStart(touchEvent([touch(150, 300), touch(250, 300)], 0)); + // Separation wobbles by only a few px — noise, not intent. + h.current.onTouchMove(touchEvent([touch(148, 300), touch(253, 300)], 16)); + h.current.onTouchEnd(touchEvent([], 32)); + + expect(pinchZoom).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalledWith('pinch-zoom', expect.anything()); + }); +}); diff --git a/apps/readest-app/src/__tests__/ios/share-extension-activation-rule.test.ts b/apps/readest-app/src/__tests__/ios/share-extension-activation-rule.test.ts new file mode 100644 index 000000000..f5020526e --- /dev/null +++ b/apps/readest-app/src/__tests__/ios/share-extension-activation-rule.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +/** + * Regression guard: sharing a `.txt` file to Readest on iOS got stuck. + * + * The Share Extension (added in #4256 / #4267) is an article-URL clipper — its + * `ShareViewController` only ever extracts an http(s) URL from the shared item. + * Its activation rule enabled `NSExtensionActivationSupportsText`, but a `.txt` + * file is UTI `public.plain-text` (which conforms to `public.text`), so that key + * made the extension activate for plain-text FILES it cannot handle: the share + * sheet hung instead of the file taking the main app's CFBundleDocumentTypes + * "Copy to Readest" open-in-place path (which imports txt fine, exactly like the + * EPUB/PDF that never matched the extension). + * + * Fix: the extension activates only for web URLs, never for text — so `.txt` + * (and any plain-text file) routes to the working document-open import path. + * + * We assert on `project.yml` only. It is the xcodegen source of truth: Tauri's + * iOS CLI runs `xcodegen` at build time, which regenerates each target's + * `Info.plist` from this file. The committed `ShareExtension/Info.plist` is a + * generated artifact (marked `skip-worktree`) and must not be relied on. + */ + +// `NSExtensionActivationSupportsText` is legitimately named in a `#` comment +// warning contributors not to re-add it. Strip comments so the check targets +// active config only. +const stripYamlComments = (text: string): string => text.replace(/#.*$/gm, ''); + +const projectYml = stripYamlComments( + readFileSync(resolve(process.cwd(), 'src-tauri/gen/apple/project.yml'), 'utf-8'), +); + +describe('iOS Share Extension activation rule (txt share stuck)', () => { + it('does not enable NSExtensionActivationSupportsText (would capture .txt files)', () => { + expect(projectYml).not.toContain('NSExtensionActivationSupportsText'); + }); + + it('still activates for web URLs (article clipping preserved)', () => { + expect(projectYml).toContain('NSExtensionActivationSupportsWebURLWithMaxCount'); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/document.test.ts b/apps/readest-app/src/__tests__/libs/document.test.ts index 3f49af39a..0fc4d9499 100644 --- a/apps/readest-app/src/__tests__/libs/document.test.ts +++ b/apps/readest-app/src/__tests__/libs/document.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -import { DocumentLoader } from '@/libs/document'; +import { DocumentLoader, getDirection } from '@/libs/document'; if (typeof globalThis['CSS'] === 'undefined') { (globalThis as Record)['CSS'] = { @@ -94,3 +94,34 @@ describe('DocumentLoader.open', () => { expect(result.format).toBe('EPUB'); }, 15000); }); + +describe('getDirection', () => { + afterEach(() => { + document.body.removeAttribute('style'); + document.body.removeAttribute('dir'); + document.documentElement.removeAttribute('dir'); + document.body.innerHTML = ''; + }); + + it('treats vertical-rl (Japanese vertical) as RTL so columns advance right-to-left', () => { + // vertical-rl reads top-to-bottom with columns progressing right-to-left, but + // its computed CSS `direction` stays `ltr`; the writing mode alone marks it RTL. + document.body.style.writingMode = 'vertical-rl'; + expect(getDirection(document)).toEqual({ vertical: true, rtl: true }); + }); + + it('treats vertical-lr (Mongolian) as vertical but left-to-right', () => { + document.body.style.writingMode = 'vertical-lr'; + expect(getDirection(document)).toEqual({ vertical: true, rtl: false }); + }); + + it('leaves horizontal-tb as neither vertical nor RTL', () => { + document.body.style.writingMode = 'horizontal-tb'; + expect(getDirection(document)).toEqual({ vertical: false, rtl: false }); + }); + + it('still honors an explicit rtl dir for horizontal text', () => { + document.body.setAttribute('dir', 'rtl'); + expect(getDirection(document)).toEqual({ vertical: false, rtl: true }); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/edgeTTS-audio-data.test.ts b/apps/readest-app/src/__tests__/libs/edgeTTS-audio-data.test.ts new file mode 100644 index 000000000..f181d27eb --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/edgeTTS-audio-data.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const fetchWithAuthMock = vi.fn(); + +vi.mock('@/utils/fetch', () => ({ + fetchWithAuth: (...args: unknown[]) => fetchWithAuthMock(...args), +})); + +vi.mock('@/services/environment', () => ({ + getAPIBaseUrl: () => 'http://api.test', + isTauriAppPlatform: () => false, +})); + +import { + EdgeSpeechTTS, + type EdgeTTSPayload, + hashTTSPayload, + serializeWordBoundaries, + WORD_BOUNDARIES_HEADER, +} from '@/libs/edgeTTS'; + +const makePayload = (text: string): EdgeTTSPayload => ({ + lang: 'en', + text, + voice: 'en-US-AriaNeural', + rate: 1.0, + pitch: 1.0, +}); + +const makeResponse = () => + new Response(new Uint8Array([1, 2, 3, 4]).buffer, { + status: 200, + headers: { + [WORD_BOUNDARIES_HEADER]: serializeWordBoundaries([ + { offset: 1_000_000, duration: 4_000_000, text: 'hello' }, + ]), + }, + }); + +describe('hashTTSPayload', () => { + test('is stable for equal payload content', () => { + expect(hashTTSPayload(makePayload('abc'))).toBe(hashTTSPayload(makePayload('abc'))); + }); + + test('differs when payload fields differ', () => { + expect(hashTTSPayload(makePayload('abc'))).not.toBe(hashTTSPayload(makePayload('abd'))); + expect(hashTTSPayload({ ...makePayload('abc'), pitch: 1.2 })).not.toBe( + hashTTSPayload(makePayload('abc')), + ); + }); +}); + +describe('createAudioData', () => { + beforeEach(() => { + fetchWithAuthMock.mockReset(); + fetchWithAuthMock.mockImplementation(async () => makeResponse()); + }); + + test('returns audio bytes and boundaries from the network on first call', async () => { + const tts = new EdgeSpeechTTS('https'); + const { data, boundaries } = await tts.createAudioData(makePayload('first call text')); + expect(new Uint8Array(data)).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(boundaries).toHaveLength(1); + expect(boundaries[0]!.text).toBe('hello'); + expect(fetchWithAuthMock).toHaveBeenCalledTimes(1); + }); + + test('serves the second call from cache with a fresh, non-detached buffer', async () => { + const tts = new EdgeSpeechTTS('https'); + const payload = makePayload('cache hit text'); + const first = await tts.createAudioData(payload); + const second = await tts.createAudioData(payload); + expect(fetchWithAuthMock).toHaveBeenCalledTimes(1); + // WebKit's decodeAudioData detaches its input; every call must get its + // own copy so replay from cache cannot hand out a detached buffer. + expect(second.data).not.toBe(first.data); + expect(first.data.byteLength).toBe(4); + expect(second.data.byteLength).toBe(4); + expect(second.boundaries).toHaveLength(1); + }); + + test('deduplicates concurrent in-flight fetches for the same payload', async () => { + let release: (() => void) | undefined; + fetchWithAuthMock.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve(makeResponse()); + }), + ); + const tts = new EdgeSpeechTTS('https'); + const payload = makePayload('concurrent text'); + const p1 = tts.createAudioData(payload); + const p2 = tts.createAudioData(payload); + await Promise.resolve(); + release!(); + const [r1, r2] = await Promise.all([p1, p2]); + expect(fetchWithAuthMock).toHaveBeenCalledTimes(1); + expect(new Uint8Array(r1.data)).toEqual(new Uint8Array(r2.data)); + }); + + test('a failed fetch is not cached; the next call retries', async () => { + fetchWithAuthMock.mockImplementationOnce(async () => { + throw new Error('network down'); + }); + const tts = new EdgeSpeechTTS('https'); + const payload = makePayload('retry after failure'); + await expect(tts.createAudioData(payload)).rejects.toThrow('network down'); + const { data } = await tts.createAudioData(payload); + expect(data.byteLength).toBe(4); + expect(fetchWithAuthMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/edgeTTS-word-boundaries.test.ts b/apps/readest-app/src/__tests__/libs/edgeTTS-word-boundaries.test.ts index 779bc9499..6ac5f5483 100644 --- a/apps/readest-app/src/__tests__/libs/edgeTTS-word-boundaries.test.ts +++ b/apps/readest-app/src/__tests__/libs/edgeTTS-word-boundaries.test.ts @@ -78,7 +78,7 @@ const makeMetadataFrame = (text: string, offset: number, duration: number) => ], }); -describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', () => { +describe('EdgeSpeechTTS.createAudioData word boundaries (browser WebSocket path)', () => { beforeEach(() => { wsState.instances.length = 0; (URL as unknown as { createObjectURL?: (blob: Blob) => string }).createObjectURL = vi.fn( @@ -97,7 +97,7 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', ( pitch: 1.0, }; - const promise = tts.createAudio(payload); + const promise = tts.createAudioData(payload); await vi.waitFor(() => expect(wsState.instances.length).toBe(1)); const ws = wsState.instances[0]!; // In a browser (jsdom has `window`), the WebSocket constructor must be @@ -110,8 +110,8 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', ( ws.emit('message', { data: makeMetadataFrame('brave', 6000000, 4000000) }); ws.emit('message', { data: 'Path:turn.end\r\n\r\n' }); - const { url, boundaries } = await promise; - expect(url).toBe('blob:mock-object-url'); + const { data, boundaries } = await promise; + expect(new Uint8Array(data)).toEqual(new Uint8Array([1, 2, 3, 4])); expect(boundaries).toEqual([ { offset: 1000000, duration: 4000000, text: 'Hello' }, { offset: 6000000, duration: 4000000, text: 'brave' }, @@ -119,9 +119,9 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', ( // A second call for the same payload is served from the cache: no new // WebSocket connection, same boundaries. - const cached = await tts.createAudio(payload); + const cached = await tts.createAudioData(payload); expect(wsState.instances.length).toBe(1); - expect(cached.url).toBe('blob:mock-object-url'); + expect(new Uint8Array(cached.data)).toEqual(new Uint8Array([1, 2, 3, 4])); expect(cached.boundaries).toEqual(boundaries); }); @@ -129,7 +129,7 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', ( const { EdgeSpeechTTS } = await import('@/libs/edgeTTS'); const tts = new EdgeSpeechTTS('wss'); - const promise = tts.createAudio({ + const promise = tts.createAudioData({ lang: 'en', text: 'No metadata here', voice: 'en-US-AriaNeural', @@ -171,7 +171,7 @@ describe('word-boundary header (de)serialization', () => { }); }); -describe('EdgeSpeechTTS.createAudio over the HTTPS proxy (word boundaries via header)', () => { +describe('EdgeSpeechTTS.createAudioData over the HTTPS proxy (word boundaries via header)', () => { beforeEach(() => { httpState.headers = {}; httpState.body = new Uint8Array([1, 2, 3]); @@ -191,7 +191,7 @@ describe('EdgeSpeechTTS.createAudio over the HTTPS proxy (word boundaries via he httpState.headers = { [WORD_BOUNDARIES_HEADER]: serializeWordBoundaries(boundaries) }; const tts = new EdgeSpeechTTS('https'); - const result = await tts.createAudio({ + const result = await tts.createAudioData({ lang: 'en', text: 'Hello world https', voice: 'en-US-AriaNeural', @@ -204,7 +204,7 @@ describe('EdgeSpeechTTS.createAudio over the HTTPS proxy (word boundaries via he test('returns empty boundaries when the proxy omits the header', async () => { const { EdgeSpeechTTS } = await import('@/libs/edgeTTS'); const tts = new EdgeSpeechTTS('https'); - const result = await tts.createAudio({ + const result = await tts.createAudioData({ lang: 'en', text: 'No header https', voice: 'en-US-AriaNeural', diff --git a/apps/readest-app/src/__tests__/libs/mediaSession.test.ts b/apps/readest-app/src/__tests__/libs/mediaSession.test.ts new file mode 100644 index 000000000..dc77746a2 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/mediaSession.test.ts @@ -0,0 +1,183 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +// getMediaSession() consults the platform helpers; mock them so each test can +// pin a platform without touching the real user agent / env. +vi.mock('@/utils/misc', () => ({ + getOSPlatform: vi.fn(), +})); +vi.mock('@/services/environment', () => ({ + isTauriAppPlatform: vi.fn(), +})); + +// mediaSession.ts imports these at module load; TauriMediaSession construction +// does not call them, but the imports must resolve in jsdom. +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), + addPluginListener: vi.fn(), +})); + +import { invoke, addPluginListener, type PluginListener } from '@tauri-apps/api/core'; +import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession'; +import { getOSPlatform } from '@/utils/misc'; +import { isTauriAppPlatform } from '@/services/environment'; + +const setNavigatorMediaSession = (present: boolean) => { + if (present) { + Object.defineProperty(navigator, 'mediaSession', { + value: { metadata: null, setActionHandler: vi.fn() }, + configurable: true, + }); + } else if ('mediaSession' in navigator) { + delete (navigator as unknown as { mediaSession?: unknown }).mediaSession; + } +}; + +describe('getMediaSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + setNavigatorMediaSession(false); + }); + + test('uses navigator.mediaSession on iOS, NOT the native plugin', () => { + // iOS audio plays through the WebView (Edge TTS media element, or the silent + // keep-alive element during system TTS), so navigator.mediaSession is what + // surfaces the lock-screen cover + sentence + controls. Routing iOS through + // the native plugin hid the Edge cover/sentence and gave system TTS no + // controls (AVSpeechSynthesizer can't be surfaced that way). See #4676. + vi.mocked(getOSPlatform).mockReturnValue('ios'); + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + setNavigatorMediaSession(true); + + const result = getMediaSession(); + expect(result).not.toBeInstanceOf(TauriMediaSession); + expect(result).toBe(navigator.mediaSession); + }); + + test('returns TauriMediaSession on Android Tauri (native foreground service)', () => { + // Android is checked first, so it uses the native session even though the + // WebView may also expose navigator.mediaSession. + vi.mocked(getOSPlatform).mockReturnValue('android'); + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + setNavigatorMediaSession(true); + + expect(getMediaSession()).toBeInstanceOf(TauriMediaSession); + }); + + test('falls back to navigator.mediaSession on the web', () => { + vi.mocked(getOSPlatform).mockReturnValue('macos'); + vi.mocked(isTauriAppPlatform).mockReturnValue(false); + setNavigatorMediaSession(true); + + const result = getMediaSession(); + expect(result).not.toBeInstanceOf(TauriMediaSession); + expect(result).toBe(navigator.mediaSession); + }); + + test('returns null when neither a native nor a web media session is available', () => { + vi.mocked(getOSPlatform).mockReturnValue('linux'); + vi.mocked(isTauriAppPlatform).mockReturnValue(false); + setNavigatorMediaSession(false); + + expect(getMediaSession()).toBeNull(); + }); +}); + +describe('TauriMediaSession.setActive', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('requests POST_NOTIFICATIONS whenever the session activates', async () => { + // The foreground-service media notification IS the lock-screen control; on + // Android 13+ it is silently suppressed unless POST_NOTIFICATIONS is + // granted. The request must fire on every activation (it used to be gated + // on an opt-in setting, which left the control missing by default). + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'plugin:native-tts|checkPermissions') { + return { postNotification: 'prompt' } as unknown; + } + return undefined as unknown; + }); + + const session = new TauriMediaSession(); + await session.setActive({ active: true }); + + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|checkPermissions'); + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|requestPermissions', { + permissions: ['postNotification'], + }); + }); + + test('still activates the native session when the permission request throws', async () => { + // A thrown/hung permission request must never abort the foreground-service + // start, or the service never becomes foreground and the OS reclaims it on + // idle (observed on MIUI: "Stopping service due to app idle"). + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'plugin:native-tts|checkPermissions') { + throw new Error('permission plugin unavailable'); + } + return undefined as unknown; + }); + + const session = new TauriMediaSession(); + await session.setActive({ active: true }); + + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|set_media_session_active', { + payload: { active: true }, + }); + }); + + test('does not re-prompt once the permission is already decided', async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'plugin:native-tts|checkPermissions') { + return { postNotification: 'granted' } as unknown; + } + return undefined as unknown; + }); + + const session = new TauriMediaSession(); + await session.setActive({ active: true }); + + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|checkPermissions'); + expect(invoke).not.toHaveBeenCalledWith( + 'plugin:native-tts|requestPermissions', + expect.anything(), + ); + }); +}); + +describe('TauriMediaSession media-session-seek', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('routes the native seek payload to the seekto handler', async () => { + // addPluginListener delivers the native payload DIRECTLY (like the + // native-bridge shared-intent listener), not wrapped in { payload }. The + // seek listener used to read `event.payload.position`, which threw, so + // lock-screen / Android Auto seeks silently did nothing while in-app seeks + // (which call the controller directly) worked. Guard the payload shape. + const listeners: Record void> = {}; + vi.mocked(addPluginListener).mockImplementation((async ( + _plugin: string, + event: string, + cb: (payload: unknown) => void, + ) => { + listeners[event] = cb; + return { unregister: vi.fn() } as unknown as PluginListener; + }) as unknown as typeof addPluginListener); + vi.mocked(invoke).mockResolvedValue({ postNotification: 'granted' } as unknown); + + const session = new TauriMediaSession(); + const seekHandler = vi.fn(); + session.setActionHandler('seekto', seekHandler as (position: number) => void); + await session.setActive({ active: true }); + + // Native fires the payload directly: { position }. + listeners['media-session-seek']!({ position: 42000 }); + expect(seekHandler).toHaveBeenCalledWith(42000); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/apple-notifications.test.ts b/apps/readest-app/src/__tests__/libs/payment/apple-notifications.test.ts new file mode 100644 index 000000000..a0f58cdae --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/apple-notifications.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Apple App Store Server Notifications V2 webhook handler. The notification is +// signed by Apple (decoded/verified by `app-store-server-api`), carries no +// user id, and must be resolved to a user via `original_transaction_id` before +// the subscription/plan tables are updated. + +const appleMocks = vi.hoisted(() => ({ + decodeNotificationPayload: vi.fn(), + decodeTransaction: vi.fn(), + decodeRenewalInfo: vi.fn(), +})); + +vi.mock('app-store-server-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + decodeNotificationPayload: appleMocks.decodeNotificationPayload, + decodeTransaction: appleMocks.decodeTransaction, + decodeRenewalInfo: appleMocks.decodeRenewalInfo, + }; +}); + +const h = vi.hoisted(() => ({ supabase: null as ReturnType | null })); + +vi.mock('@/utils/supabase', () => ({ + createSupabaseAdminClient: () => h.supabase!.client, +})); + +import { + NotificationType, + NotificationSubtype, + AutoRenewStatus, + TransactionType, +} from 'app-store-server-api'; +import { handleAppleNotification } from '@/libs/payment/iap/apple/notifications'; + +type Captures = { + appleSubUpserts: Array>; + planUpdates: Array>; + paymentUpdates: Array>; +}; + +function createSupabaseMock(state: { + appleSubRow?: unknown; + paymentRow?: unknown; + completedPayments?: Array<{ storage_gb: number }>; +}) { + const captures: Captures = { appleSubUpserts: [], planUpdates: [], paymentUpdates: [] }; + const client = { + from(table: string) { + switch (table) { + case 'apple_iap_subscriptions': + return { + select: () => ({ + eq: () => ({ single: () => Promise.resolve({ data: state.appleSubRow ?? null }) }), + }), + upsert: (obj: Record) => { + captures.appleSubUpserts.push(obj); + return Promise.resolve({ data: obj, error: null }); + }, + }; + case 'plans': + return { + update: (obj: Record) => ({ + eq: () => { + captures.planUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + case 'payments': + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data: state.paymentRow ?? null }), + in: () => Promise.resolve({ data: state.completedPayments ?? [] }), + }), + }), + update: (obj: Record) => ({ + eq: () => { + captures.paymentUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + default: + throw new Error(`unexpected table: ${table}`); + } + }, + }; + return { client, captures }; +} + +const PLUS_PRODUCT = 'com.bilingify.readest.plus.monthly'; +const STORAGE_PRODUCT = 'com.bilingify.readest.purchase.storage.5gb'; +const BUNDLE_ID = 'com.bilingify.readest'; +const ORIGINAL_TX = 'orig-tx-1'; + +const buildTransaction = (overrides: Record = {}) => ({ + bundleId: BUNDLE_ID, + productId: PLUS_PRODUCT, + transactionId: 'tx-1', + originalTransactionId: ORIGINAL_TX, + purchaseDate: 1_700_000_000_000, + expiresDate: Date.now() + 30 * 24 * 60 * 60 * 1000, + quantity: 1, + type: TransactionType.AutoRenewableSubscription, + webOrderLineItemId: 'wol-1', + subscriptionGroupIdentifier: 'group-1', + ...overrides, +}); + +const mockNotification = ( + notificationType: NotificationType, + subtype?: NotificationSubtype, + hasRenewal = true, +) => { + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType, + subtype, + data: { + bundleId: BUNDLE_ID, + environment: 'Production', + signedTransactionInfo: 'SIGNED_TX', + signedRenewalInfo: hasRenewal ? 'SIGNED_RENEWAL' : undefined, + }, + }); +}; + +beforeEach(() => { + appleMocks.decodeNotificationPayload.mockReset(); + appleMocks.decodeTransaction.mockReset(); + appleMocks.decodeRenewalInfo.mockReset(); + process.env['APPLE_IAP_BUNDLE_ID'] = BUNDLE_ID; + appleMocks.decodeTransaction.mockResolvedValue(buildTransaction()); + appleMocks.decodeRenewalInfo.mockResolvedValue({ autoRenewStatus: AutoRenewStatus.On }); +}); + +describe('handleAppleNotification — subscriptions', () => { + it('marks a renewed subscription active and keeps the paid plan', async () => { + mockNotification(NotificationType.DidRenew); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'active' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ status: 'active' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'active' }); + }); + + it('drops the user to free when the subscription expires', async () => { + mockNotification(NotificationType.Expired); + appleMocks.decodeTransaction.mockResolvedValue( + buildTransaction({ expiresDate: Date.now() - 1000 }), + ); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'expired' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'expired' }); + }); + + it('revokes access and drops to free on a subscription refund', async () => { + mockNotification(NotificationType.Refund); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'revoked' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'revoked' }); + }); + + it('keeps the plan active but records auto-renew off when renewal is disabled', async () => { + mockNotification( + NotificationType.DidChangeRenewalStatus, + NotificationSubtype.AutoRenewDisabled, + ); + appleMocks.decodeRenewalInfo.mockResolvedValue({ autoRenewStatus: AutoRenewStatus.Off }); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'active' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ + status: 'active', + auto_renew_status: false, + }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'active' }); + }); + + it('keeps entitlement during the billing grace period', async () => { + mockNotification(NotificationType.DidFailToRenew, NotificationSubtype.GracePeriod); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'in_grace_period' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'in_grace_period' }); + }); + + it('ignores notifications for an unknown subscription', async () => { + mockNotification(NotificationType.DidRenew); + const sb = createSupabaseMock({ appleSubRow: null }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: false, reason: 'subscription_not_found' }); + expect(sb.captures.planUpdates).toHaveLength(0); + }); +}); + +describe('handleAppleNotification — validation', () => { + it('skips summary notifications without transaction data', async () => { + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType: NotificationType.RenewalExtension, + subtype: NotificationSubtype.Summary, + summary: { requestIdentifier: 'r1' }, + }); + h.supabase = createSupabaseMock({}); + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: false, reason: 'summary_notification' }); + }); + + it('rejects a payload for a different bundle id', async () => { + mockNotification(NotificationType.DidRenew); + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType: NotificationType.DidRenew, + data: { + bundleId: 'com.evil.app', + environment: 'Production', + signedTransactionInfo: 'SIGNED_TX', + }, + }); + h.supabase = createSupabaseMock({}); + + await expect(handleAppleNotification('payload')).rejects.toThrow(); + }); +}); + +describe('handleAppleNotification — one-time purchases', () => { + it('marks a refunded one-time purchase and recomputes storage', async () => { + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType: NotificationType.Refund, + data: { + bundleId: BUNDLE_ID, + environment: 'Production', + signedTransactionInfo: 'SIGNED_TX', + }, + }); + appleMocks.decodeTransaction.mockResolvedValue( + buildTransaction({ productId: STORAGE_PRODUCT, type: TransactionType.NonConsumable }), + ); + const sb = createSupabaseMock({ + paymentRow: { user_id: 'user-1' }, + completedPayments: [], + }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true }); + expect(sb.captures.paymentUpdates.at(-1)).toMatchObject({ status: 'refunded' }); + expect(sb.captures.planUpdates.at(-1)).toMatchObject({ storage_purchased_bytes: 0 }); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/google-notifications.test.ts b/apps/readest-app/src/__tests__/libs/payment/google-notifications.test.ts new file mode 100644 index 000000000..09d2c50d1 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/google-notifications.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Google Play Real-Time Developer Notifications (RTDN) webhook handler. The +// Pub/Sub message carries a base64-encoded DeveloperNotification with no user +// id and no trustworthy state, so the handler resolves the user via the stored +// `purchase_token` and re-verifies against the Play Developer API (overriding +// the status for terminal events such as REVOKED/EXPIRED). + +const googleMocks = vi.hoisted(() => ({ + verifyPurchase: vi.fn(), +})); + +vi.mock('@/libs/payment/iap/google/verifier', () => ({ + getGoogleIAPVerifier: () => ({ verifyPurchase: googleMocks.verifyPurchase }), +})); + +const h = vi.hoisted(() => ({ supabase: null as ReturnType | null })); + +vi.mock('@/utils/supabase', () => ({ + createSupabaseAdminClient: () => h.supabase!.client, +})); + +import { handleGoogleNotification } from '@/libs/payment/iap/google/notifications'; + +type Captures = { + googleSubUpserts: Array>; + planUpdates: Array>; + paymentUpdates: Array>; +}; + +function createSupabaseMock(state: { + googleSubRow?: unknown; + paymentRow?: unknown; + completedPayments?: Array<{ storage_gb: number }>; +}) { + const captures: Captures = { googleSubUpserts: [], planUpdates: [], paymentUpdates: [] }; + const client = { + from(table: string) { + switch (table) { + case 'google_iap_subscriptions': + return { + select: () => ({ + eq: () => ({ single: () => Promise.resolve({ data: state.googleSubRow ?? null }) }), + }), + upsert: (obj: Record) => { + captures.googleSubUpserts.push(obj); + return Promise.resolve({ data: obj, error: null }); + }, + }; + case 'plans': + return { + update: (obj: Record) => ({ + eq: () => { + captures.planUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + case 'payments': + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data: state.paymentRow ?? null }), + in: () => Promise.resolve({ data: state.completedPayments ?? [] }), + }), + }), + update: (obj: Record) => ({ + eq: () => { + captures.paymentUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + default: + throw new Error(`unexpected table: ${table}`); + } + }, + }; + return { client, captures }; +} + +const PLUS_PRODUCT = 'com.bilingify.readest.plus.monthly'; +const PACKAGE = 'com.bilingify.readest'; +const TOKEN = 'purchase-token-1'; + +const subRow = { + user_id: 'user-1', + product_id: PLUS_PRODUCT, + order_id: 'order-1', + package_name: PACKAGE, +}; + +const encode = (notification: Record) => + Buffer.from(JSON.stringify(notification)).toString('base64'); + +const subscriptionMessage = (notificationType: number) => + encode({ + version: '1.0', + packageName: PACKAGE, + eventTimeMillis: '1700000000000', + subscriptionNotification: { + version: '1.0', + notificationType, + purchaseToken: TOKEN, + subscriptionId: PLUS_PRODUCT, + }, + }); + +const activeVerification = () => ({ + success: true, + status: 'active', + purchaseDate: new Date(1_700_000_000_000), + expiresDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + purchaseType: 'subscription', + purchaseData: { + orderId: 'order-1', + priceAmountMicros: '4990000', + priceCurrencyCode: 'USD', + autoRenewing: true, + purchaseState: 0, + acknowledgementState: 1, + quantity: 1, + }, +}); + +beforeEach(() => { + googleMocks.verifyPurchase.mockReset(); + googleMocks.verifyPurchase.mockResolvedValue(activeVerification()); +}); + +describe('handleGoogleNotification — subscriptions', () => { + it('re-verifies and keeps the plan active on renewal', async () => { + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(2)); // SUBSCRIPTION_RENEWED + + expect(res).toMatchObject({ handled: true, status: 'active' }); + expect(sb.captures.googleSubUpserts.at(-1)).toMatchObject({ status: 'active' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'active' }); + }); + + it('forces revocation on SUBSCRIPTION_REVOKED even if the API still reports active', async () => { + googleMocks.verifyPurchase.mockResolvedValue(activeVerification()); + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(12)); // SUBSCRIPTION_REVOKED + + expect(res).toMatchObject({ handled: true, status: 'revoked' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'revoked' }); + }); + + it('drops to free on SUBSCRIPTION_EXPIRED', async () => { + googleMocks.verifyPurchase.mockResolvedValue({ + ...activeVerification(), + status: 'expired', + expiresDate: new Date(Date.now() - 1000), + }); + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(13)); // SUBSCRIPTION_EXPIRED + + expect(res).toMatchObject({ handled: true, status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'expired' }); + }); + + it('keeps entitlement during the grace period', async () => { + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(6)); // SUBSCRIPTION_IN_GRACE_PERIOD + + expect(res).toMatchObject({ handled: true, status: 'in_grace_period' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'in_grace_period' }); + }); + + it('downgrades to free when re-verification fails on a terminal event', async () => { + googleMocks.verifyPurchase.mockResolvedValue({ success: false, error: 'gone' }); + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(13)); // SUBSCRIPTION_EXPIRED + + expect(res).toMatchObject({ handled: true, status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'expired' }); + }); + + it('ignores notifications for an unknown purchase token', async () => { + const sb = createSupabaseMock({ googleSubRow: null }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(2)); + + expect(res).toMatchObject({ handled: false, reason: 'subscription_not_found' }); + expect(sb.captures.planUpdates).toHaveLength(0); + }); +}); + +describe('handleGoogleNotification — voided purchases', () => { + it('revokes a refunded subscription', async () => { + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification( + encode({ + version: '1.0', + packageName: PACKAGE, + voidedPurchaseNotification: { purchaseToken: TOKEN, orderId: 'order-1', productType: 1 }, + }), + ); + + expect(res).toMatchObject({ handled: true, status: 'revoked' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'revoked' }); + }); + + it('refunds a voided one-time purchase and recomputes storage', async () => { + const sb = createSupabaseMock({ paymentRow: { user_id: 'user-1' }, completedPayments: [] }); + h.supabase = sb; + + const res = await handleGoogleNotification( + encode({ + version: '1.0', + packageName: PACKAGE, + voidedPurchaseNotification: { purchaseToken: TOKEN, orderId: 'order-1', productType: 2 }, + }), + ); + + expect(res).toMatchObject({ handled: true }); + expect(sb.captures.paymentUpdates.at(-1)).toMatchObject({ status: 'refunded' }); + expect(sb.captures.planUpdates.at(-1)).toMatchObject({ storage_purchased_bytes: 0 }); + }); +}); + +describe('handleGoogleNotification — other', () => { + it('acknowledges test notifications without processing', async () => { + h.supabase = createSupabaseMock({}); + + const res = await handleGoogleNotification(encode({ testNotification: { version: '1.0' } })); + + expect(res).toMatchObject({ handled: false, reason: 'test_notification' }); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/iap-telemetry.test.ts b/apps/readest-app/src/__tests__/libs/payment/iap-telemetry.test.ts new file mode 100644 index 000000000..eb5caa579 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/iap-telemetry.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// IAP webhook telemetry: a structured log line (for `wrangler tail`) plus a +// Cloudflare Analytics Engine data point (100% capture, independent of the +// head-sampled Workers Logs). Must no-op gracefully off the Worker runtime. + +const cf = vi.hoisted(() => ({ getCloudflareContext: vi.fn() })); +vi.mock('@opennextjs/cloudflare', () => ({ getCloudflareContext: cf.getCloudflareContext })); + +import { recordIapWebhook } from '@/libs/payment/iap/telemetry'; + +type DataPoint = { indexes?: string[]; blobs?: (string | null)[]; doubles?: number[] }; + +let writeDataPoint: ReturnType; +let logSpy: ReturnType; + +const firstDataPoint = (): DataPoint => writeDataPoint.mock.calls[0]![0] as DataPoint; + +beforeEach(() => { + writeDataPoint = vi.fn(); + cf.getCloudflareContext.mockReturnValue({ env: { IAP_WEBHOOK_AE: { writeDataPoint } } }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + logSpy.mockRestore(); + cf.getCloudflareContext.mockReset(); +}); + +describe('recordIapWebhook', () => { + it('writes an Analytics Engine data point indexed by provider', () => { + recordIapWebhook({ + provider: 'apple', + outcome: 'handled', + notificationType: 'DID_RENEW', + status: 'active', + durationMs: 12, + }); + + expect(writeDataPoint).toHaveBeenCalledTimes(1); + const point = firstDataPoint(); + expect(point.indexes).toEqual(['apple']); + expect(point.blobs).toEqual(['webhook', 'apple', 'handled', 'DID_RENEW', 'active', '']); + expect(point.doubles).toEqual([12]); + }); + + it('emits a tagged structured log line', () => { + recordIapWebhook({ provider: 'google', outcome: 'error', reason: 'db down', durationMs: 5 }); + + const logged = JSON.parse(logSpy.mock.calls.at(-1)![0] as string); + expect(logged).toMatchObject({ + tag: 'iap-webhook', + kind: 'webhook', + provider: 'google', + outcome: 'error', + reason: 'db down', + }); + }); + + it('does not throw and still logs outside the Worker runtime', () => { + cf.getCloudflareContext.mockImplementation(() => { + throw new Error('no cloudflare context'); + }); + + expect(() => + recordIapWebhook({ provider: 'apple', outcome: 'handled', durationMs: 1 }), + ).not.toThrow(); + expect(logSpy).toHaveBeenCalled(); + expect(writeDataPoint).not.toHaveBeenCalled(); + }); + + it('no-ops the data point when the AE binding is absent', () => { + cf.getCloudflareContext.mockReturnValue({ env: {} }); + + expect(() => + recordIapWebhook({ provider: 'apple', outcome: 'handled', durationMs: 1 }), + ).not.toThrow(); + expect(writeDataPoint).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/stripe-server.live.test.ts b/apps/readest-app/src/__tests__/libs/payment/stripe-server.live.test.ts new file mode 100644 index 000000000..544e0c3d6 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/stripe-server.live.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import Stripe from 'stripe'; + +// Live integration test for `getHighestActivePlan` against the real Stripe API. +// +// Skipped by default (including in CI) — it only runs when you explicitly opt +// in by providing a live key and a target customer id. To run it locally: +// +// STRIPE_SECRET_KEY=sk_live_xxx \ +// STRIPE_TEST_CUSTOMER_ID=cus_xxx \ +// STRIPE_TEST_EXPECTED_PLAN=pro \ +// pnpm test src/__tests__/libs/payment/stripe-server.live.test.ts +// +// Point STRIPE_TEST_CUSTOMER_ID at a customer that holds overlapping active +// subscriptions (e.g. Plus + Pro after an upgrade) to verify the helper +// resolves to the highest plan. STRIPE_TEST_EXPECTED_PLAN is optional; when +// omitted the test just asserts a valid plan and logs the resolved value. +// +// The module under test is imported dynamically inside the test body so that +// when the test is skipped the file stays import-safe (it pulls in Supabase, +// whose top-level setup needs env that CI does not provide for this lane). + +const stripeKey = process.env['STRIPE_SECRET_KEY'] || process.env['STRIPE_SECRET_KEY_DEV']; +const customerId = process.env['STRIPE_TEST_CUSTOMER_ID']; +const expectedPlan = process.env['STRIPE_TEST_EXPECTED_PLAN']; + +describe('getHighestActivePlan (live Stripe)', () => { + it.skipIf(!stripeKey || !customerId)( + 'resolves the highest active plan for a real customer', + async () => { + const { getHighestActivePlan } = await import('@/libs/payment/stripe/server'); + + const stripe = new Stripe(stripeKey!, { + httpClient: Stripe.createFetchHttpClient(), + }); + const plan = await getHighestActivePlan(stripe, customerId!); + console.info(`[live] highest active plan for ${customerId}: ${plan}`); + + if (expectedPlan) { + expect(plan).toBe(expectedPlan); + } else { + expect(['free', 'plus', 'pro', 'purchase']).toContain(plan); + } + }, + ); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/stripe-server.test.ts b/apps/readest-app/src/__tests__/libs/payment/stripe-server.test.ts new file mode 100644 index 000000000..eedb3f2f6 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/stripe-server.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// When a user upgrades Plus -> Pro on Stripe, both subscriptions stay active +// for a while (the old Plus one is not cancelled immediately). Each webhook +// calls `createOrUpdateSubscription`, which used to overwrite `plans.plan` with +// the plan of whichever subscription's event fired last. If the Plus event was +// processed after the Pro event, the user was downgraded to `plus`. +// +// The plan written to the `plans` table must reflect the HIGHEST active plan +// the user holds, regardless of webhook ordering. + +const stripeMocks = vi.hoisted(() => ({ + subscriptionsRetrieve: vi.fn(), + subscriptionsList: vi.fn(), +})); + +const db = vi.hoisted(() => ({ + existingSubscription: null as unknown, + planUpdates: [] as Array>, +})); + +vi.mock('stripe', () => { + function MockStripe() { + return { + subscriptions: { + retrieve: stripeMocks.subscriptionsRetrieve, + list: stripeMocks.subscriptionsList, + }, + }; + } + MockStripe.createFetchHttpClient = () => ({}); + return { default: MockStripe }; +}); + +vi.mock('@/utils/supabase', () => ({ + createSupabaseAdminClient: () => ({ + from: (table: string) => { + if (table === 'plans') { + return { + update: (values: Record) => ({ + eq: () => { + db.planUpdates.push(values); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + } + if (table === 'subscriptions') { + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data: db.existingSubscription }), + }), + }), + update: () => ({ eq: () => Promise.resolve({ data: null, error: null }) }), + insert: () => Promise.resolve({ data: null, error: null }), + }; + } + throw new Error(`unexpected table: ${table}`); + }, + }), +})); + +import { + createOrUpdateSubscription, + getHighestActivePlan, + getStripe, +} from '@/libs/payment/stripe/server'; + +const makeSub = (id: string, plan: string, status = 'active') => ({ + id, + status, + items: { + data: [ + { + price: { id: `price_${plan}`, product: { id: `prod_${plan}`, metadata: { plan } } }, + current_period_start: 1700000000, + current_period_end: 1702592000, + }, + ], + }, +}); + +beforeEach(() => { + stripeMocks.subscriptionsRetrieve.mockReset(); + stripeMocks.subscriptionsList.mockReset(); + db.existingSubscription = null; + db.planUpdates = []; + process.env['STRIPE_SECRET_KEY_DEV'] = 'sk_test_dummy'; +}); + +describe('getHighestActivePlan', () => { + it('returns the highest plan among multiple active subscriptions', async () => { + stripeMocks.subscriptionsList.mockResolvedValue({ + data: [ + { id: 'sub_plus', status: 'active' }, + { id: 'sub_pro', status: 'active' }, + ], + }); + stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) => + Promise.resolve(id === 'sub_pro' ? makeSub('sub_pro', 'pro') : makeSub('sub_plus', 'plus')), + ); + + expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('pro'); + }); + + it('ignores subscriptions that are not active or trialing', async () => { + stripeMocks.subscriptionsList.mockResolvedValue({ + data: [ + { id: 'sub_pro_old', status: 'canceled' }, + { id: 'sub_plus', status: 'active' }, + { id: 'sub_pro_pastdue', status: 'past_due' }, + ], + }); + stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) => + Promise.resolve(makeSub(id, 'plus')), + ); + + expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('plus'); + // Only the single active subscription needs to be retrieved. + expect(stripeMocks.subscriptionsRetrieve).toHaveBeenCalledTimes(1); + }); + + it('counts trialing subscriptions', async () => { + stripeMocks.subscriptionsList.mockResolvedValue({ + data: [{ id: 'sub_pro', status: 'trialing' }], + }); + stripeMocks.subscriptionsRetrieve.mockResolvedValue(makeSub('sub_pro', 'pro')); + + expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('pro'); + }); + + it('returns "free" when no subscription is active', async () => { + stripeMocks.subscriptionsList.mockResolvedValue({ + data: [{ id: 'sub_plus', status: 'canceled' }], + }); + + expect(await getHighestActivePlan(getStripe(), 'cus_1')).toBe('free'); + expect(stripeMocks.subscriptionsRetrieve).not.toHaveBeenCalled(); + }); +}); + +describe('createOrUpdateSubscription', () => { + it('writes the highest active plan when a user holds both Plus and Pro (Plus event fires last)', async () => { + db.existingSubscription = { id: 1 }; + // The webhook being processed is for the older Plus subscription. + stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) => + Promise.resolve(id === 'sub_pro' ? makeSub('sub_pro', 'pro') : makeSub('sub_plus', 'plus')), + ); + stripeMocks.subscriptionsList.mockResolvedValue({ + data: [ + { id: 'sub_plus', status: 'active' }, + { id: 'sub_pro', status: 'active' }, + ], + }); + + await createOrUpdateSubscription('user-1', 'cus_1', 'sub_plus'); + + expect(db.planUpdates.at(-1)?.['plan']).toBe('pro'); + }); + + it('keeps the active higher plan even when the triggering subscription is past_due', async () => { + db.existingSubscription = { id: 1 }; + stripeMocks.subscriptionsRetrieve.mockImplementation((id: string) => + Promise.resolve( + id === 'sub_pro' ? makeSub('sub_pro', 'pro') : makeSub('sub_plus', 'plus', 'past_due'), + ), + ); + stripeMocks.subscriptionsList.mockResolvedValue({ + data: [ + { id: 'sub_plus', status: 'past_due' }, + { id: 'sub_pro', status: 'active' }, + ], + }); + + await createOrUpdateSubscription('user-1', 'cus_1', 'sub_plus'); + + expect(db.planUpdates.at(-1)?.['plan']).toBe('pro'); + }); + + it('downgrades to "free" when the only subscription becomes inactive', async () => { + db.existingSubscription = { id: 1 }; + stripeMocks.subscriptionsRetrieve.mockResolvedValue(makeSub('sub_plus', 'plus', 'canceled')); + stripeMocks.subscriptionsList.mockResolvedValue({ + data: [{ id: 'sub_plus', status: 'canceled' }], + }); + + await createOrUpdateSubscription('user-1', 'cus_1', 'sub_plus'); + + expect(db.planUpdates.at(-1)?.['plan']).toBe('free'); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/storage.test.ts b/apps/readest-app/src/__tests__/libs/storage.test.ts new file mode 100644 index 000000000..24f3a6104 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/storage.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/services/environment', () => ({ + getAPIBaseUrl: () => 'https://api.test', + isWebAppPlatform: () => false, +})); +vi.mock('@/utils/access', () => ({ getUserID: vi.fn() })); +vi.mock('@/utils/fetch', () => ({ fetchWithAuth: vi.fn() })); +vi.mock('@/utils/transfer', () => ({ + tauriUpload: vi.fn(), + tauriDownload: vi.fn(), + webUpload: vi.fn(), + webDownload: vi.fn(), +})); + +import { deleteFile } from '@/libs/storage'; +import { getUserID } from '@/utils/access'; +import { fetchWithAuth } from '@/utils/fetch'; + +describe('deleteFile (cloud) — best-effort cleanup (READEST-5)', () => { + beforeEach(() => vi.clearAllMocks()); + + test('resolves without throwing when the delete request fails', async () => { + vi.mocked(getUserID).mockResolvedValue('user-1'); + vi.mocked(fetchWithAuth).mockRejectedValue(new Error('network down')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Callers dispatch this without awaiting; a throw here becomes an unhandled + // rejection. It must swallow the failure and just log it. + await expect(deleteFile('books/x.epub')).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + + warn.mockRestore(); + }); + + test('resolves without throwing when the user is not authenticated', async () => { + vi.mocked(getUserID).mockResolvedValue(null); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(deleteFile('books/x.epub')).resolves.toBeUndefined(); + expect(fetchWithAuth).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); +}); diff --git a/apps/readest-app/src/__tests__/pages/api/sync-cover-merge.test.ts b/apps/readest-app/src/__tests__/pages/api/sync-cover-merge.test.ts new file mode 100644 index 000000000..32bf3da8b --- /dev/null +++ b/apps/readest-app/src/__tests__/pages/api/sync-cover-merge.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCoverMerge } from '@/pages/api/sync'; + +const iso = (ms: number) => new Date(ms).toISOString(); + +describe('resolveCoverMerge (issue #4544)', () => { + it('keeps the client cover when its cover_updated_at is newer', () => { + const out = resolveCoverMerge( + { cover_hash: 'newhash', cover_updated_at: iso(200) }, + { cover_hash: 'oldhash', cover_updated_at: iso(100) }, + ); + expect(out).toEqual({ cover_hash: 'newhash', cover_updated_at: iso(200) }); + }); + + it('keeps the server cover when its cover_updated_at is newer', () => { + const out = resolveCoverMerge( + { cover_hash: 'clienthash', cover_updated_at: iso(100) }, + { cover_hash: 'serverhash', cover_updated_at: iso(300) }, + ); + expect(out).toEqual({ cover_hash: 'serverhash', cover_updated_at: iso(300) }); + }); + + it('ties go to the client', () => { + const out = resolveCoverMerge( + { cover_hash: 'clienthash', cover_updated_at: iso(150) }, + { cover_hash: 'serverhash', cover_updated_at: iso(150) }, + ); + expect(out).toEqual({ cover_hash: 'clienthash', cover_updated_at: iso(150) }); + }); + + it('treats a missing/null timestamp as oldest', () => { + // Server has a real cover edit; an unstamped client (legacy / page-turn + // push carrying no cover_updated_at) must not win and clobber it. + const out = resolveCoverMerge( + { cover_hash: 'staleclient', cover_updated_at: null }, + { cover_hash: 'realserver', cover_updated_at: iso(1) }, + ); + expect(out).toEqual({ cover_hash: 'realserver', cover_updated_at: iso(1) }); + }); + + it('both unset → null cover (no spurious change)', () => { + const out = resolveCoverMerge( + { cover_hash: null, cover_updated_at: null }, + { cover_hash: null, cover_updated_at: null }, + ); + expect(out).toEqual({ cover_hash: null, cover_updated_at: null }); + }); +}); diff --git a/apps/readest-app/src/__tests__/pages/api/sync-synced-at-cursor.test.ts b/apps/readest-app/src/__tests__/pages/api/sync-synced-at-cursor.test.ts new file mode 100644 index 000000000..aab7294f1 --- /dev/null +++ b/apps/readest-app/src/__tests__/pages/api/sync-synced-at-cursor.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { buildStatusPropagationRow } from '@/pages/api/sync'; +import type { DBBook } from '@/types/records'; + +const iso = (ms: number) => new Date(ms).toISOString(); + +// Issue #4678: when the server wins a books row but the client's reading_status +// is newer, the change must still reach every peer. It used to be propagated by +// rewriting `updated_at = now()`, which reordered the date-read library by +// sync-processing time (#4677). Now the `synced_at` trigger advances the pull +// cursor on the write, so the propagation row keeps `updated_at` untouched. +describe('buildStatusPropagationRow', () => { + const serverBook = { + user_id: 'u', + book_hash: 'h', + format: 'EPUB', + title: 'T', + author: 'A', + updated_at: iso(1000), + reading_status: 'reading', + reading_status_updated_at: iso(500), + } as unknown as DBBook; + + const fresherStatus = { + reading_status: 'finished', + reading_status_updated_at: iso(2000), + }; + + it('grafts the fresher status onto the server row', () => { + const row = buildStatusPropagationRow(serverBook, fresherStatus); + expect(row.reading_status).toBe('finished'); + expect(row.reading_status_updated_at).toBe(iso(2000)); + }); + + it('leaves updated_at untouched so the date-read sort never jumps to sync time', () => { + const row = buildStatusPropagationRow(serverBook, fresherStatus); + expect(row.updated_at).toBe(serverBook.updated_at); + }); + + it('preserves the rest of the server row', () => { + const row = buildStatusPropagationRow(serverBook, fresherStatus); + expect(row.book_hash).toBe('h'); + expect(row.title).toBe('T'); + expect(row.format).toBe('EPUB'); + }); +}); diff --git a/apps/readest-app/src/__tests__/paragraph-mode.test.tsx b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx index 66e83b78d..e21b05d94 100644 --- a/apps/readest-app/src/__tests__/paragraph-mode.test.tsx +++ b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx @@ -208,6 +208,89 @@ describe('paragraph mode', () => { }); }); + it('resumes without scrolling the underlying view so repeated enter/exit cannot rewind (#4717)', async () => { + const doc = createDoc('

Para A

Para B

Para C

'); + const { view, renderer } = createMockView([doc], 0); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange).toBeTruthy(); + }); + + // Resuming/entering focuses the paragraph already at the reading position. + // Scrolling the underlying view to that paragraph's start rewinds whenever it + // began on an earlier page, so the view must NOT be moved on resume (#4717). + expect(renderer.goTo).not.toHaveBeenCalled(); + expect(renderer.scrollToAnchor).not.toHaveBeenCalled(); + }); + + it('resumes at the view live CFI even when the store progress is stale (#4717)', async () => { + const doc = createDoc('

Block zero

Block one

Block two

'); + const { view } = createMockView([doc], 0); + // The rAF-debounced store (mockGetProgress) returns null/stale; the view's + // live lastLocation CFI points at the third paragraph. Resume must follow the + // live CFI (resolved against the current doc), not fall back to chapter start. + const thirdParagraph = doc.querySelectorAll('p')[2]!; + (view as unknown as { lastLocation: { cfi: string } }).lastLocation = { cfi: 'cfi-live' }; + (view.resolveCFI as ReturnType).mockImplementation((cfi: string) => + cfi === 'cfi-live' + ? { + index: 0, + anchor: () => { + const r = doc.createRange(); + r.selectNodeContents(thirdParagraph); + return r; + }, + } + : null, + ); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block two'); + }); + }); + + it('does not scroll the underlying view when exiting paragraph mode (#4717)', async () => { + const doc = createDoc('

Para A

Para B

'); + const { view, renderer } = createMockView([doc], 0); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange).toBeTruthy(); + }); + + await act(async () => { + await hookApi?.toggleParagraphMode(); + }); + + expect(renderer.scrollToAnchor).not.toHaveBeenCalled(); + }); + + it('still scrolls the underlying view when navigating paragraphs', async () => { + const doc = createDoc('

Para A

Para B

Para C

'); + const { view, renderer } = createMockView([doc], 0); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange).toBeTruthy(); + }); + + await act(async () => { + await hookApi?.goToNextParagraph(); + }); + + // Navigation to another paragraph must move the underlying view (the goTo + // runs after a rAF inside focusCurrentParagraph, so wait for it). + await waitFor(() => { + expect(renderer.goTo).toHaveBeenCalled(); + }); + }); + it('renders preserved presentation and layout-aware click zones in the overlay', async () => { const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch'); const overlayBookKey = 'overlay-book'; @@ -381,6 +464,56 @@ describe('paragraph mode', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + + const getDialog = (container: HTMLElement) => + container.querySelector('[role="dialog"]') as HTMLDivElement; + + it('focuses the dialog when it opens so it receives keys directly (#4717)', async () => { + const { container } = await renderVisibleOverlay(vi.fn()); + const dialog = getDialog(container); + expect(document.activeElement).toBe(dialog); + }); + + it('exits when the toggle paragraph mode shortcut (Shift+P) is pressed (#4717)', async () => { + const onClose = vi.fn(); + const { container } = await renderVisibleOverlay(onClose); + + fireEvent.keyDown(getDialog(container), { key: 'P', shiftKey: true }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('exits when Escape is pressed on the dialog (#4717)', async () => { + const onClose = vi.fn(); + const { container } = await renderVisibleOverlay(onClose); + + fireEvent.keyDown(getDialog(container), { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('stops the toggle key from propagating so it cannot fire twice (#4717)', async () => { + const onClose = vi.fn(); + const { container } = await renderVisibleOverlay(onClose); + const windowSpy = vi.fn(); + window.addEventListener('keydown', windowSpy); + + fireEvent.keyDown(getDialog(container), { key: 'P', shiftKey: true }); + + // The dialog handler must stop propagation so the global useShortcuts + // handler never receives the same keypress (which would re-toggle). + expect(windowSpy).not.toHaveBeenCalled(); + window.removeEventListener('keydown', windowSpy); + }); + + it('does not exit on an unrelated key while visible', async () => { + const onClose = vi.fn(); + const { container } = await renderVisibleOverlay(onClose); + + fireEvent.keyDown(getDialog(container), { key: 'x' }); + + expect(onClose).not.toHaveBeenCalled(); + }); }); describe('paragraph mode TTS sync', () => { diff --git a/apps/readest-app/src/__tests__/reader/iframe-keyboard-selection.browser.test.ts b/apps/readest-app/src/__tests__/reader/iframe-keyboard-selection.browser.test.ts new file mode 100644 index 000000000..72b36b1fb --- /dev/null +++ b/apps/readest-app/src/__tests__/reader/iframe-keyboard-selection.browser.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { extendSelectionFromContents } from '@/utils/sel'; + +// Standard desktop selection shortcuts (#4728). The parent (where focus sits +// after a selection) extends the iframe selection via this helper. Selection.modify() +// needs a real layout engine, so this runs in the Chromium browser lane. + +const iframes: HTMLIFrameElement[] = []; + +// Render an isolated document in an iframe, the way foliate renders a section. +const renderSection = (bodyHtml: string) => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframes.push(iframe); + const doc = iframe.contentDocument!; + doc.body.innerHTML = bodyHtml; + return { doc, win: iframe.contentWindow! }; +}; + +// Mimic a real left-to-right mouse drag: setBaseAndExtent establishes the +// anchor/focus directionality that Selection.modify() needs. (addRange() leaves +// the selection directionless, so backward modify() would silently no-op — a +// test artifact, not how a user-made selection behaves.) +const selectRange = (win: Window, node: Node, start: number, end: number) => { + const sel = win.getSelection()!; + sel.setBaseAndExtent(node, start, node, end); + return sel; +}; + +afterEach(() => { + while (iframes.length) iframes.pop()!.remove(); +}); + +describe('extendSelectionFromContents (#4728)', () => { + test('Shift+ArrowRight extends the selection by a character', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); // "Hello" + + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true }, + true, + ); + + expect(handled).toBe(true); + expect(sel.toString().length).toBe(6); + expect(sel.toString().startsWith('Hello')).toBe(true); + }); + + test('Ctrl+Shift+ArrowRight extends the selection by a word', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); // "Hello" + + extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true, ctrlKey: true }, + true, + ); + + expect(sel.toString()).toContain('world'); + expect(sel.toString().length).toBeGreaterThan(6); + }); + + test('Alt(Option)+Shift+ArrowRight extends by a word (macOS modifier)', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); + + extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true, altKey: true }, + true, + ); + + expect(sel.toString()).toContain('world'); + }); + + test('Shift+ArrowLeft moves the active edge back by a character', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); // "Hello" + + extendSelectionFromContents([{ doc }], { key: 'ArrowLeft', shiftKey: true }, true); + + expect(sel.toString()).toBe('Hell'); + }); + + test('with extend=false it reports the selection but does not modify it', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); + + // Mirrors the iframe-focused case: the browser already extended natively, so + // the parent only needs to confirm a selection exists (to suppress nav). + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true }, + false, + ); + + expect(handled).toBe(true); + expect(sel.toString()).toBe('Hello'); // unchanged + }); + + test('returns false when no selection is active so navigation still works', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + win.getSelection()!.removeAllRanges(); + + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true }, + true, + ); + + expect(handled).toBe(false); + }); + + test('returns false when Meta/Cmd is held (reserved for native line selection)', () => { + const { doc, win } = renderSection('

Hello world from Readest

'); + const sel = selectRange(win, doc.querySelector('p')!.firstChild!, 0, 5); + + const handled = extendSelectionFromContents( + [{ doc }], + { key: 'ArrowRight', shiftKey: true, metaKey: true }, + true, + ); + + expect(handled).toBe(false); + expect(sel.toString()).toBe('Hello'); + }); + + test('finds the selection across multiple rendered section documents', () => { + const a = renderSection('

First section

'); + const b = renderSection('

Second section

'); + a.win.getSelection()!.removeAllRanges(); + const sel = selectRange(b.win, b.doc.querySelector('p')!.firstChild!, 0, 6); // "Second" + + const handled = extendSelectionFromContents( + [{ doc: a.doc }, { doc: b.doc }], + { key: 'ArrowRight', shiftKey: true, ctrlKey: true }, + true, + ); + + expect(handled).toBe(true); + expect(sel.toString()).toContain('section'); + }); +}); diff --git a/apps/readest-app/src/__tests__/reader/utils/autoscroller.test.ts b/apps/readest-app/src/__tests__/reader/utils/autoscroller.test.ts new file mode 100644 index 000000000..e73b7c599 --- /dev/null +++ b/apps/readest-app/src/__tests__/reader/utils/autoscroller.test.ts @@ -0,0 +1,313 @@ +import { describe, test, expect, vi } from 'vitest'; +import { + Autoscroller, + PacedScroller, + AUTOSCROLL_DEAD_ZONE_PX, + AUTOSCROLL_SPEED_PER_PX, + AUTOSCROLL_MAX_VELOCITY, + PACED_SCROLL_MAX_FRAME_MS, +} from '@/app/reader/utils/autoscroller'; + +// Middle-click autoscroll core (#4951). The controller is pure logic driven by +// an injected rAF/clock so the speed model and the press/release state machine +// can be tested deterministically. +const createHarness = () => { + let frameCb: FrameRequestCallback | null = null; + let time = 0; + const deltas: number[] = []; + const onStop = vi.fn(); + const scroller = new Autoscroller({ + scrollBy: (delta) => deltas.push(delta), + onStop, + raf: (cb) => { + frameCb = cb; + return 1; + }, + caf: () => { + frameCb = null; + }, + now: () => time, + }); + // Advance the clock and fire the pending animation frame (which re-arms itself). + const step = (ms: number) => { + time += ms; + const cb = frameCb; + frameCb = null; + cb?.(time); + }; + const scrolledTotal = () => deltas.reduce((a, b) => a + b, 0); + const hasPendingFrame = () => frameCb !== null; + return { scroller, deltas, onStop, step, scrolledTotal, hasPendingFrame }; +}; + +describe('Autoscroller speed model', () => { + test('is inactive until started', () => { + const { scroller, hasPendingFrame } = createHarness(); + expect(scroller.active).toBe(false); + expect(hasPendingFrame()).toBe(false); + scroller.start(100, 100, 'y'); + expect(scroller.active).toBe(true); + expect(hasPendingFrame()).toBe(true); + }); + + test('does not scroll while the pointer stays within the dead zone', () => { + const { scroller, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'y'); + scroller.move(100, 100 + AUTOSCROLL_DEAD_ZONE_PX - 2); + step(100); + step(100); + expect(scrolledTotal()).toBe(0); + }); + + test('scrolls proportionally to the distance beyond the dead zone', () => { + const { scroller, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'y'); + // 50px beyond the dead zone -> 50 * SPEED px/s; 100ms -> a tenth of that. + scroller.move(100, 100 + AUTOSCROLL_DEAD_ZONE_PX + 50); + step(100); + expect(scrolledTotal()).toBe((50 * AUTOSCROLL_SPEED_PER_PX) / 10); + }); + + test('scrolls backwards when the pointer is before the anchor', () => { + const { scroller, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'y'); + scroller.move(100, 100 - AUTOSCROLL_DEAD_ZONE_PX - 50); + step(100); + expect(scrolledTotal()).toBe(-(50 * AUTOSCROLL_SPEED_PER_PX) / 10); + }); + + test('uses the x displacement on the horizontal axis and ignores y', () => { + const { scroller, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'x'); + scroller.move(100 + AUTOSCROLL_DEAD_ZONE_PX + 50, 500); + step(100); + expect(scrolledTotal()).toBe((50 * AUTOSCROLL_SPEED_PER_PX) / 10); + }); + + test('caps the scroll velocity', () => { + const { scroller, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'y'); + scroller.move(100, 100 + AUTOSCROLL_DEAD_ZONE_PX + 100000); + step(100); + expect(scrolledTotal()).toBe(AUTOSCROLL_MAX_VELOCITY / 10); + }); + + test('emits whole pixels and carries the fractional remainder', () => { + const { scroller, deltas, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'y'); + // 2px beyond the dead zone -> 2 * SPEED px/s (20px/s by default): a 10ms + // frame yields 0.2px, below one pixel, so nothing may be emitted per frame. + scroller.move(100, 100 + AUTOSCROLL_DEAD_ZONE_PX + 2); + for (let i = 0; i < 10; i++) step(10); + expect(scrolledTotal()).toBe((2 * AUTOSCROLL_SPEED_PER_PX) / 10); + expect(deltas.every((d) => Number.isInteger(d))).toBe(true); + }); +}); + +describe('Autoscroller press/release state machine', () => { + test('release after dragging beyond the dead zone stops scrolling', () => { + const { scroller, onStop, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'y'); + scroller.move(100, 100 + AUTOSCROLL_DEAD_ZONE_PX + 50); + step(100); + const scrolledWhileHeld = scrolledTotal(); + expect(scrolledWhileHeld).toBeGreaterThan(0); + scroller.release(); + expect(scroller.active).toBe(false); + expect(onStop).toHaveBeenCalledTimes(1); + step(100); + expect(scrolledTotal()).toBe(scrolledWhileHeld); + }); + + test('a quick release near the anchor enters sticky mode', () => { + const { scroller, onStop, step, scrolledTotal } = createHarness(); + scroller.start(100, 100, 'y'); + scroller.move(100, 105); + scroller.release(); + expect(scroller.active).toBe(true); + expect(onStop).not.toHaveBeenCalled(); + scroller.move(100, 100 + AUTOSCROLL_DEAD_ZONE_PX + 50); + step(100); + expect(scrolledTotal()).toBeGreaterThan(0); + scroller.stop(); + expect(scroller.active).toBe(false); + expect(onStop).toHaveBeenCalledTimes(1); + }); + + test('stop is idempotent and cancels the frame loop', () => { + const { scroller, onStop, hasPendingFrame } = createHarness(); + scroller.stop(); + expect(onStop).not.toHaveBeenCalled(); + scroller.start(100, 100, 'y'); + scroller.stop(); + scroller.stop(); + expect(onStop).toHaveBeenCalledTimes(1); + expect(hasPendingFrame()).toBe(false); + }); +}); + +// Auto Scroll reading mode core (#4998): constant-velocity teleprompter +// scrolling, same injected rAF/clock testing approach as the middle-click core. +const createPacedHarness = () => { + let frameCb: FrameRequestCallback | null = null; + let time = 0; + const deltas: number[] = []; + const onStop = vi.fn(); + const scroller = new PacedScroller({ + scrollBy: (delta) => deltas.push(delta), + onStop, + raf: (cb) => { + frameCb = cb; + return 1; + }, + caf: () => { + frameCb = null; + }, + now: () => time, + }); + const step = (ms: number) => { + time += ms; + const cb = frameCb; + frameCb = null; + cb?.(time); + }; + const advanceIdle = (ms: number) => { + time += ms; + }; + const scrolledTotal = () => deltas.reduce((a, b) => a + b, 0); + const hasPendingFrame = () => frameCb !== null; + return { scroller, deltas, onStop, step, advanceIdle, scrolledTotal, hasPendingFrame }; +}; + +describe('PacedScroller', () => { + test('is inactive until started', () => { + const { scroller, hasPendingFrame } = createPacedHarness(); + expect(scroller.active).toBe(false); + expect(hasPendingFrame()).toBe(false); + scroller.start(100); + expect(scroller.active).toBe(true); + expect(scroller.paused).toBe(false); + expect(hasPendingFrame()).toBe(true); + }); + + test('scrolls at the configured velocity', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(100); + expect(scrolledTotal()).toBe(10); + step(100); + expect(scrolledTotal()).toBe(20); + }); + + test('emits whole pixels and carries the fractional remainder', () => { + const { scroller, deltas, step, scrolledTotal } = createPacedHarness(); + // 25 px/s over 10ms frames yields 0.25px per frame; only whole pixels may + // be emitted, with the remainder carried so slow speeds still progress. + scroller.start(25); + for (let i = 0; i < 8; i++) step(10); + expect(scrolledTotal()).toBe(2); + expect(deltas.every((d) => Number.isInteger(d))).toBe(true); + }); + + test('setVelocity takes effect immediately', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(100); + expect(scrolledTotal()).toBe(10); + scroller.setVelocity(200); + step(100); + expect(scrolledTotal()).toBe(30); + }); + + test('pause halts emission and resume continues without a jump', () => { + const { scroller, step, advanceIdle, scrolledTotal, hasPendingFrame } = createPacedHarness(); + scroller.start(100); + step(100); + expect(scrolledTotal()).toBe(10); + scroller.pause(); + expect(scroller.active).toBe(true); + expect(scroller.paused).toBe(true); + expect(hasPendingFrame()).toBe(false); + // Time spent paused must not be scrolled through on resume. + advanceIdle(5000); + scroller.resume(); + expect(scroller.paused).toBe(false); + step(100); + expect(scrolledTotal()).toBe(20); + }); + + test('clamps oversized frame gaps so a background tab does not jump', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(10 * PACED_SCROLL_MAX_FRAME_MS); + expect(scrolledTotal()).toBe((100 * PACED_SCROLL_MAX_FRAME_MS) / 1000); + }); + + test('pause and resume are no-ops while inactive', () => { + const { scroller, onStop, hasPendingFrame } = createPacedHarness(); + scroller.pause(); + scroller.resume(); + expect(scroller.active).toBe(false); + expect(hasPendingFrame()).toBe(false); + expect(onStop).not.toHaveBeenCalled(); + }); + + test('stop is idempotent, cancels the frame loop, and fires onStop once', () => { + const { scroller, onStop, hasPendingFrame } = createPacedHarness(); + scroller.stop(); + expect(onStop).not.toHaveBeenCalled(); + scroller.start(100); + scroller.stop(); + scroller.stop(); + expect(onStop).toHaveBeenCalledTimes(1); + expect(hasPendingFrame()).toBe(false); + }); + + test('stop while paused fires onStop and stays stopped', () => { + const { scroller, onStop, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + scroller.pause(); + scroller.stop(); + expect(scroller.active).toBe(false); + expect(onStop).toHaveBeenCalledTimes(1); + step(100); + expect(scrolledTotal()).toBe(0); + }); + + test('restart after stop scrolls again', () => { + const { scroller, step, scrolledTotal } = createPacedHarness(); + scroller.start(100); + step(100); + scroller.stop(); + scroller.start(50); + step(100); + expect(scrolledTotal()).toBe(15); + }); + + test('a scrollBy callback that stops the scroller does not re-arm the frame loop', () => { + let frameCb: FrameRequestCallback | null = null; + let time = 0; + const onStop = vi.fn(); + const scroller: PacedScroller = new PacedScroller({ + // The session auto-stops from inside a tick when the book ends. + scrollBy: () => scroller.stop(), + onStop, + raf: (cb) => { + frameCb = cb; + return 1; + }, + caf: () => { + frameCb = null; + }, + now: () => time, + }); + scroller.start(100); + time += 100; + const cb = frameCb!; + frameCb = null; + cb(time); + expect(onStop).toHaveBeenCalledTimes(1); + expect(scroller.active).toBe(false); + expect(frameCb).toBe(null); + }); +}); diff --git a/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts b/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts index 6d29606f3..fd0e998bd 100644 --- a/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts +++ b/apps/readest-app/src/__tests__/reader/utils/iframeEventHandlers.test.ts @@ -176,7 +176,7 @@ describe('single-tap opens image gallery / table zoom in reflowable books (#4584 expect(types).not.toContain('iframe-open-media'); }); - test('reflowable: tap on a linked image follows the link (posts neither)', async () => { + test('reflowable: tap on a linked image opens the viewer instead of following the link (#4757)', async () => { const handlers = await importHandlers(); const anchor = document.createElement('a'); anchor.href = 'https://example.com'; @@ -186,8 +186,27 @@ describe('single-tap opens image gallery / table zoom in reflowable books (#4584 tap(handlers, false, img); + const messages = postedMessages(); + const types = messages.map((m) => m['type']); + expect(types).toContain('iframe-open-media'); + expect(types).not.toContain('iframe-single-click'); + const media = messages.find((m) => m['type'] === 'iframe-open-media')!; + expect(media['elementType']).toBe('image'); + expect(media['src']).toBe(img.src); + }); + + test('reflowable: tap on a footnote-link image keeps footnote behavior, not the media viewer', async () => { + const handlers = await importHandlers(); + const anchor = document.createElement('a'); + anchor.className = 'duokan-footnote'; + anchor.href = '#note-1'; + const img = document.createElement('img'); + img.src = 'blob:http://localhost/note'; + anchor.appendChild(img); + + tap(handlers, false, img); + const types = postedMessages().map((m) => m['type']); expect(types).not.toContain('iframe-open-media'); - expect(types).not.toContain('iframe-single-click'); }); }); diff --git a/apps/readest-app/src/__tests__/scripts/build-wordlens-data.test.ts b/apps/readest-app/src/__tests__/scripts/build-wordlens-data.test.ts index ef5974cc2..77dfb12fa 100644 --- a/apps/readest-app/src/__tests__/scripts/build-wordlens-data.test.ts +++ b/apps/readest-app/src/__tests__/scripts/build-wordlens-data.test.ts @@ -5,10 +5,16 @@ import { parseCsvLine, parseExchange, shortGloss, + shortDefGloss, + finalizeInflections as finalizeInflectionsUntyped, + parseWordNetData as parseWordNetDataUntyped, + parseWordNetIndex as parseWordNetIndexUntyped, + resolveEnEnGloss as resolveEnEnGlossUntyped, sha256Hex, packEntry, buildManifest, buildEnZh as buildEnZhUntyped, + buildEnEn as buildEnEnUntyped, buildZhEn as buildZhEnUntyped, parseFrequencyWords as parseFrequencyWordsUntyped, extractXToEn as extractXToEnUntyped, @@ -23,6 +29,45 @@ import type { GlossIndexData } from '@/services/wordlens/types'; // The .mjs script has no type annotations; pin the builders' returns to the // real GlossIndexData shape so the assertions are type-checked. const buildEnZh = buildEnZhUntyped as (csvText: string, topN: number) => GlossIndexData; + +type WnSynset = { members: string[]; hyper: string | null }; +type WnPrimary = { + noun: Map; + verb: Map; + adj: Map; + adv: Map; +}; +interface WordNet { + byKey: Map; + primary: WnPrimary; +} +const emptyWordNet = (): WordNet => ({ + byKey: new Map(), + primary: { noun: new Map(), verb: new Map(), adj: new Map(), adv: new Map() }, +}); +const buildEnEn = buildEnEnUntyped as ( + csvText: string, + wordnet: WordNet, + topN: number, +) => GlossIndexData; +const finalizeInflections = finalizeInflectionsUntyped as ( + entries: Record, + inflections: Record, +) => void; +const parseWordNetData = parseWordNetDataUntyped as ( + text: string, + pos: string, + byKey?: Map, +) => Map; +const parseWordNetIndex = parseWordNetIndexUntyped as ( + text: string, + map?: Map, +) => Map; +const resolveEnEnGloss = resolveEnEnGlossUntyped as ( + word: string, + rawDef: string, + ctx: { byKey: Map; primary: WnPrimary; frqMap: Map }, +) => string; const buildZhEn = buildZhEnUntyped as ( cedictText: string, hskJson: unknown, @@ -87,15 +132,14 @@ describe('parseExchange', () => { }); describe('shortGloss', () => { - it('truncates to the first 1-2 senses joined by ;and ≤24 chars', () => { + it('keeps the first 1-2 senses joined by ;', () => { const g = shortGloss('to run; to operate; to manage; foo'); expect(g).toBe('to run;to operate'); - expect(g.length).toBeLessThanOrEqual(24); }); - it('caps overly long senses at 24 chars', () => { + it('does not length-cap a long sense (the cap is applied at display, not in the pack)', () => { const g = shortGloss('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); - expect(g.length).toBe(24); + expect(g).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); // 32 chars stored as-is }); it('strips leading POS tags, bracket annotations, and CL: clauses', () => { @@ -104,6 +148,281 @@ describe('shortGloss', () => { expect(shortGloss('[网络] 隐;晦涩的')).toBe('隐;晦涩的'); expect(shortGloss('government/CL:個|个[ge4]')).toBe('government'); }); + + it('keeps only the first synonym within each sense (","/"、"-separated)', () => { + // ";" separates senses; "," within a sense separates near-synonyms → keep first. + expect(shortGloss('阻止, 监禁, 拘留;隔离, 拘留, 滞留, 停')).toBe('阻止;隔离'); + expect(shortGloss('house, home')).toBe('house'); + }); + + it('dedupes when two senses share the same first synonym', () => { + expect(shortGloss('天的, 天国的, 天空的;天的, 天空的, 天国')).toBe('天的'); + }); +}); + +describe('shortDefGloss', () => { + it('shows at most two senses when there is no ";" (\\n-separated)', () => { + // ECDICT separates sense lines with a literal "\n" (backslash-n). + const def = String.raw`to begin\nto start\nto set in motion`; + expect(shortDefGloss(def)).toBe('to begin; to start'); + }); + + it('treats each ";" part as a sense and keeps at most two of them', () => { + expect(shortDefGloss('quick; fast')).toBe('quick; fast'); + expect(shortDefGloss('a; b; c; d; e')).toBe('a; b'); // at most two ";" parts + // a ";" within a line is a sense even when the line came from a \n split. + expect(shortDefGloss(String.raw`of an obscure nature; puzzling\nhidden`)).toBe( + 'of an obscure nature; puzzling', + ); + }); + + it('stores the full ≤2-sense definition without length-capping (the cap is applied at display)', () => { + expect(shortDefGloss('enjoying or showing or marked by joy or pleasure')).toBe( + 'enjoying or showing or marked by joy or pleasure', + ); + }); + + it('strips POS codes — period-terminated (n./adv.) or bare WordNet (v/s)', () => { + expect(shortDefGloss('v take the first step')).toBe('take the first step'); + expect(shortDefGloss('s being present')).toBe('being present'); + expect(shortDefGloss('adv. in a quick way')).toBe('in a quick way'); + // a bare leading "a" is the article, not the adjective POS code → kept. + expect(shortDefGloss('n. a small house')).toBe('a small house'); + }); + + it('strips bracket annotations and a Webster-style trailing period', () => { + expect(shortDefGloss('a happy state [psychology]')).toBe('a happy state'); + expect(shortDefGloss('A magician.')).toBe('A magician'); + }); + + it('returns empty string for empty / whitespace-only / all-empty senses', () => { + expect(shortDefGloss('')).toBe(''); + expect(shortDefGloss(' ')).toBe(''); + expect(shortDefGloss(String.raw`\n \n`)).toBe(''); + }); +}); + +describe('buildEnEn', () => { + const header = + 'word,phonetic,definition,translation,pos,collins,oxford,tag,bnc,frq,exchange,detail,audio'; + const csv = [ + header, + 'Run,/rʌn/,move fast on foot,跑;经营,v,3,,,100,312,p:ran/i:running/3:runs/0:run,,', + String.raw`cryptic,/ˈkrɪptɪk/,having a hidden meaning\nmysterious,晦涩的,adj,2,,,500,18000,,,`, + ].join('\n'); + + it('falls back to the full ECDICT definition (≤2 senses) when no WordNet hint applies', () => { + const data = buildEnEn(csv, emptyWordNet(), 100); + expect(data.meta).toEqual({ + source: 'en', + target: 'en', + metric: 'frq', + version: 1, + count: 2, + }); + expect(data.entries['run']).toEqual({ r: 312, g: 'move fast on foot' }); + // stored uncapped; the display-time cap (cleanGloss) shortens it when rendered. + expect(data.entries['cryptic']).toEqual({ r: 18000, g: 'having a hidden meaning; mysterious' }); + // exchange forms map back to the lemma; the lemma itself is not an inflection. + expect(data.inflections['ran']).toBe('run'); + expect(data.inflections['runs']).toBe('run'); + expect(data.inflections['run']).toBeUndefined(); + }); + + it('prefers a simpler synonym / category from WordNet over the definition', () => { + const csv2 = [ + header, + 'ship,/ʃ/,a large seagoing vessel,船,n,,,,100,4000,,,', + 'vessel,/v/,a craft for traveling on water,船,n,,,,100,2500,,,', + 'prejudice,/p/,an adverse judgment formed beforehand,偏见,n,,,,100,6000,,,', + 'bias,/b/,a partiality,偏向,n,,,,100,3000,,,', + ].join('\n'); + const wn = emptyWordNet(); + wn.byKey.set('noun:1', { members: ['ship'], hyper: 'noun:2' }); + wn.byKey.set('noun:2', { members: ['vessel'], hyper: null }); + wn.byKey.set('noun:3', { members: ['prejudice', 'bias'], hyper: null }); + wn.primary.noun.set('ship', '1'); + wn.primary.noun.set('prejudice', '3'); + const data = buildEnEn(csv2, wn, 100); + expect(data.entries['ship']!.g).toBe('vessel'); // no simpler synonym → hypernym + expect(data.entries['prejudice']!.g).toBe('bias'); // simpler synonym wins + }); + + it('skips a row whose definition is empty', () => { + const data = buildEnEn([header, 'word,/x/,,无,n,1,,,1,10,,,'].join('\n'), emptyWordNet(), 100); + expect(data.entries['word']).toBeUndefined(); + expect(data.meta.count).toBe(0); + }); + + it('drops an inflected-form entry when its lemma is present (lemmatize)', () => { + const csv2 = [ + header, + 'keep,/kiːp/,hold on to,保持,v,5,,,50,120,p:kept/i:keeping/3:keeps,,', + 'kept,/kept/,past tense of keep,过去式,v,,,,800,2500,,,', + ].join('\n'); + const data = buildEnEn(csv2, emptyWordNet(), 100); + expect(data.entries['kept']).toBeUndefined(); + expect(data.entries['keep']).toEqual({ r: 120, g: 'hold on to' }); + expect(data.inflections['kept']).toBe('keep'); + expect(data.meta.count).toBe(1); + }); + + it('lemmatizes a transparent derivation to its base when the definition names it (thickly ⇐ thick)', () => { + const csv2 = [ + header, + 'thick,/θ/,of great width,厚的,adj,,,,100,1000,,,', + 'thickly,/θ/,in a thick manner,浓密地,adv,,,,500,15000,,,', + ].join('\n'); + const data = buildEnEn(csv2, emptyWordNet(), 100); + // "thickly" resolves to "thick" (so the difficulty check uses thick's rank). + expect(data.entries['thickly']).toBeUndefined(); + expect(data.inflections['thickly']).toBe('thick'); + expect(data.entries['thick']).toEqual({ r: 1000, g: 'of great width' }); + }); + + it('lemmatizes a negative -able derivation via translation overlap (insufferable ⇐ suffer)', () => { + // The English def never names "suffer", so def-mention alone can't validate it; + // the Chinese translations overlap (忍受) → transparent derivation. + const csv2 = [ + header, + 'suffer,/s/,undergo pain,"遭受, 经历, 忍受",v,,,,100,1099,d:suffered/p:suffered/i:suffering/3:suffers,,', + 'insufferable,/i/,used of persons or their behavior,"不可忍受的, 忍耐不住的",adj,,,,500,24043,,,', + ].join('\n'); + const data = buildEnEn(csv2, emptyWordNet(), 100); + expect(data.entries['insufferable']).toBeUndefined(); + expect(data.inflections['insufferable']).toBe('suffer'); + expect(data.entries['suffer']).toBeDefined(); + }); + + it('keeps an -able word whose stem is a coincidence (capable ⇏ cap)', () => { + // morphologically capable→cap, but the translations share nothing → not a derivation. + const csv2 = [ + header, + 'cap,/k/,a head covering,"帽子, 盖",n,,,,100,2000,,,', + 'capable,/k/,having the ability,"有能力的, 能干的",adj,,,,500,9000,,,', + ].join('\n'); + const data = buildEnEn(csv2, emptyWordNet(), 100); + expect(data.inflections['capable']).toBeUndefined(); + expect(data.entries['capable']).toBeDefined(); + }); + + it('keeps a drifted derivation (hardly⇐hard) and a false base match (ally⇐ale)', () => { + const csv2 = [ + header, + 'hard,/h/,not easy,坚硬的,adj,,,,100,500,,,', + 'hardly,/h/,almost not,几乎不,adv,,,,500,16000,,,', + 'ale,/eɪl/,a kind of beer,啤酒,n,,,,100,3000,,,', + 'ally,/ə/,a friendly nation,盟友,n,,,,500,8000,,,', + ].join('\n'); + const data = buildEnEn(csv2, emptyWordNet(), 100); + // definition doesn't name the base → not a transparent derivation, kept as-is. + expect(data.inflections['hardly']).toBeUndefined(); + expect(data.entries['hardly']).toBeDefined(); + expect(data.inflections['ally']).toBeUndefined(); + expect(data.entries['ally']).toBeDefined(); + }); +}); + +describe('WordNet hybrid (parse + resolve)', () => { + it('parseWordNetData: members + first hypernym pointer, keyed pos:offset', () => { + const line = '01382086 00 s 02 huge 0 immense 0 002 @ 01382033 a 0000 & 0 a 0000 | great'; + const byKey = parseWordNetData(line, 'adj'); + expect(byKey.get('adj:01382086')).toEqual({ + members: ['huge', 'immense'], + hyper: 'adj:01382033', + }); + }); + + it('parseWordNetIndex: lemma → primary (first) synset offset', () => { + const idx = ['huge a 2 1 & 2 0 01382086 09573200', 'enormous a 1 1 & 1 0 00109510'].join('\n'); + const m = parseWordNetIndex(idx); + expect(m.get('huge')).toBe('01382086'); // first/primary offset, secondary dropped + expect(m.get('enormous')).toBe('00109510'); + }); + + it('resolveEnEnGloss: synonym → hypernym → definition (POS-priority)', () => { + const byKey = new Map([ + ['noun:1', { members: ['ship'], hyper: 'noun:2' }], + ['noun:2', { members: ['vessel'], hyper: null }], + ['noun:3', { members: ['prejudice', 'bias'], hyper: null }], + ['noun:4', { members: ['thing'], hyper: 'noun:5' }], + ['noun:5', { members: ['entity'], hyper: null }], // generic hypernym + ]); + const primary: WnPrimary = { + noun: new Map([ + ['ship', '1'], + ['prejudice', '3'], + ['thing', '4'], + ]), + verb: new Map(), + adj: new Map(), + adv: new Map(), + }; + const frqMap = new Map([ + ['ship', 4000], + ['vessel', 2500], + ['prejudice', 6000], + ['bias', 3000], + ['thing', 50], + ]); + const ctx = { byKey, primary, frqMap }; + expect(resolveEnEnGloss('prejudice', 'an adverse judgment', ctx)).toBe('bias'); // simpler synonym + expect(resolveEnEnGloss('ship', 'a large vessel', ctx)).toBe('vessel'); // hypernym (no synonym) + expect(resolveEnEnGloss('thing', 'a generic item', ctx)).toBe('a generic item'); // generic hyper → def + expect(resolveEnEnGloss('zzz', 'made up word', ctx)).toBe('made up word'); // no WordNet entry → def + }); +}); + +describe('finalizeInflections', () => { + it('resolves an inflection chain to the terminal lemma (paintings→painting→paint)', () => { + const entries: Record = { + paint: { r: 500, g: 'x' }, + painting: { r: 800, g: 'y' }, + paintings: { r: 1200, g: 'z' }, + }; + const inflections: Record = { painting: 'paint', paintings: 'painting' }; + finalizeInflections(entries, inflections); + expect(inflections['paintings']).toBe('paint'); // chain resolved to terminal + expect(inflections['painting']).toBe('paint'); + expect(entries['painting']).toBeUndefined(); // both dropped (paint is more common) + expect(entries['paintings']).toBeUndefined(); + expect(entries['paint']).toBeDefined(); + }); + + it('prunes a wrong collision where the form outranks its lemma (number⇏numb)', () => { + const entries: Record = { + number: { r: 300, g: 'a count' }, + numb: { r: 9000, g: 'without feeling' }, + numbers: { r: 1500, g: 'counts' }, + }; + const inflections: Record = { number: 'numb', numbers: 'number' }; + finalizeInflections(entries, inflections); + expect(inflections['number']).toBeUndefined(); // wrong link pruned + expect(entries['number']).toBeDefined(); // the common noun is kept as an entry + expect(inflections['numbers']).toBe('number'); // valid inflection of the kept entry + expect(entries['numbers']).toBeUndefined(); + }); + + it('drops a mapping whose terminal lemma is not an entry (no dangling)', () => { + const entries: Record = { run: { r: 100, g: 'go fast' } }; + const inflections: Record = { jogging: 'jog' }; // jog is not an entry + finalizeInflections(entries, inflections); + expect(inflections['jogging']).toBeUndefined(); + }); + + it('breaks an inflection cycle instead of self-looping (axes↔axis)', () => { + const entries: Record = { + axis: { r: 4000, g: 'a line' }, + axes: { r: 5000, g: 'plural' }, + }; + const inflections: Record = { axes: 'axis', axis: 'axes' }; // mutual + finalizeInflections(entries, inflections); + // No self-loop, no dangling: every surviving inflection points at a present entry. + for (const [form, lemma] of Object.entries(inflections)) { + expect(form).not.toBe(lemma); + expect(entries[lemma]).toBeDefined(); + } + }); }); describe('buildEnZh', () => { @@ -142,6 +461,18 @@ describe('buildEnZh', () => { expect(data.meta.count).toBe(1); }); + it('lemmatizes a derivation via the English definition column even though the gloss is Chinese', () => { + const csv2 = [ + header, + 'thick,/θ/,of great width,厚的,adj,,,,100,1000,,,', + 'thickly,/θ/,in a thick manner,浓密地,adv,,,,500,15000,,,', + ].join('\n'); + const data = buildEnZh(csv2, 100); + expect(data.entries['thickly']).toBeUndefined(); + expect(data.inflections['thickly']).toBe('thick'); + expect(data.entries['thick']).toEqual({ r: 1000, g: '厚的' }); // gloss still from translation + }); + it('resolves an ambiguous inflected form to the most frequent lemma (first-wins)', () => { // Both "do" (common) and "doe" (rare) claim the form "does"; the more frequent // lemma must win so "does" -> "do", not "doe". @@ -410,6 +741,20 @@ describe('buildPack', () => { expect(data.entries['perros']).toBeUndefined(); expect(data.inflections['perros']).toBe('perro'); }); + + it('lemmatizes an English DERIVATION in an en-X pack via the reused en-zh table (thickly⇐thick)', () => { + // en-X (e.g. en-de) builds pass the en-zh inflection table as lemmaMap, which now + // carries derivations (thickly→thick) — so the rule holds for every en-source pair. + const freqList: FreqEntry[] = [ + { word: 'thick', rank: 1 }, + { word: 'thickly', rank: 2 }, + ]; + const glossMap = new Map([['thick', ['breit']]]); // en→de gloss + const lemmaMap = new Map([['thickly', 'thick']]); // from en-zh.json + const data = buildPack({ freqList, glossMap, meta, topN: 30000, skipTop: 0, lemmaMap }); + expect(data.entries['thickly']).toBeUndefined(); + expect(data.inflections['thickly']).toBe('thick'); + }); }); describe('extractWikDict', () => { diff --git a/apps/readest-app/src/__tests__/scripts/preview-wordlens.test.ts b/apps/readest-app/src/__tests__/scripts/preview-wordlens.test.ts new file mode 100644 index 000000000..99f284b69 --- /dev/null +++ b/apps/readest-app/src/__tests__/scripts/preview-wordlens.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { sampleEntries as sampleEntriesUntyped } from '../../../scripts/preview-wordlens.mjs'; + +type Entry = { r: number; g: string }; +const sampleEntries = sampleEntriesUntyped as ( + entries: Record, + count?: number, +) => [string, Entry][]; + +describe('sampleEntries', () => { + it('returns all entries rank-sorted (ascending) when count >= size', () => { + const e = { b: { r: 20, g: 'B' }, a: { r: 10, g: 'A' } }; + expect(sampleEntries(e, 5).map(([w]) => w)).toEqual(['a', 'b']); + }); + + it('spreads samples across the rank range — commonest first, rarest last', () => { + const e: Record = {}; + for (let i = 1; i <= 100; i++) e[`w${i}`] = { r: i, g: `g${i}` }; + const s = sampleEntries(e, 5); + expect(s.length).toBe(5); + expect(s[0]![1].r).toBe(1); // most common + expect(s[4]![1].r).toBe(100); // rarest + const ranks = s.map(([, v]) => v.r); + expect([...ranks].sort((a, b) => a - b)).toEqual(ranks); // non-decreasing + }); +}); diff --git a/apps/readest-app/src/__tests__/scripts/wordlens-pack-data.test.ts b/apps/readest-app/src/__tests__/scripts/wordlens-pack-data.test.ts new file mode 100644 index 000000000..cd1b5b633 --- /dev/null +++ b/apps/readest-app/src/__tests__/scripts/wordlens-pack-data.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { GlossIndexData } from '@/services/wordlens/types'; + +// Validates the lemmatization in the COMMITTED Word Lens pack data (not just the +// build functions). Every English-source pack runs through build-time lemmatization +// — buildEnPack for en-en/en-zh, buildPack reusing en-en's table for en-X — so the +// shipped JSON must satisfy the inflection invariants below. Regenerate with +// `node scripts/build-wordlens-data.mjs ...` if these fail after a data refresh. + +const DATA_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../../data/wordlens'); +const load = (pair: string): GlossIndexData => + JSON.parse(readFileSync(resolve(DATA_DIR, `${pair}.json`), 'utf8')) as GlossIndexData; + +const EN_SOURCE_PAIRS = ['en-en', 'en-zh', 'en-de', 'en-es', 'en-fr', 'en-pt', 'en-ru'] as const; + +describe('Word Lens pack data — lemmatization invariants', () => { + for (const pair of EN_SOURCE_PAIRS) { + describe(pair, () => { + const { entries, inflections } = load(pair); + + it('resolves every inflection to a present lemma entry (no dangling pointer)', () => { + const dangling = Object.entries(inflections).filter(([, lemma]) => !entries[lemma]); + expect(dangling).toEqual([]); + }); + + it('never keeps an inflected form as a standalone entry (it resolves to its lemma)', () => { + const leaked = Object.keys(inflections).filter((form) => entries[form]); + expect(leaked).toEqual([]); + }); + + it('has no inflection chains (every lemma is terminal)', () => { + const chained = Object.keys(inflections).filter((form) => inflections[inflections[form]!]); + expect(chained).toEqual([]); + }); + + it('lemmatizes the transparent derivation thickly → thick (gated by lemma rank)', () => { + expect(inflections['thickly']).toBe('thick'); + expect(entries['thickly']).toBeUndefined(); + expect(entries['thick']).toBeDefined(); + }); + }); + } + + it('keeps the common noun "number" (not dropped as the comparative of "numb")', () => { + for (const pair of ['en-en', 'en-zh'] as const) { + const { entries, inflections } = load(pair); + expect(entries['number']).toBeDefined(); + expect(inflections['number']).toBeUndefined(); // not treated as an inflection + expect(inflections['numbers']).toBe('number'); // its plural resolves to it + } + }); + + it('lemmatizes the negative -able derivation insufferable → suffer (translation overlap)', () => { + // Its English def never names "suffer" — validated by the shared Chinese 忍受. + for (const pair of ['en-en', 'en-zh'] as const) { + const { entries, inflections } = load(pair); + expect(inflections['insufferable']).toBe('suffer'); + expect(entries['insufferable']).toBeUndefined(); + expect(entries['suffer']).toBeDefined(); + } + }); +}); diff --git a/apps/readest-app/src/__tests__/services/app-service.test.ts b/apps/readest-app/src/__tests__/services/app-service.test.ts index ae8c799d0..79f0c4a48 100644 --- a/apps/readest-app/src/__tests__/services/app-service.test.ts +++ b/apps/readest-app/src/__tests__/services/app-service.test.ts @@ -67,9 +67,20 @@ vi.mock('@/utils/misc', async (importOriginal) => { }; }); +// Keep the real isStoragePermissionError; only stub the interactive request. +vi.mock('@/utils/permission', async (importOriginal) => { + const original = await importOriginal>(); + return { + ...original, + requestStoragePermission: vi.fn(), + }; +}); + import { BaseAppService } from '@/services/appService'; import * as Settings from '@/services/settingsService'; import * as BookSvc from '@/services/bookService'; +import * as LibrarySvc from '@/services/libraryService'; +import { requestStoragePermission } from '@/utils/permission'; // Concrete test implementation of BaseAppService class TestAppService extends BaseAppService { @@ -193,6 +204,70 @@ describe('BaseAppService', () => { }); }); + describe('saveLibraryBooks storage permission (READEST-A)', () => { + // clearAllMocks resets call history but not implementations, so restore the + // shared saveLibraryBooks mock's default after these override it. + afterEach(() => { + vi.mocked(LibrarySvc.saveLibraryBooks).mockReset().mockResolvedValue(undefined); + vi.mocked(requestStoragePermission).mockReset(); + }); + + const permError = () => + new Error( + 'Failed to save library.json: failed to open file at path: ' + + '/storage/emulated/0/Readest/Books/library.json.bak with error: ' + + 'Permission denied (os error 13)', + ); + + test('on Android, requests storage permission and retries once when granted', async () => { + service.isAndroidApp = true; + vi.mocked(LibrarySvc.saveLibraryBooks) + .mockRejectedValueOnce(permError()) + .mockResolvedValueOnce(undefined); + vi.mocked(requestStoragePermission).mockResolvedValue(true); + + await expect(service.saveLibraryBooks([])).resolves.toBeUndefined(); + expect(requestStoragePermission).toHaveBeenCalledTimes(1); + expect(LibrarySvc.saveLibraryBooks).toHaveBeenCalledTimes(2); + }); + + test('on Android, does not crash when permission is denied', async () => { + service.isAndroidApp = true; + vi.mocked(LibrarySvc.saveLibraryBooks).mockRejectedValue(permError()); + vi.mocked(requestStoragePermission).mockResolvedValue(false); + + await expect(service.saveLibraryBooks([])).resolves.toBeUndefined(); + // No retry when the permission was declined. + expect(LibrarySvc.saveLibraryBooks).toHaveBeenCalledTimes(1); + }); + + test('only prompts once per session across repeated failing saves', async () => { + service.isAndroidApp = true; + vi.mocked(LibrarySvc.saveLibraryBooks).mockRejectedValue(permError()); + vi.mocked(requestStoragePermission).mockResolvedValue(false); + + await service.saveLibraryBooks([]); + await service.saveLibraryBooks([]); + expect(requestStoragePermission).toHaveBeenCalledTimes(1); + }); + + test('re-throws non-permission errors', async () => { + service.isAndroidApp = true; + vi.mocked(LibrarySvc.saveLibraryBooks).mockRejectedValue(new Error('disk full')); + + await expect(service.saveLibraryBooks([])).rejects.toThrow('disk full'); + expect(requestStoragePermission).not.toHaveBeenCalled(); + }); + + test('does not intercept on non-Android platforms', async () => { + service.isAndroidApp = false; + vi.mocked(LibrarySvc.saveLibraryBooks).mockRejectedValue(permError()); + + await expect(service.saveLibraryBooks([])).rejects.toThrow('Permission denied'); + expect(requestStoragePermission).not.toHaveBeenCalled(); + }); + }); + describe('file operations', () => { test('openFile delegates to fs', async () => { await service.openFile('test.epub', 'Books'); @@ -269,6 +344,22 @@ describe('BaseAppService', () => { const result = await service.resolveFilePath('', 'Data'); expect(result).toBe('/base/books'); }); + + // base 'None' carries a complete, absolute source path (in-place / + // external books point `book.filePath` outside Books//). Its prefix + // is empty, so the path must be returned verbatim. Prepending a separator + // produced `/C:\Users\...` on Windows (and `//Users/...` on POSIX), which + // the native upload guard (transfer_file.rs `ensure_path_allowed`) rejects + // as "path not in filesystem scope" — issue #4720. + test('returns an absolute path unchanged when the prefix is empty (base None)', async () => { + vi.mocked(mockFs.getPrefix).mockResolvedValue(''); + + const windowsPath = "C:\\Users\\me\\Documents\\books\\Alice's Adventures.epub"; + expect(await service.resolveFilePath(windowsPath, 'None')).toBe(windowsPath); + + const posixPath = '/Users/me/Documents/books/Alice.epub'; + expect(await service.resolveFilePath(posixPath, 'None')).toBe(posixPath); + }); }); describe('settings', () => { diff --git a/apps/readest-app/src/__tests__/services/backup-settings.test.ts b/apps/readest-app/src/__tests__/services/backup-settings.test.ts index 19a581b34..c8095779b 100644 --- a/apps/readest-app/src/__tests__/services/backup-settings.test.ts +++ b/apps/readest-app/src/__tests__/services/backup-settings.test.ts @@ -59,8 +59,24 @@ function makeSettings(overrides: Partial = {}): SystemSettings { checksumMethod: 'binary', strategy: 'prompt', }, + webdav: { + enabled: true, + serverUrl: 'https://dav.example', + username: 'wuser', + password: 'wpass', + rootPath: '/', + deviceId: 'webdav-device-id', + lastSyncedAt: 666, + }, readwise: { enabled: true, accessToken: 'rw-token', lastSyncedAt: 999 }, hardcover: { enabled: false, accessToken: 'hc-token', lastSyncedAt: 888 }, + googleDrive: { + enabled: true, + accountLabel: 'me@gmail.com', + strategy: 'silent', + deviceId: 'gdrive-device-id', + lastSyncedAt: 777, + }, aiSettings: { enabled: true, provider: 'ollama', @@ -94,6 +110,14 @@ describe('sanitizeSettingsForBackup - blacklist', () => { const out = rec(sanitizeSettingsForBackup(makeSettings())); expect(out['replicaDeviceId']).toBeUndefined(); expect(rec(out['kosync'])['deviceId']).toBeUndefined(); + expect(rec(out['googleDrive'])['deviceId']).toBeUndefined(); + // Non-identity Drive settings still travel with the backup. + expect(rec(out['googleDrive'])['enabled']).toBe(true); + // WebDAV device identity and cursor stay on the device; restoring + // them onto a second device would duplicate WebDAV sync identity. + expect(rec(out['webdav'])['deviceId']).toBeUndefined(); + expect(rec(out['webdav'])['lastSyncedAt']).toBeUndefined(); + expect(rec(out['webdav'])['serverUrl']).toBe('https://dav.example'); }); it('strips sync cursors', () => { @@ -104,6 +128,7 @@ describe('sanitizeSettingsForBackup - blacklist', () => { expect(out['lastSyncedAtReplicas']).toBeUndefined(); expect(rec(out['readwise'])['lastSyncedAt']).toBeUndefined(); expect(rec(out['hardcover'])['lastSyncedAt']).toBeUndefined(); + expect(rec(out['googleDrive'])['lastSyncedAt']).toBeUndefined(); }); it('strips transient runtime state', () => { diff --git a/apps/readest-app/src/__tests__/services/backup-windows-paths.test.ts b/apps/readest-app/src/__tests__/services/backup-windows-paths.test.ts new file mode 100644 index 000000000..3482f7543 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/backup-windows-paths.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import type { ZipWriter } from '@zip.js/zip.js'; +import { addBackupEntriesToZip } from '@/services/backupService'; +import type { AppService, FileItem } from '@/types/system'; + +/** + * Regression test for issue #4703: a backup zip exported on Windows failed to + * restore on every platform. `readDirectory` returns paths using the host + * separator, so on Windows `file.path` is `hash\cover.png` (backslash). When + * that backslash leaked into the zip entry name, restore's + * `filename.startsWith(`${hash}/`)` filter (forward slash) never matched and + * every book file was silently skipped. Entry names must always use `/`. + */ + +const BOOK_HASH = '6afdd0136531fbe028e0503a14ba234c'; + +/** Build a stub AppService whose `readDirectory` returns the given file list. */ +function makeAppService(files: FileItem[]): AppService { + return { + loadLibraryBooks: async () => [], + loadSettings: async () => ({}) as never, + resolveFilePath: async () => 'C:/Users/me/AppData/Books', + readDirectory: async () => files, + readFile: async () => new ArrayBuffer(8), + } as unknown as AppService; +} + +/** A ZipWriter stub that records the entry name passed to each `add` call. */ +function makeCapturingWriter() { + const names: string[] = []; + const writer = { + add: async (name: string) => { + names.push(name); + }, + } as unknown as ZipWriter; + return { writer, names }; +} + +describe('addBackupEntriesToZip - cross-platform entry names (#4703)', () => { + it('normalizes Windows backslash paths to forward slashes', async () => { + const windowsFiles: FileItem[] = [ + { path: `${BOOK_HASH}\\book.epub`, size: 1000 }, + { path: `${BOOK_HASH}\\cover.png`, size: 200 }, + { path: `${BOOK_HASH}\\config.json`, size: 50 }, + ]; + const { writer, names } = makeCapturingWriter(); + + await addBackupEntriesToZip(writer, makeAppService(windowsFiles), {}); + + // No entry name may contain a backslash. + expect(names.some((n) => n.includes('\\'))).toBe(false); + + // Restore filters book files by `${hash}/`; the Windows export must match. + const bookEntries = names.filter((n) => n.startsWith(`${BOOK_HASH}/`)); + expect(bookEntries).toEqual([ + `${BOOK_HASH}/book.epub`, + `${BOOK_HASH}/cover.png`, + `${BOOK_HASH}/config.json`, + ]); + }); + + it('leaves POSIX forward-slash paths unchanged', async () => { + const posixFiles: FileItem[] = [ + { path: `${BOOK_HASH}/book.epub`, size: 1000 }, + { path: `${BOOK_HASH}/cover.png`, size: 200 }, + ]; + const { writer, names } = makeCapturingWriter(); + + await addBackupEntriesToZip(writer, makeAppService(posixFiles), {}); + + const bookEntries = names.filter((n) => n.startsWith(`${BOOK_HASH}/`)); + expect(bookEntries).toEqual([`${BOOK_HASH}/book.epub`, `${BOOK_HASH}/cover.png`]); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/cloud-service.test.ts b/apps/readest-app/src/__tests__/services/cloud-service.test.ts index 10909f7be..6556cc6ce 100644 --- a/apps/readest-app/src/__tests__/services/cloud-service.test.ts +++ b/apps/readest-app/src/__tests__/services/cloud-service.test.ts @@ -216,7 +216,7 @@ describe('cloudService', () => { expect(book.uploadedAt).toBe(1000); }); - test('removes the in-place source file and the sidecar dir for in-place books', async () => { + test('wipes the sidecar dir but NEVER the in-place source file', async () => { const book = createMockBook({ filePath: '/Users/me/Library/sample.epub' }); vi.mocked(mockFs.exists).mockImplementation(async (path, base) => { if (base === 'None' && path === book.filePath) return true; @@ -226,9 +226,11 @@ describe('cloudService', () => { await deleteBook(mockFs, book, 'purge'); - // External source file (outside Books//) is removed... - expect(mockFs.removeFile).toHaveBeenCalledWith('/Users/me/Library/sample.epub', 'None'); - // ...and the metadata sidecar directory is wiped too. + // The user's original "read in place" file lives outside Books// + // and must be left untouched — deleting a book from Readest never + // removes the user's source file. + expect(mockFs.removeFile).not.toHaveBeenCalledWith('/Users/me/Library/sample.epub', 'None'); + // ...but the app-generated metadata sidecar directory is still wiped. expect(mockFs.removeDir).toHaveBeenCalledWith(book.hash, 'Books', true); }); @@ -287,12 +289,14 @@ describe('cloudService', () => { }); }); - // In-place imports keep their content at a user-controlled location - // (book.filePath, base 'None') rather than under Books//. For - // 'local'/'both' deletes that source file IS the local copy and gets - // removed (symmetric with deleting Books//.epub for a - // normal book). The cloud upload path is shared, so cross-device sync - // can still pull the book back. + // In-place imports ("Read books in place") keep their content at a + // user-controlled location (book.filePath, base 'None') OUTSIDE Readest's + // Books/<hash>/ dir — Readest never copied it. Deleting such a book from + // Readest must NEVER remove that source file; only the app-generated + // sidecars (cover.png, config.json, ...) under Books/<hash>/ are ours to + // delete. The cloud upload path is shared, so cross-device sync can still + // pull the book back. (Regression: in-place delete used to wipe the + // user's originals.) describe('in-place (book.filePath set)', () => { const mockInPlaceExists = (book: Book, coverExists = true) => { vi.mocked(mockFs.exists).mockImplementation(async (path, base) => { @@ -302,26 +306,24 @@ describe('cloudService', () => { }); }; - test('local action removes the user-controlled source file', async () => { + test('local action does NOT remove the user-controlled source file', async () => { const book = createMockBook({ filePath: '/Users/me/Library/sample.epub' }); mockInPlaceExists(book); await deleteBook(mockFs, book, 'local'); - // The source file is read from base 'None' (absolute path), not Books/. - expect(mockFs.removeFile).toHaveBeenCalledWith('/Users/me/Library/sample.epub', 'None'); + // The external source (base 'None', absolute path) is the user's own + // file and must survive a Readest-side delete. + expect(mockFs.removeFile).not.toHaveBeenCalledWith('/Users/me/Library/sample.epub', 'None'); }); - test('local action does not remove Books/<hash>/<title>.epub', async () => { + test('local action removes no files at all (nothing managed to delete)', async () => { const book = createMockBook({ filePath: '/Users/me/Library/sample.epub' }); mockInPlaceExists(book); await deleteBook(mockFs, book, 'local'); - // The hash-copy path lives only on a normal book; for an in-place book, - // the resolver can probe it, but deletion must target the external source. - expect(mockFs.removeFile).not.toHaveBeenCalledWith( - `${book.hash}/${book.title}.epub`, - 'Books', - ); + // There is no managed Books/<hash>/<title>.epub copy for an in-place + // book, and the external source is off-limits, so nothing is removed. + expect(mockFs.removeFile).not.toHaveBeenCalled(); }); test('local action still clears downloadedAt', async () => { @@ -335,7 +337,6 @@ describe('cloudService', () => { }); test('local action does not throw when the source file is missing', async () => { - // exists() returns false → no removeFile call, but no error either. vi.mocked(mockFs.exists).mockResolvedValue(false); const book = createMockBook({ filePath: '/Users/me/Library/sample.epub', @@ -347,21 +348,7 @@ describe('cloudService', () => { expect(book.downloadedAt).toBeNull(); }); - test('local action swallows errors from removeFile (best-effort source delete)', async () => { - vi.mocked(mockFs.removeFile).mockRejectedValueOnce(new Error('EPERM')); - const book = createMockBook({ - filePath: '/Users/me/Library/sample.epub', - downloadedAt: 12345, - }); - mockInPlaceExists(book); - - // Must not throw, and must still flip the metadata bit so the UI - // reflects the user's delete intent. - await deleteBook(mockFs, book, 'local'); - expect(book.downloadedAt).toBeNull(); - }); - - test('both action removes both the source file and the cover sidecar', async () => { + test('both action removes the cover sidecar but NEVER the source file', async () => { const book = createMockBook({ filePath: '/Users/me/Library/sample.epub', uploadedAt: null, @@ -369,11 +356,11 @@ describe('cloudService', () => { mockInPlaceExists(book); await deleteBook(mockFs, book, 'both'); - // Source file under user-controlled path: - expect(mockFs.removeFile).toHaveBeenCalledWith('/Users/me/Library/sample.epub', 'None'); - // Cover sidecar under Books/<hash>/: + // Cover sidecar under Books/<hash>/ is app-generated → removable: expect(mockFs.removeFile).toHaveBeenCalledWith(`${book.hash}/cover.png`, 'Books'); - // We must never poke at Books/<hash>/<title>.epub for an in-place book. + // The user's source file is NEVER removed: + expect(mockFs.removeFile).not.toHaveBeenCalledWith('/Users/me/Library/sample.epub', 'None'); + // And there is no managed copy to poke at for an in-place book. expect(mockFs.removeFile).not.toHaveBeenCalledWith( `${book.hash}/${book.title}.epub`, 'Books', diff --git a/apps/readest-app/src/__tests__/services/collect-known-source-paths.test.ts b/apps/readest-app/src/__tests__/services/collect-known-source-paths.test.ts new file mode 100644 index 000000000..95005c481 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/collect-known-source-paths.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'vitest'; +import { collectKnownSourcePaths } from '@/services/bookService'; +import type { Book } from '@/types/book'; + +function makeBook(overrides: Partial<Book> = {}): Book { + return { + hash: 'bookhash', + format: 'EPUB', + title: 'sample', + author: 'Author', + createdAt: 1, + updatedAt: 1, + deletedAt: null, + downloadedAt: 1, + ...overrides, + }; +} + +describe('collectKnownSourcePaths', () => { + test('includes a live in-place book path', () => { + const book = makeBook({ filePath: '/Users/me/Books/sample.epub' }); + const result = collectKnownSourcePaths([book]); + expect(result.has('/Users/me/Books/sample.epub')).toBe(true); + }); + + test('includes a soft-deleted in-place book path (key regression)', () => { + const book = makeBook({ filePath: '/Users/me/Books/deleted.epub', deletedAt: Date.now() }); + const result = collectKnownSourcePaths([book]); + expect(result.has('/Users/me/Books/deleted.epub')).toBe(true); + }); + + test('excludes a book whose filePath is a URL (http)', () => { + const book = makeBook({ filePath: 'http://example.com/book.epub' }); + const result = collectKnownSourcePaths([book]); + expect(result.size).toBe(0); + }); + + test('excludes a book whose filePath is a URL (https)', () => { + const book = makeBook({ filePath: 'https://example.com/book.epub' }); + const result = collectKnownSourcePaths([book]); + expect(result.size).toBe(0); + }); + + test('excludes a book with no filePath', () => { + const book = makeBook(); + const result = collectKnownSourcePaths([book]); + expect(result.size).toBe(0); + }); + + test('normalizes case on osPlatform macos (key is lowercased)', () => { + const book = makeBook({ filePath: '/Users/Me/Books/Sample.EPUB' }); + const result = collectKnownSourcePaths([book], 'macos'); + expect(result.has('/users/me/books/sample.epub')).toBe(true); + expect(result.has('/Users/Me/Books/Sample.EPUB')).toBe(false); + }); + + test('preserves case on osPlatform linux', () => { + const book = makeBook({ filePath: '/home/Me/Books/Sample.epub' }); + const result = collectKnownSourcePaths([book], 'linux'); + expect(result.has('/home/Me/Books/Sample.epub')).toBe(true); + expect(result.has('/home/me/books/sample.epub')).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts b/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts index 1490e6c0e..32adcc559 100644 --- a/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts +++ b/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts @@ -84,7 +84,7 @@ describe('buildCommandRegistry', () => { const panels = new Set(settingsItems.map((i) => i.panel)); expect(panels.has('Font')).toBe(true); expect(panels.has('Layout')).toBe(true); - expect(panels.has('Color')).toBe(true); + expect(panels.has('Theme')).toBe(true); expect(panels.has('Control')).toBe(true); expect(panels.has('Language')).toBe(true); expect(panels.has('Custom')).toBe(true); diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts index ca565fa33..38d881ac4 100644 --- a/apps/readest-app/src/__tests__/services/constants.test.ts +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -142,6 +142,7 @@ describe('services/constants', () => { expect(SUPPORTED_BOOK_EXTS).toContain('pdf'); expect(SUPPORTED_BOOK_EXTS).toContain('mobi'); expect(SUPPORTED_BOOK_EXTS).toContain('txt'); + expect(SUPPORTED_BOOK_EXTS).toContain('md'); }); it('BOOK_ACCEPT_FORMATS is a comma-separated string of dotted extensions', () => { @@ -229,7 +230,6 @@ describe('services/constants', () => { expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysOnTop).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.openBookInNewWindow).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysShowStatusBar).toBe('boolean'); - expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysInForeground).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.autoCheckUpdates).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.screenWakeLock).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.openLastBooks).toBe('boolean'); @@ -303,6 +303,7 @@ describe('services/constants', () => { expect(hw.bindings.pageNext).toBeNull(); expect(hw.bindings.sectionPrev).toBeNull(); expect(hw.bindings.sectionNext).toBeNull(); + expect(hw.bindings.refresh).toBeNull(); }); }); @@ -505,6 +506,7 @@ describe('services/constants', () => { expect(typeof DEFAULT_BOOK_STYLE.wordSpacing).toBe('number'); expect(typeof DEFAULT_BOOK_STYLE.letterSpacing).toBe('number'); expect(typeof DEFAULT_BOOK_STYLE.textIndent).toBe('number'); + expect(DEFAULT_BOOK_STYLE.contrast).toBe(100); }); it('has boolean style flags', () => { @@ -603,13 +605,11 @@ describe('services/constants', () => { expect(typeof DEFAULT_VIEW_CONFIG.showCurrentBatteryStatus).toBe('boolean'); expect(typeof DEFAULT_VIEW_CONFIG.showBatteryPercentage).toBe('boolean'); expect(typeof DEFAULT_VIEW_CONFIG.use24HourClock).toBe('boolean'); - expect(typeof DEFAULT_VIEW_CONFIG.tapToToggleFooter).toBe('boolean'); expect(typeof DEFAULT_VIEW_CONFIG.showPaginationButtons).toBe('boolean'); }); it('has progress style settings', () => { expect(typeof DEFAULT_VIEW_CONFIG.progressStyle).toBe('string'); - expect(typeof DEFAULT_VIEW_CONFIG.progressInfoMode).toBe('string'); }); it('has animation and eink flags', () => { @@ -648,9 +648,10 @@ describe('services/constants', () => { expect(typeof DEFAULT_TTS_CONFIG).toBe('object'); expect(typeof DEFAULT_TTS_CONFIG.ttsRate).toBe('number'); expect(DEFAULT_TTS_CONFIG.ttsRate).toBeGreaterThan(0); + expect(typeof DEFAULT_TTS_CONFIG.ttsSentenceGap).toBe('number'); + expect(DEFAULT_TTS_CONFIG.ttsSentenceGap).toBeGreaterThan(0); expect(typeof DEFAULT_TTS_CONFIG.ttsVoice).toBe('string'); expect(typeof DEFAULT_TTS_CONFIG.ttsLocation).toBe('string'); - expect(typeof DEFAULT_TTS_CONFIG.showTTSBar).toBe('boolean'); expect(typeof DEFAULT_TTS_CONFIG.ttsMediaMetadata).toBe('string'); }); @@ -1109,6 +1110,10 @@ describe('services/constants', () => { expect(TRANSLATOR_LANGS['fi']).toBeDefined(); }); + it('TRANSLATOR_LANGS includes Urdu', () => { + expect(TRANSLATOR_LANGS['ur']).toBe('اردو'); + }); + it('SUPPORTED_LANGS includes zh in addition to TRANSLATED_LANGS entries', () => { expect(typeof SUPPORTED_LANGS).toBe('object'); expect(SUPPORTED_LANGS['zh']).toBeDefined(); diff --git a/apps/readest-app/src/__tests__/services/cover-sync.test.ts b/apps/readest-app/src/__tests__/services/cover-sync.test.ts new file mode 100644 index 000000000..7fc2f8e5f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/cover-sync.test.ts @@ -0,0 +1,129 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { Book, BookFormat } from '@/types/book'; +import { FileSystem } from '@/types/system'; + +// uploadBookCover uses the storage layer; mock it so we can assert the cloud +// path it uploads to without touching the network. computeCoverHash uses the +// REAL @/utils/book + @/utils/md5 (content hashing must be exercised for real). +vi.mock('@/libs/storage', () => ({ + downloadFile: vi.fn().mockResolvedValue(undefined), + uploadFile: vi.fn().mockResolvedValue(undefined), + uploadReplicaFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn(), + createProgressHandler: vi.fn().mockReturnValue(vi.fn()), + batchGetDownloadUrls: vi.fn().mockResolvedValue([]), +})); + +import { uploadBookCover } from '@/services/cloudService'; +import { computeCoverHash } from '@/services/bookService'; +import { uploadFile } from '@/libs/storage'; + +function createMockBook(overrides: Partial<Book> = {}): Book { + return { + hash: 'abc123', + format: 'EPUB' as BookFormat, + title: 'Test Book', + author: 'Author', + createdAt: 1, + updatedAt: 2, + deletedAt: null, + uploadedAt: 1000, + downloadedAt: 1000, + coverDownloadedAt: 1000, + ...overrides, + }; +} + +function createMockFs(overrides: Partial<FileSystem> = {}): FileSystem { + return { + resolvePath: vi + .fn() + .mockReturnValue({ baseDir: 0, basePrefix: async () => '', fp: 't', base: 'Books' }), + getURL: vi.fn().mockReturnValue('url'), + getBlobURL: vi.fn().mockResolvedValue('blob:url'), + getImageURL: vi.fn().mockResolvedValue('image:url'), + openFile: vi.fn().mockResolvedValue(new File(['cover-bytes'], 'cover.png')), + copyFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue('content'), + writeFile: vi.fn().mockResolvedValue(undefined), + removeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([]), + createDir: vi.fn().mockResolvedValue(undefined), + removeDir: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue(true), + stats: vi.fn().mockResolvedValue({ + isFile: true, + isDirectory: false, + size: 100, + mtime: null, + atime: null, + birthtime: null, + }), + getPrefix: vi.fn().mockResolvedValue('Readest/Books'), + ...overrides, + } as FileSystem; +} + +describe('computeCoverHash (issue #4544)', () => { + test('returns null when the cover does not exist', async () => { + const fs = createMockFs({ exists: vi.fn().mockResolvedValue(false) }); + const hash = await computeCoverHash(fs, createMockBook()); + expect(hash).toBeNull(); + expect(fs.openFile).not.toHaveBeenCalled(); + }); + + test('is stable for identical cover bytes and differs for different bytes', async () => { + const fsA = createMockFs({ + openFile: vi.fn().mockResolvedValue(new File(['cover-A'], 'cover.png')), + }); + const fsA2 = createMockFs({ + openFile: vi.fn().mockResolvedValue(new File(['cover-A'], 'cover.png')), + }); + const fsB = createMockFs({ + openFile: vi.fn().mockResolvedValue(new File(['cover-B-different'], 'cover.png')), + }); + + const hashA = await computeCoverHash(fsA, createMockBook()); + const hashA2 = await computeCoverHash(fsA2, createMockBook()); + const hashB = await computeCoverHash(fsB, createMockBook()); + + expect(hashA).toBeTruthy(); + expect(hashA).toBe(hashA2); // idempotent: identical content ⇒ identical hash + expect(hashA).not.toBe(hashB); // changed content ⇒ changed hash + }); + + test('reads cover.png under the book hash dir', async () => { + const fs = createMockFs(); + await computeCoverHash(fs, createMockBook({ hash: 'deadbeef' })); + expect(fs.openFile).toHaveBeenCalledWith('deadbeef/cover.png', 'Books'); + }); +}); + +describe('uploadBookCover (issue #4544)', () => { + const resolveFilePath = vi.fn().mockResolvedValue('/abs/abc123/cover.png'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('uploads only the cover to books/<hash>/cover.png and does not touch uploadedAt', async () => { + const fs = createMockFs(); + const book = createMockBook({ uploadedAt: 1000 }); + await uploadBookCover(fs, resolveFilePath, book); + expect(uploadFile).toHaveBeenCalledTimes(1); + // The cloud key is built inside uploadFileToCloud via + // fs.openFile(lfp, base, cfp). Assert the cover key was passed through. + expect(fs.openFile).toHaveBeenCalledWith( + 'abc123/cover.png', + 'Books', + 'Readest/Books/abc123/cover.png', + ); + expect(book.uploadedAt).toBe(1000); // unchanged + }); + + test('no-ops when the cover is absent', async () => { + const fs = createMockFs({ exists: vi.fn().mockResolvedValue(false) }); + await uploadBookCover(fs, resolveFilePath, createMockBook()); + expect(uploadFile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts new file mode 100644 index 000000000..095d7ceca --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/dict-popup-font-size.browser.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +// Real-browser regression test for readest#4443 (adjustable dictionary popup +// font size). The feature hangs entirely on CSS behaviour jsdom cannot model: +// 1. Tailwind `text-*` utilities re-based to `em` so they ride a scaled +// `font-size` root on `[data-dict-content]`. +// 2. `::part(dict-content)` reaching into the MDict shadow root, with the +// `--dict-font-scale` custom property inheriting across the boundary. +// Both need a layout engine + a real Shadow DOM + Custom Highlight-free part +// styling, so this lives in the browser lane. +// +// The rules below mirror the `[data-dict-content]` block in +// `src/styles/globals.css`; keep them byte-identical to what ships. +const POPUP_FONT_RULES = ` +[data-dict-content] { + font-size: calc(var(--dict-font-scale, 1) * 1em); +} +[data-dict-content] .text-xs { font-size: 0.75em; } +[data-dict-content] .text-sm { font-size: 0.875em; } +[data-dict-content] .text-base { font-size: 1em; } +[data-dict-content] .text-lg { font-size: 1.125em; } +[data-dict-content] .dict-shadow-host::part(dict-content) { + font-size: calc(var(--dict-font-scale, 1) * 0.875rem); +} +`; + +let style: HTMLStyleElement | null = null; +let root: HTMLElement | null = null; + +const px = (el: Element): number => parseFloat(getComputedStyle(el).fontSize); + +/** + * Build the exact DOM the popup produces for a card: a `[data-dict-content]` + * content root holding a light-DOM headword + body (e.g. the DICT provider) + * and an MDict shadow host whose shadow body carries `part="dict-content"`. + */ +const mountCard = (scale: number) => { + root = document.createElement('div'); + // The popup's surrounding chrome inherits the document base; force a known + // 16px base so `rem`/`em` math is deterministic regardless of UA defaults. + root.style.fontSize = '16px'; + root.innerHTML = ` + <div data-dict-content style="--dict-font-scale: ${scale}"> + <h1 class="text-lg" data-testid="headword">word</h1> + <pre class="text-sm" data-testid="light-body">a definition</pre> + <div class="dict-shadow-host mt-2 text-sm" data-testid="host"></div> + </div>`; + document.body.appendChild(root); + + const host = root.querySelector('[data-testid="host"]') as HTMLElement; + const shadow = host.attachShadow({ mode: 'open' }); + const body = document.createElement('div'); + body.dataset['dictKind'] = 'mdict'; + body.setAttribute('part', 'dict-content'); + body.textContent = 'shadow definition'; + shadow.appendChild(body); + + return { + headword: root.querySelector('[data-testid="headword"]') as HTMLElement, + lightBody: root.querySelector('[data-testid="light-body"]') as HTMLElement, + shadowBody: body, + }; +}; + +describe('dictionary popup font size (#4443)', () => { + afterEach(() => { + root?.remove(); + root = null; + style?.remove(); + style = null; + }); + + it('renders default sizes at scale 1 and scales every region linearly', () => { + style = document.createElement('style'); + style.textContent = POPUP_FONT_RULES; + document.head.appendChild(style); + + const base = mountCard(1); + // Defaults match the pre-feature look exactly: text-lg=18, text-sm=14, + // and the MDict shadow body keeps its 0.875rem (14px) base. + expect(px(base.headword)).toBeCloseTo(18, 1); + expect(px(base.lightBody)).toBeCloseTo(14, 1); + expect(px(base.shadowBody)).toBeCloseTo(14, 1); + root!.remove(); + + const big = mountCard(1.5); + // Light-DOM utilities ride the scaled `[data-dict-content]` root... + expect(px(big.headword)).toBeCloseTo(27, 1); // 18 * 1.5 + expect(px(big.lightBody)).toBeCloseTo(21, 1); // 14 * 1.5 + // ...and `::part()` carries the same factor across the shadow boundary, + // proving --dict-font-scale inherits into the shadow tree. + expect(px(big.shadowBody)).toBeCloseTo(21, 1); // 14 * 1.5 + }); + + it('leaves the shadow body at its default when the part hook is absent', () => { + // Without the part attribute the rule cannot reach the shadow content — + // this guards against a future refactor dropping the hook silently. + style = document.createElement('style'); + style.textContent = POPUP_FONT_RULES; + document.head.appendChild(style); + + root = document.createElement('div'); + root.style.fontSize = '16px'; + root.innerHTML = `<div data-dict-content style="--dict-font-scale: 1.5"> + <div class="dict-shadow-host" data-testid="host"></div> + </div>`; + document.body.appendChild(root); + const host = root.querySelector('[data-testid="host"]') as HTMLElement; + const shadow = host.attachShadow({ mode: 'open' }); + const body = document.createElement('div'); + body.style.fontSize = '0.875rem'; // its own author style, no part exposed + body.textContent = 'unreachable'; + shadow.appendChild(body); + + expect(px(body)).toBeCloseTo(14, 1); // unscaled — the ::part rule can't bind + }); +}); diff --git a/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts index 1abce1cb1..d053392d2 100644 --- a/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts +++ b/apps/readest-app/src/__tests__/services/dictionaries/mdictProvider.test.ts @@ -846,6 +846,44 @@ describe('mdictProvider', () => { } }); + it('exposes part="dict-content" on the in-shadow body so outer CSS can size it via ::part()', async () => { + const jsmdict = await import('js-mdict'); + const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX); + type FakeMDX = ReturnType<typeof origMDXCreate> extends Promise<infer T> ? T : never; + jsmdict.MDX.create = async () => + ({ + meta: { encrypt: 0 }, + header: {}, + lookup: async (word: string) => ({ + keyText: word, + definition: `<p>def</p>`, + }), + }) as unknown as FakeMDX; + + try { + const provider = createMdictProvider({ dict: buildDict(false), fs: makeFs() }); + const container = document.createElement('div'); + await provider.lookup('hello', { + signal: new AbortController().signal, + container, + }); + + const shadow = getMdictShadow(container); + // The content root carries BOTH the data-dict-kind hook and the + // shadow `part` so the popup's `::part(dict-content)` font-size rule + // can reach across the shadow boundary (it otherwise can't). + const body = shadow.querySelector('[data-dict-kind="mdict"]'); + expect(body).not.toBeNull(); + expect(body!.getAttribute('part')).toBe('dict-content'); + // The host must be selectable from the outer tree for the `::part()` + // rule's host selector to match. + const host = container.lastElementChild as HTMLElement; + expect(host.classList.contains('dict-shadow-host')).toBe(true); + } finally { + jsmdict.MDX.create = origMDXCreate; + } + }); + it('keeps the auto-prepended headword when the dict body has a different h1 text', async () => { const jsmdict = await import('js-mdict'); const origMDXCreate = jsmdict.MDX.create.bind(jsmdict.MDX); diff --git a/apps/readest-app/src/__tests__/services/edge-tts-client-playback.test.ts b/apps/readest-app/src/__tests__/services/edge-tts-client-playback.test.ts new file mode 100644 index 000000000..175b2c870 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/edge-tts-client-playback.test.ts @@ -0,0 +1,360 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { TTSMessageEvent } from '@/services/tts/TTSClient'; +import type { TTSController } from '@/services/tts/TTSController'; +import { FakeAudioContext } from './tts-fake-audio'; + +// Mock control shared with the hoisted module mock. +type MockAudioData = { + data: ArrayBuffer; + boundaries: Array<{ offset: number; duration: number; text: string }>; +}; +let createAudioDataBehavior: (payloadText: string) => Promise<MockAudioData>; +let parsedMarks: Array<{ name: string; text: string; language: string }> = []; + +vi.mock('@/libs/edgeTTS', () => { + const voices = [{ id: 'en-US-AriaNeural', name: 'Aria', lang: 'en-US' }]; + return { + EdgeSpeechTTS: class MockEdgeSpeechTTS { + static voices = voices; + create = vi.fn().mockResolvedValue(undefined); + createAudioData = vi + .fn() + .mockImplementation((payload: { text: string }) => createAudioDataBehavior(payload.text)); + }, + EDGE_TTS_PROTOCOL: 'wss', + }; +}); + +vi.mock('@/utils/ssml', () => ({ + parseSSMLMarks: vi.fn(() => ({ marks: parsedMarks })), +})); + +vi.mock('@/utils/misc', () => ({ + getUserLocale: vi.fn((lang: string) => (lang === 'en' ? 'en-US' : lang)), +})); + +vi.mock('@/services/tts/TTSUtils', () => ({ + TTSUtils: { + getPreferredVoice: vi.fn(() => null), + sortVoicesPreferLocaleFunc: () => () => 0, + }, +})); + +const consoleSpy = { + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + log: vi.spyOn(console, 'log').mockImplementation(() => {}), +}; +void consoleSpy; + +// One second of fake audio bytes: the fake decoder maps 1 byte -> 1 sample at +// 24kHz, and all-zero samples make findSpeechBounds keep the full range. +const audioOf = (seconds: number): MockAudioData => ({ + data: new ArrayBuffer(Math.round(seconds * 24000)), + boundaries: [], +}); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +let rafCallbacks: Map<number, FrameRequestCallback>; +let rafId = 0; +const runRaf = () => { + const cbs = [...rafCallbacks.values()]; + rafCallbacks.clear(); + for (const cb of cbs) cb(0); +}; + +interface MockController { + dispatchSpeakMark: ReturnType<typeof vi.fn>; + prepareSpeakWords: ReturnType<typeof vi.fn>; + dispatchSpeakWord: ReturnType<typeof vi.fn>; +} + +type EdgeClientClass = typeof import('@/services/tts/EdgeTTSClient').EdgeTTSClient; + +describe('EdgeTTSClient Web Audio playback', () => { + let EdgeTTSClient: EdgeClientClass; + let controller: MockController; + + beforeEach(async () => { + vi.resetModules(); + FakeAudioContext.instances = []; + rafCallbacks = new Map(); + vi.stubGlobal('AudioContext', FakeAudioContext); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallbacks.set(++rafId, cb); + return rafId; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + rafCallbacks.delete(id); + }); + createAudioDataBehavior = async () => audioOf(1); + parsedMarks = [ + { name: '0', text: 'First sentence.', language: 'en' }, + { name: '1', text: 'Second sentence.', language: 'en' }, + ]; + controller = { + dispatchSpeakMark: vi.fn(), + prepareSpeakWords: vi.fn(), + dispatchSpeakWord: vi.fn(), + }; + ({ EdgeTTSClient } = await import('@/services/tts/EdgeTTSClient')); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const startClient = async () => { + const client = new EdgeTTSClient(controller as unknown as TTSController); + await client.init(); + return client; + }; + + const collectSpeak = (client: InstanceType<EdgeClientClass>, signal: AbortSignal) => { + const events: TTSMessageEvent[] = []; + const done = (async () => { + for await (const event of client.speak('<ssml/>', signal)) { + events.push(event); + } + })(); + return { events, done }; + }; + + const ctx = () => FakeAudioContext.instances[0]!; + + test('plays marks gaplessly: boundary per audible chunk, one final end', async () => { + const client = await startClient(); + const { events, done } = collectSpeak(client, new AbortController().signal); + await flush(); + await flush(); + + // Both chunks scheduled ahead, but only chunk 0 is audible: exactly one + // mark dispatched so foliate's cursor tracks the voice, not the fetcher. + expect(ctx().sources.length).toBe(2); + expect(controller.dispatchSpeakMark).toHaveBeenCalledTimes(1); + expect(events.filter((e) => e.code === 'boundary')).toHaveLength(1); + + await ctx().advanceTo(1.1); // chunk 0 ends (starts at 0.03, 1s long) + await flush(); + expect(controller.dispatchSpeakMark).toHaveBeenCalledTimes(2); + + await ctx().advanceTo(3); // chunk 1 ends + await done; + expect(events.map((e) => e.code)).toEqual(['boundary', 'boundary', 'end']); + expect(events[0]!.mark).toBe('0'); + expect(events[1]!.mark).toBe('1'); + }); + + test('chunks are scheduled with a rate-scaled gap and no element restarts', async () => { + const client = await startClient(); + await client.setRate(1); // gap = 0.15 / 1 + const { done } = collectSpeak(client, new AbortController().signal); + await flush(); + await flush(); + const [first, second] = ctx().sources; + expect(second!.startedAt! - first!.endTime).toBeCloseTo(0.15, 5); + await ctx().advanceTo(5); + await done; + }); + + test('setSentenceGap before speaking changes the observed gap', async () => { + const client = await startClient(); + await client.setRate(1); + client.setSentenceGap(0.4); // gap = 0.4 / 1 + const { done } = collectSpeak(client, new AbortController().signal); + await flush(); + await flush(); + const [first, second] = ctx().sources; + expect(second!.startedAt! - first!.endTime).toBeCloseTo(0.4, 5); + await ctx().advanceTo(5); + await done; + }); + + test('setSentenceGap mid-session affects the next scheduled gap', async () => { + parsedMarks = [ + { name: '0', text: 'First sentence.', language: 'en' }, + { name: '1', text: 'Second sentence.', language: 'en' }, + { name: '2', text: 'Third sentence.', language: 'en' }, + { name: '3', text: 'Fourth sentence.', language: 'en' }, + ]; + const client = await startClient(); + await client.setRate(1); + const { done } = collectSpeak(client, new AbortController().signal); + // Initial scheduling of chunks + await flush(); + await flush(); + let sources = ctx().sources; + const [first, second] = sources; + expect(second!.startedAt! - first!.endTime).toBeCloseTo(0.15, 5); + + // Change gap mid-session + client.setSentenceGap(0.3); + + // Advance playback to trigger chunk 0's onended and free scheduling capacity + // for chunk 2 to be scheduled. Then advance more to free chunk 1 so chunk 3 is scheduled. + await ctx().advanceTo(1.1); + await flush(); + + // Chunk 2 should now be scheduled with the old gap (from chunk 1's timing) + sources = ctx().sources; + expect(sources.length).toBeGreaterThanOrEqual(3); + + // Advance further to free chunk 2, allowing chunk 3 to be scheduled with the new gap + await ctx().advanceTo(3.4); + await flush(); + + sources = ctx().sources; + expect(sources.length).toBeGreaterThanOrEqual(4); + + // Chunk 2 is the first chunk scheduled after setSentenceGap, so the gap + // preceding chunk 3 (chunk 2's schedule-time gap) reflects the new value. + const [, , thirdChunk, fourthChunk] = sources; + expect(fourthChunk!.startedAt! - thirdChunk!.endTime).toBeCloseTo(0.3, 5); + + await ctx().advanceTo(5); + await done; + }); + + test('word tracking follows the audio clock and survives pause/resume', async () => { + createAudioDataBehavior = async () => ({ + data: new ArrayBuffer(48000), // 2s + boundaries: [ + { offset: 1_000_000, duration: 4_000_000, text: 'Hello' }, // 0.1s + { offset: 6_000_000, duration: 4_000_000, text: 'brave' }, // 0.6s + { offset: 11_000_000, duration: 4_000_000, text: 'world' }, // 1.1s + ], + }); + parsedMarks = [{ name: '0', text: 'Hello brave world', language: 'en' }]; + const client = await startClient(); + const { done } = collectSpeak(client, new AbortController().signal); + await flush(); + await flush(); + + expect(controller.prepareSpeakWords).toHaveBeenCalledWith(['Hello', 'brave', 'world']); + ctx().currentTime = 0.03 + 0.15; + runRaf(); + expect(controller.dispatchSpeakWord).toHaveBeenLastCalledWith(0); + + await client.pause(); + expect(ctx().state).toBe('suspended'); + await client.resume(); + expect(ctx().state).toBe('running'); + + ctx().currentTime = 0.03 + 0.7; + runRaf(); + expect(controller.dispatchSpeakWord).toHaveBeenLastCalledWith(1); + + // Same index is not re-dispatched. + const calls = controller.dispatchSpeakWord.mock.calls.length; + runRaf(); + expect(controller.dispatchSpeakWord.mock.calls.length).toBe(calls); + + await ctx().advanceTo(5); + await done; + }); + + test('abort mid-stream yields Aborted and stops all sources', async () => { + const client = await startClient(); + const abortController = new AbortController(); + const { events, done } = collectSpeak(client, abortController.signal); + await flush(); + await flush(); + abortController.abort(); + await done; + expect(events.at(-1)).toMatchObject({ code: 'error', message: 'Aborted' }); + expect(ctx().sources.every((s) => s.stopped)).toBe(true); + }); + + test('a no-audio mark is skipped and the session continues', async () => { + createAudioDataBehavior = async (text: string) => { + if (text === 'First sentence.') throw new Error('No audio data received.'); + return audioOf(1); + }; + const client = await startClient(); + const { events, done } = collectSpeak(client, new AbortController().signal); + await flush(); + await flush(); + await ctx().advanceTo(5); + await done; + const codes = events.map((e) => e.code); + expect(codes.filter((c) => c === 'boundary')).toHaveLength(1); + expect(codes.at(-1)).toBe('end'); + }); + + test('a decode failure is treated like no-audio: warn, skip, continue', async () => { + // The context exists once the scheduler's first fetch runs (ensureContext + // precedes it), so the first fetch installs a decoder that fails exactly + // once — the first mark's decode dies, the second succeeds. + let installed = false; + createAudioDataBehavior = async () => { + if (!installed) { + installed = true; + const context = FakeAudioContext.instances[0]!; + const original = context.decodeImpl; + let failed = false; + context.decodeImpl = async (data) => { + if (!failed) { + failed = true; + throw new Error('bad mp3'); + } + return original(data); + }; + } + return audioOf(1); + }; + const client = await startClient(); + const { events, done } = collectSpeak(client, new AbortController().signal); + await flush(); + await flush(); + await ctx().advanceTo(10); + await done; + const codes = events.map((e) => e.code); + expect(codes.filter((c) => c === 'boundary')).toHaveLength(1); // only mark 1 played + expect(codes.at(-1)).toBe('end'); + }); + + test('all marks failing still ends the session with end (no wedge)', async () => { + createAudioDataBehavior = async () => { + throw new Error('No audio data received.'); + }; + const client = await startClient(); + const { events, done } = collectSpeak(client, new AbortController().signal); + await done; // zero chunks scheduled; session-end fires synchronously + const codes = events.map((e) => e.code); + expect(codes.at(-1)).toBe('end'); + expect(codes).not.toContain('boundary'); + }); + + test('a hard fetch error yields error and terminates', async () => { + createAudioDataBehavior = async () => { + throw new Error('network exploded'); + }; + const client = await startClient(); + const { events, done } = collectSpeak(client, new AbortController().signal); + await done; + expect(events.at(-1)).toMatchObject({ code: 'error', message: 'network exploded' }); + }, 10000); + + test('pause without a session is a no-op returning true', async () => { + const client = await startClient(); + expect(await client.pause()).toBe(true); + expect(await client.resume()).toBe(true); + }); + + test('getChunkPosition reports trim-relative clamped seconds', async () => { + const client = await startClient(); + parsedMarks = [{ name: '0', text: 'Only sentence.', language: 'en' }]; + const { done } = collectSpeak(client, new AbortController().signal); + await flush(); + await flush(); + ctx().currentTime = 0.03 + 0.4; + const pos = client.getChunkPosition(); + expect(pos).not.toBeNull(); + expect(pos!).toBeGreaterThan(0.3); + expect(pos!).toBeLessThanOrEqual(1); + await ctx().advanceTo(5); + await done; + expect(client.getChunkPosition()).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts index 6a86f5592..ce567a940 100644 --- a/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts +++ b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts @@ -3,14 +3,13 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; // Shared mock control: tests can override createBehavior to change how create() behaves let createBehavior: () => Promise<undefined> = () => Promise.resolve(undefined); -// Shared mock control for createAudioUrl() and parsed SSML marks -let createAudioUrlBehavior = vi.fn<() => Promise<string>>(() => Promise.resolve('blob:mock-url')); -type MockAudioResult = { - url: string; +// Shared mock control for createAudioData() and parsed SSML marks +type MockAudioData = { + data: ArrayBuffer; boundaries: Array<{ offset: number; duration: number; text: string }>; }; -let createAudioBehavior = vi.fn<() => Promise<MockAudioResult>>(() => - Promise.resolve({ url: 'blob:mock-url', boundaries: [] }), +let createAudioDataBehavior = vi.fn<() => Promise<MockAudioData>>(() => + Promise.resolve({ data: new ArrayBuffer(8), boundaries: [] }), ); let parsedMarks: Array<{ name: string; text: string; language: string }> = []; @@ -27,8 +26,7 @@ vi.mock('@/libs/edgeTTS', () => { EdgeSpeechTTS: class MockEdgeSpeechTTS { static voices = voices; create = vi.fn().mockImplementation(() => createBehavior()); - createAudioUrl = vi.fn().mockImplementation(() => createAudioUrlBehavior()); - createAudio = vi.fn().mockImplementation(() => createAudioBehavior()); + createAudioData = vi.fn().mockImplementation(() => createAudioDataBehavior()); }, EDGE_TTS_PROTOCOL: 'wss', }; @@ -70,9 +68,8 @@ describe('EdgeTTSClient', () => { beforeEach(() => { createBehavior = () => Promise.resolve(undefined); - createAudioUrlBehavior = vi.fn<() => Promise<string>>(() => Promise.resolve('blob:mock-url')); - createAudioBehavior = vi.fn<() => Promise<MockAudioResult>>(() => - Promise.resolve({ url: 'blob:mock-url', boundaries: [] }), + createAudioDataBehavior = vi.fn<() => Promise<MockAudioData>>(() => + Promise.resolve({ data: new ArrayBuffer(8), boundaries: [] }), ); parsedMarks = []; client = new EdgeTTSClient(); @@ -431,14 +428,14 @@ describe('EdgeTTSClient', () => { } }; - test('retries createAudioUrl up to 3 times when preload fails', async () => { + test('retries createAudioData up to 3 times when preload fails', async () => { await client.init(); parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }]; - createAudioUrlBehavior = vi.fn(() => Promise.reject(new Error('network error'))); + createAudioDataBehavior = vi.fn(() => Promise.reject(new Error('network error'))); await consumePreload(client, new AbortController().signal); - expect(createAudioUrlBehavior).toHaveBeenCalledTimes(3); + expect(createAudioDataBehavior).toHaveBeenCalledTimes(3); }); test('does not retry when the first preload attempt succeeds', async () => { @@ -447,37 +444,47 @@ describe('EdgeTTSClient', () => { await consumePreload(client, new AbortController().signal); - expect(createAudioUrlBehavior).toHaveBeenCalledTimes(1); + expect(createAudioDataBehavior).toHaveBeenCalledTimes(1); }); test('stops retrying once an attempt succeeds', async () => { await client.init(); parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }]; let calls = 0; - createAudioUrlBehavior = vi.fn(() => { + createAudioDataBehavior = vi.fn(() => { calls++; return calls < 2 ? Promise.reject(new Error('network error')) - : Promise.resolve('blob:mock-url'); + : Promise.resolve({ data: new ArrayBuffer(8), boundaries: [] }); }); await consumePreload(client, new AbortController().signal); - expect(createAudioUrlBehavior).toHaveBeenCalledTimes(2); + expect(createAudioDataBehavior).toHaveBeenCalledTimes(2); + }); + + test('does not retry a permanent no-audio failure', async () => { + await client.init(); + parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }]; + createAudioDataBehavior = vi.fn(() => Promise.reject(new Error('No audio data received.'))); + + await consumePreload(client, new AbortController().signal); + + expect(createAudioDataBehavior).toHaveBeenCalledTimes(1); }); test('stops retrying once the signal is aborted', async () => { await client.init(); parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }]; const controller = new AbortController(); - createAudioUrlBehavior = vi.fn(() => { + createAudioDataBehavior = vi.fn(() => { controller.abort(); return Promise.reject(new Error('network error')); }); await consumePreload(client, controller.signal); - expect(createAudioUrlBehavior).toHaveBeenCalledTimes(1); + expect(createAudioDataBehavior).toHaveBeenCalledTimes(1); }); }); @@ -496,137 +503,4 @@ describe('EdgeTTSClient', () => { await expect(client.stop()).resolves.toBeUndefined(); }); }); - - describe('word boundary tracking during playback', () => { - class MockAudio { - static instances: MockAudio[] = []; - src = ''; - currentTime = 0; - preload = ''; - playbackRate = 1; - onended: ((e?: Event) => void) | null = null; - onerror: ((e?: unknown) => void) | null = null; - constructor() { - MockAudio.instances.push(this); - } - setAttribute() {} - play() { - return Promise.resolve(); - } - pause() {} - } - - let rafCallbacks: Map<number, FrameRequestCallback>; - let rafId = 0; - const runRaf = () => { - const cbs = [...rafCallbacks.values()]; - rafCallbacks.clear(); - for (const cb of cbs) cb(0); - }; - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - - let mockController: { - dispatchSpeakMark: ReturnType<typeof vi.fn>; - prepareSpeakWords: ReturnType<typeof vi.fn>; - dispatchSpeakWord: ReturnType<typeof vi.fn>; - }; - - beforeEach(() => { - MockAudio.instances = []; - rafCallbacks = new Map(); - vi.stubGlobal('Audio', MockAudio); - vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { - rafCallbacks.set(++rafId, cb); - return rafId; - }); - vi.stubGlobal('cancelAnimationFrame', (id: number) => { - rafCallbacks.delete(id); - }); - mockController = { - dispatchSpeakMark: vi.fn(), - prepareSpeakWords: vi.fn(), - dispatchSpeakWord: vi.fn(), - }; - client = new EdgeTTSClient(mockController as unknown as TTSController); - parsedMarks = [{ name: '0', text: 'Hello brave world', language: 'en' }]; - createAudioBehavior = vi.fn(() => - Promise.resolve({ - url: 'blob:mock-url', - boundaries: [ - { offset: 1_000_000, duration: 4_000_000, text: 'Hello' }, - { offset: 6_000_000, duration: 4_000_000, text: 'brave' }, - { offset: 11_000_000, duration: 4_000_000, text: 'world' }, - ], - }), - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - const startSpeak = async () => { - await client.init(); - const it = client.speak('<ssml/>', new AbortController().signal); - const first = await it.next(); - expect((first.value as { code: string }).code).toBe('boundary'); - const resultPromise = it.next(); - await flush(); - const audio = MockAudio.instances.at(-1)!; - return { it, resultPromise, audio }; - }; - - test('prepares speak words and dispatches word indexes as playback advances', async () => { - const { resultPromise, audio } = await startSpeak(); - - expect(mockController.prepareSpeakWords).toHaveBeenCalledWith(['Hello', 'brave', 'world']); - - audio.currentTime = 0.11; - runRaf(); - expect(mockController.dispatchSpeakWord).toHaveBeenCalledWith(0); - - audio.currentTime = 0.65; - runRaf(); - expect(mockController.dispatchSpeakWord).toHaveBeenLastCalledWith(1); - - // Same word index is not re-dispatched on subsequent frames. - const callCount = mockController.dispatchSpeakWord.mock.calls.length; - runRaf(); - expect(mockController.dispatchSpeakWord.mock.calls.length).toBe(callCount); - - audio.onended?.(); - const result = await resultPromise; - expect((result.value as { code: string }).code).toBe('end'); - }); - - test('stops dispatching after the chunk ends', async () => { - const { resultPromise, audio } = await startSpeak(); - - audio.currentTime = 0.11; - runRaf(); - const callCount = mockController.dispatchSpeakWord.mock.calls.length; - - audio.onended?.(); - await resultPromise; - - audio.currentTime = 1.2; - runRaf(); - expect(mockController.dispatchSpeakWord.mock.calls.length).toBe(callCount); - }); - - test('hands empty words to the controller and does not track when no boundaries', async () => { - createAudioBehavior = vi.fn(() => Promise.resolve({ url: 'blob:mock-url', boundaries: [] })); - const { resultPromise, audio } = await startSpeak(); - - // Empty words are still forwarded so the controller can draw the - // sentence-highlight fallback; no per-word tracking is started. - expect(mockController.prepareSpeakWords).toHaveBeenCalledWith([]); - audio.currentTime = 0.5; - runRaf(); - expect(mockController.dispatchSpeakWord).not.toHaveBeenCalled(); - - audio.onended?.(); - await resultPromise; - }); - }); }); diff --git a/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts b/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts index 2e9eb8f8f..fe3032b09 100644 --- a/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts +++ b/apps/readest-app/src/__tests__/services/hardcover/HardcoverClient.test.ts @@ -13,7 +13,7 @@ type MockFetchResponse = { type MockFetch = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<MockFetchResponse>>>; type TestBookContext = { - editionId: number; + editionId: number | null; pages: number | null; bookId: number; bookPages: number | null; @@ -712,4 +712,90 @@ describe('HardcoverClient', () => { userBook: { id: 303 }, }); }); + + test('leaves the edition id unresolved when title search lacks a featured edition and the user has none selected', async () => { + const book = { title: 'Crime and Punishment', author: 'Fyodor Dostoevsky' } as Book; + + // authenticate + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: { me: { id: 1 } } }), + }); + // QUERY_SEARCH_BOOK — real Typesense hit shape, no featured_edition_id present + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: { search: { results: { hits: [{ document: { id: 713309, pages: 311 } }] } } }, + }), + }); + // QUERY_GET_BOOK_USER_DATA — currently-reading book, but no specific edition selected + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: { + editions: [ + { + book: { + id: 713309, + pages: 311, + user_books: [ + { + id: 303, + status_id: 2, + edition: null, + user_book_reads: [{ id: 505, started_at: '2026-06-25', edition: null }], + }, + ], + }, + }, + ], + }, + }), + }); + + const context = await clientApi.fetchBookContext(book); + + expect(context?.bookId).toBe(713309); + // Regression #4792: the edition id must NOT fall back to the book id. Sending + // a book id as edition_id makes Hardcover's Action reject the read mutation + // with a parse-failed error. Leave it null when no real edition is known. + expect(context?.editionId).toBeNull(); + }); + + test('forwards a null edition id to the read mutation when no edition is resolved (#4792)', async () => { + const book = { + createdAt: 1711737600000, + title: 'Crime and Punishment', + author: 'Test', + } as Book; + const config = { progress: [3, 311] } as BookConfig; + + vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({ + editionId: null, + pages: 311, + bookId: 713309, + bookPages: 311, + userBook: { + id: 303, + status_id: 2, + user_book_reads: [{ id: 505, started_at: '2026-06-25' }], + }, + }); + const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({}); + + await client.pushProgress(book, config); + + const requestCalls = requestSpy.mock.calls as RequestSpyCall[]; + const updateReadCall = requestCalls.find((call) => + String(call[0]).includes('mutation UpdateRead'), + ); + expect(updateReadCall).toBeDefined(); + const variables = updateReadCall?.[1] as { edition_id?: unknown }; + expect(variables.edition_id).toBeNull(); + }); }); diff --git a/apps/readest-app/src/__tests__/services/import-metahash.test.ts b/apps/readest-app/src/__tests__/services/import-metahash.test.ts index 673567a5c..102e41d4e 100644 --- a/apps/readest-app/src/__tests__/services/import-metahash.test.ts +++ b/apps/readest-app/src/__tests__/services/import-metahash.test.ts @@ -158,6 +158,31 @@ describe('importBook metaHash deduplication', () => { expect(existingBook.metaHash).toBe(metaHash); }); + // Cross-device file-update convergence (issue #4544 §E): re-importing an + // edited file re-keys the hash and clears uploadedAt so the new bytes get + // re-uploaded; the old entry is soft-deleted. Peers then pull the deleted + // old-hash row (remove old) + the uploaded new-hash row (download new). + it('clears uploadedAt on a metaHash re-import so the new file re-uploads', async () => { + const metaHash = getMetadataHash(TEST_METADATA); + const existingBook = makeBook({ + hash: 'old-hash-123', + metaHash, + uploadedAt: Date.now() - 5000, + }); + const books: Book[] = [existingBook]; + + mockPartialMD5.mockResolvedValue('new-hash-456'); + setupMockBookDoc(); + + const mockFile = new File(['new content'], 'test.epub', { type: 'application/epub+zip' }); + const result = await service.importBook(mockFile, books); + + expect(result).toBe(existingBook); + expect(existingBook.hash).toBe('new-hash-456'); + // uploadedAt cleared → autoUpload / manual upload re-pushes the new file. + expect(existingBook.uploadedAt).toBeNull(); + }); + it('should not match metaHash for deleted books', async () => { const metaHash = getMetadataHash(TEST_METADATA); diff --git a/apps/readest-app/src/__tests__/services/native-app-service-updater.test.ts b/apps/readest-app/src/__tests__/services/native-app-service-updater.test.ts new file mode 100644 index 000000000..ddf732921 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/native-app-service-updater.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +// Controls what the mocked `is_updater_disabled` Tauri command returns. +let updaterDisabled = false; + +const osTypeMock = vi.fn().mockReturnValue('linux'); +const invokeMock = vi.fn((cmd: string) => { + if (cmd === 'get_executable_dir') return Promise.resolve('/exec'); + if (cmd === 'is_updater_disabled') return Promise.resolve(updaterDisabled); + return Promise.resolve(undefined); +}); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => osTypeMock(), +})); + +vi.mock('@tauri-apps/plugin-fs', () => ({ + exists: vi.fn().mockResolvedValue(false), + mkdir: vi.fn().mockResolvedValue(undefined), + readTextFile: vi.fn().mockResolvedValue(''), + readFile: vi.fn().mockResolvedValue(new Uint8Array()), + writeTextFile: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ size: 0 }), + BaseDirectory: {}, +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => invokeMock(...(args as [string])), + convertFileSrc: (p: string) => `asset://${p}`, +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(null), + ask: vi.fn().mockResolvedValue(true), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + join: (...parts: string[]) => Promise.resolve(parts.join('/')), + basename: (p: string) => Promise.resolve(p.split('/').pop() ?? p), + appDataDir: () => Promise.resolve('/tmp/app-data'), + appConfigDir: () => Promise.resolve('/tmp/app-config'), + appCacheDir: () => Promise.resolve('/tmp/app-cache'), + appLogDir: () => Promise.resolve('/tmp/app-log'), + tempDir: () => Promise.resolve('/tmp'), +})); + +vi.mock('@choochmeque/tauri-plugin-sharekit-api', () => ({ + shareFile: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/utils/bridge', () => ({ + copyURIToPath: vi.fn().mockResolvedValue({ path: '' }), + getStorefrontRegionCode: vi.fn().mockResolvedValue({ regionCode: null }), + saveImageToGallery: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/utils/file', () => ({ + NativeFile: class {}, + RemoteFile: class {}, +})); + +vi.mock('@/utils/files', () => ({ + copyFiles: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/settingsService', () => ({ + getDefaultViewSettings: vi.fn().mockReturnValue({}), + loadSettings: vi.fn().mockResolvedValue({ migrationVersion: 99999999 }), + saveSettings: vi.fn().mockResolvedValue(undefined), +})); + +async function initServiceWithOS(os: 'macos' | 'windows' | 'linux' | 'ios' | 'android') { + osTypeMock.mockReturnValue(os); + vi.resetModules(); + const mod = await import('@/services/nativeAppService'); + const service = new mod.NativeAppService(); + await service.init(); + return service; +} + +describe('NativeAppService updater gating (issue #4874)', () => { + beforeEach(() => { + invokeMock.mockClear(); + updaterDisabled = false; + delete (window as { __READEST_UPDATER_DISABLED?: boolean }).__READEST_UPDATER_DISABLED; + }); + + test('disables the in-app updater when Rust reports it is disabled', async () => { + updaterDisabled = true; + const service = await initServiceWithOS('linux'); + expect(invokeMock).toHaveBeenCalledWith('is_updater_disabled'); + expect(service.hasUpdater).toBe(false); + }); + + test('keeps the in-app updater when Rust reports it is enabled', async () => { + updaterDisabled = false; + const service = await initServiceWithOS('linux'); + expect(service.hasUpdater).toBe(true); + }); + + test('honors the Rust decision on macOS (env opt-out)', async () => { + updaterDisabled = true; + const service = await initServiceWithOS('macos'); + expect(service.hasUpdater).toBe(false); + }); + + test('does not query or enable the updater on mobile', async () => { + updaterDisabled = false; + const service = await initServiceWithOS('ios'); + expect(invokeMock).not.toHaveBeenCalledWith('is_updater_disabled'); + expect(service.hasUpdater).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/native-app-service-window.test.ts b/apps/readest-app/src/__tests__/services/native-app-service-window.test.ts new file mode 100644 index 000000000..226bf60d6 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/native-app-service-window.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect, vi } from 'vitest'; + +const osTypeMock = vi.fn().mockReturnValue('macos'); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => osTypeMock(), +})); + +vi.mock('@tauri-apps/plugin-fs', () => ({ + exists: vi.fn().mockResolvedValue(false), + mkdir: vi.fn().mockResolvedValue(undefined), + readTextFile: vi.fn().mockResolvedValue(''), + readFile: vi.fn().mockResolvedValue(new Uint8Array()), + writeTextFile: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ size: 0 }), + BaseDirectory: {}, +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn().mockResolvedValue(undefined), + convertFileSrc: (p: string) => `asset://${p}`, +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(null), + ask: vi.fn().mockResolvedValue(true), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + join: (...parts: string[]) => Promise.resolve(parts.join('/')), + basename: (p: string) => Promise.resolve(p.split('/').pop() ?? p), + appDataDir: () => Promise.resolve('/tmp/app-data'), + appConfigDir: () => Promise.resolve('/tmp/app-config'), + appCacheDir: () => Promise.resolve('/tmp/app-cache'), + appLogDir: () => Promise.resolve('/tmp/app-log'), + tempDir: () => Promise.resolve('/tmp'), +})); + +vi.mock('@/utils/bridge', () => ({ + copyURIToPath: vi.fn().mockResolvedValue({ path: '' }), + getStorefrontRegionCode: vi.fn().mockResolvedValue({ regionCode: null }), +})); + +vi.mock('@/utils/file', () => ({ + NativeFile: class {}, + RemoteFile: class {}, +})); + +vi.mock('@/utils/files', () => ({ + copyFiles: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/settingsService', () => ({ + getDefaultViewSettings: vi.fn().mockReturnValue({}), + loadSettings: vi.fn().mockResolvedValue({ migrationVersion: 99999999 }), + saveSettings: vi.fn().mockResolvedValue(undefined), +})); + +async function loadServiceWithOS(os: 'macos' | 'windows' | 'linux' | 'ios' | 'android') { + osTypeMock.mockReturnValue(os); + vi.resetModules(); + const mod = await import('@/services/nativeAppService'); + return new mod.NativeAppService(); +} + +// Regression (#3682): the Linux window used to be created fully transparent to +// draw rounded corners (#1982). On WebKitGTK a transparent window whose web +// process is busy (e.g. during a library backup) fails to repaint damaged +// regions on interaction, so the whole window composites as transparent — the +// app "turns invisible". The window is now opaque, which means it can no longer +// present a rounded, transparent frame, so `hasRoundedWindow` must be false on +// every desktop platform. +describe('NativeAppService rounded-window capability', () => { + test('Linux does not use a rounded (transparent) window', async () => { + const service = await loadServiceWithOS('linux'); + expect(service.isLinuxApp).toBe(true); + expect(service.hasRoundedWindow).toBe(false); + }); + + test('macOS does not use a rounded (transparent) window', async () => { + const service = await loadServiceWithOS('macos'); + expect(service.hasRoundedWindow).toBe(false); + }); + + test('Windows does not use a rounded (transparent) window', async () => { + const service = await loadServiceWithOS('windows'); + expect(service.hasRoundedWindow).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/native-tts-client.test.ts b/apps/readest-app/src/__tests__/services/native-tts-client.test.ts new file mode 100644 index 000000000..c4f4412ae --- /dev/null +++ b/apps/readest-app/src/__tests__/services/native-tts-client.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), + addPluginListener: vi.fn().mockResolvedValue({ unregister: vi.fn() }), +})); + +// Avoid pulling in the heavy TTSController module graph (foliate-js, etc.) — the +// native client only references it as a type. +vi.mock('@/services/tts/TTSController', () => ({ TTSController: class {} })); + +import { NativeTTSClient } from '@/services/tts/NativeTTSClient'; +import { invoke } from '@tauri-apps/api/core'; + +describe('NativeTTSClient.stop', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('resolves promptly when the native stop resolves', async () => { + vi.mocked(invoke).mockResolvedValue(undefined); + const client = new NativeTTSClient(); + + await client.stop(); + + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|stop'); + }); + + test('still resolves (bounded) when the native stop never resolves', async () => { + // Regression for #4676: a hung native stop must not hang teardown + // (controller.stop / shutdown), which would leave the TTS icon stuck. + vi.mocked(invoke).mockReturnValue(new Promise(() => {})); + const client = new NativeTTSClient(); + + let settled = false; + const p = client.stop().then(() => { + settled = true; + }); + + // Pending until the timeout fires. + await Promise.resolve(); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1500); + await p; + expect(settled).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts b/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts index 355724e45..cac56e3f7 100644 --- a/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts +++ b/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts @@ -138,6 +138,35 @@ describe('OPDS auto-download orchestrator', () => { }); }); + it('downloads with skipSslVerification like the manual download path', async () => { + // The manual OPDS download (page.tsx handleDownload) passes + // skipSslVerification as a workaround for self-signed/private-CA OPDS + // servers (#2871): the native download_file validates TLS with rustls, + // which ignores the OS trust store, while the feed fetch and auth probe + // go through the http plugin with acceptInvalidCerts. Without the same + // flag here, auto-download dies in the TLS handshake on servers where + // manual download works (#4988). + const catalogs: OPDSCatalog[] = [ + { id: 'cat-1', name: 'Shelf', url: 'https://shelf.example.com/opds', autoDownload: true }, + ]; + vi.mocked(checkFeedForNewItems).mockResolvedValue([ + { + entryId: 'urn:shelf:1', + title: 'Issue 1', + acquisitionHref: '/dl/1.epub', + mimeType: 'application/epub+zip', + baseURL: 'https://shelf.example.com/opds', + }, + ]); + + await syncSubscribedCatalogs(catalogs, appService, []); + + expect(downloadFile).toHaveBeenCalledTimes(1); + expect(vi.mocked(downloadFile).mock.calls[0]![0]).toMatchObject({ + skipSslVerification: true, + }); + }); + it('handles import failure by adding to failedEntries', async () => { const catalogs: OPDSCatalog[] = [ { id: 'cat-1', name: 'Test', url: 'https://example.com/opds', autoDownload: true }, diff --git a/apps/readest-app/src/__tests__/services/opds-feed-crawl.test.ts b/apps/readest-app/src/__tests__/services/opds-feed-crawl.test.ts new file mode 100644 index 000000000..e5056e16e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/opds-feed-crawl.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { OPDSCatalog, OPDSFeed } from '@/types/opds'; +import type { OPDSSubscriptionState } from '@/services/opds/types'; +import { MAX_CRAWL_DEPTH, MAX_FEEDS_PER_CRAWL } from '@/services/opds/types'; +import { checkFeedForNewItems, getSubsectionURLs } from '@/services/opds/feedChecker'; +import { fetchWithAuth } from '@/app/opds/utils/opdsReq'; + +vi.mock('@/services/environment', () => ({ + isWebAppPlatform: vi.fn(() => false), + isTauriAppPlatform: vi.fn(() => true), + getAPIBaseUrl: () => '/api', + getNodeAPIBaseUrl: () => '/node-api', +})); + +vi.mock('@tauri-apps/plugin-http', () => ({ + fetch: vi.fn(), +})); + +vi.mock('@/app/opds/utils/opdsReq', () => ({ + fetchWithAuth: vi.fn(), + needsProxy: vi.fn(() => false), + getProxiedURL: vi.fn((url: string) => url), +})); + +// --- Copyparty-style feed fixtures ----------------------------------------- +// Reproduces https://github.com/readest/readest/issues/4272: copyparty +// exposes each directory as an OPDS feed where subdirectories are +// rel="subsection" navigation entries and files are acquisition entries. +// There is no "by newest" feed and no pagination. + +const dirEntry = (name: string, href: string) => ` + <entry> + <id>urn:uuid:dir-${name}</id> + <title>${name}/ + + 2026-03-28T05:37:03Z + `; + +const bookEntry = (id: string, title: string, href: string) => ` + + ${id} + ${title} + 2025-11-02T17:50:21Z + + `; + +const feedXML = ( + title: string, + body: string, + extraLinks = '', +) => ` + + urn:uuid:feed-${title} + ${title} + ${extraLinks} + ${body} +`; + +const BASE = 'https://files.example.com/books/Kids/?opds'; +const RAMONA_URL = 'https://files.example.com/books/Kids/Ramona/?opds'; + +// URL → XML served by the mocked fetchWithAuth; set per test. +let feeds: Record; + +const makeCatalog = (): OPDSCatalog => ({ id: 'cat-1', name: 'Kids', url: BASE }); + +const emptyState = (): OPDSSubscriptionState => ({ + catalogId: 'cat-1', + lastCheckedAt: 0, + knownEntryIds: [], + failedEntries: [], +}); + +beforeEach(() => { + vi.clearAllMocks(); + feeds = {}; + vi.mocked(fetchWithAuth).mockImplementation(async (url: string) => { + const xml = feeds[url]; + if (!xml) { + return { + ok: false, + url, + status: 404, + statusText: 'Not Found', + text: async () => '', + } as unknown as Response; + } + return { + ok: true, + url, + status: 200, + statusText: 'OK', + text: async () => xml, + } as unknown as Response; + }); +}); + +describe('checkFeedForNewItems directory crawl (#4272)', () => { + it('collects books from subdirectories of a directory-style catalog', async () => { + feeds[BASE] = feedXML( + 'Kids', + dirEntry('Ramona', '/books/Kids/Ramona/?opds') + + bookEntry('urn:uuid:rosie', 'Rosie Revere.epub', '/books/Kids/Rosie%20Revere.epub?dl'), + ); + feeds[RAMONA_URL] = feedXML( + 'Ramona', + bookEntry( + 'urn:uuid:ramona-1', + 'Complete Ramona Collection.epub', + '/books/Kids/Ramona/Complete%20Ramona%20Collection.epub?dl', + ), + ); + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + + const ids = items.map((i) => i.entryId); + expect(ids).toContain('urn:uuid:rosie'); + expect(ids).toContain('urn:uuid:ramona-1'); + // The subdirectory book must resolve its href against the sub-feed URL. + const ramonaItem = items.find((i) => i.entryId === 'urn:uuid:ramona-1')!; + expect(ramonaItem.baseURL).toBe(RAMONA_URL); + }); + + it('crawls a folder that contains only subfolders (no top-level books)', async () => { + feeds[BASE] = feedXML( + 'Kids', + dirEntry('Ramona', '/books/Kids/Ramona/?opds') + dirEntry('Beaty', '/books/Kids/Beaty/?opds'), + ); + feeds[RAMONA_URL] = feedXML( + 'Ramona', + bookEntry('urn:uuid:ramona-1', 'Ramona.epub', '/books/Kids/Ramona/Ramona.epub?dl'), + ); + feeds['https://files.example.com/books/Kids/Beaty/?opds'] = feedXML( + 'Beaty', + bookEntry('urn:uuid:beaty-1', 'Beaty.epub', '/books/Kids/Beaty/Beaty.epub?dl'), + ); + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + expect(items.map((i) => i.entryId).sort()).toEqual(['urn:uuid:beaty-1', 'urn:uuid:ramona-1']); + }); + + it('collects nested subdirectories (depth 2)', async () => { + const SUB2 = 'https://files.example.com/books/Kids/Ramona/Extras/?opds'; + feeds[BASE] = feedXML('Kids', dirEntry('Ramona', '/books/Kids/Ramona/?opds')); + feeds[RAMONA_URL] = feedXML( + 'Ramona', + dirEntry('Extras', '/books/Kids/Ramona/Extras/?opds') + + bookEntry('urn:uuid:ramona-1', 'Ramona.epub', '/books/Kids/Ramona/Ramona.epub?dl'), + ); + feeds[SUB2] = feedXML( + 'Extras', + bookEntry('urn:uuid:extra-1', 'Extra.epub', '/books/Kids/Ramona/Extras/Extra.epub?dl'), + ); + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + expect(items.map((i) => i.entryId).sort()).toEqual(['urn:uuid:extra-1', 'urn:uuid:ramona-1']); + }); + + it('does not crawl navigation when the catalog has a "by newest" feed', async () => { + const NEWEST_URL = 'https://library.example.com/opds/new'; + const BY_AUTHOR_URL = 'https://library.example.com/opds/author'; + const ROOT = 'https://library.example.com/opds'; + + feeds[ROOT] = feedXML( + 'Library', + dirEntry('author', '/opds/author'), + '', + ); + feeds[NEWEST_URL] = feedXML( + 'Newest', + bookEntry('urn:uuid:new-1', 'New Book.epub', '/dl/new-1.epub'), + ); + feeds[BY_AUTHOR_URL] = feedXML( + 'By Author', + bookEntry('urn:uuid:author-1', 'Author Book.epub', '/dl/author-1.epub'), + ); + + const catalog: OPDSCatalog = { id: 'cat-1', name: 'Library', url: ROOT }; + const items = await checkFeedForNewItems(catalog, emptyState()); + + expect(items.map((i) => i.entryId)).toEqual(['urn:uuid:new-1']); + const fetchedURLs = vi.mocked(fetchWithAuth).mock.calls.map((c) => c[0]); + expect(fetchedURLs).not.toContain(BY_AUTHOR_URL); + }); + + it('falls back to root publications without crawling when the newest feed is broken', async () => { + const ROOT = 'https://library.example.com/opds'; + const BY_AUTHOR_URL = 'https://library.example.com/opds/author'; + + // sort/new link points at a 404; root itself has publications + navigation. + feeds[ROOT] = feedXML( + 'Library', + dirEntry('author', '/opds/author') + + bookEntry('urn:uuid:root-1', 'Root Book.epub', '/dl/root-1.epub'), + '', + ); + feeds[BY_AUTHOR_URL] = feedXML( + 'By Author', + bookEntry('urn:uuid:author-1', 'Author Book.epub', '/dl/author-1.epub'), + ); + + const catalog: OPDSCatalog = { id: 'cat-1', name: 'Library', url: ROOT }; + const items = await checkFeedForNewItems(catalog, emptyState()); + + expect(items.map((i) => i.entryId)).toEqual(['urn:uuid:root-1']); + const fetchedURLs = vi.mocked(fetchWithAuth).mock.calls.map((c) => c[0]); + expect(fetchedURLs).not.toContain(BY_AUTHOR_URL); + }); + + it('stops descending at MAX_CRAWL_DEPTH', async () => { + // Chain: root -> d1 -> d2 -> ... each level has one book and one subdir. + const dirURL = (i: number) => `https://files.example.com/books/Kids/${'d/'.repeat(i)}?opds`; + const chainLen = MAX_CRAWL_DEPTH + 2; + feeds[BASE] = feedXML('Kids', dirEntry('d1', dirURL(1))); + for (let i = 1; i <= chainLen; i++) { + feeds[dirURL(i)] = feedXML( + `d${i}`, + bookEntry(`urn:uuid:book-${i}`, `Book ${i}.epub`, `/dl/book-${i}.epub`) + + dirEntry(`d${i + 1}`, dirURL(i + 1)), + ); + } + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + const ids = items.map((i) => i.entryId); + expect(ids).toContain(`urn:uuid:book-${MAX_CRAWL_DEPTH}`); + expect(ids).not.toContain(`urn:uuid:book-${MAX_CRAWL_DEPTH + 1}`); + }); + + it('stops fetching after MAX_FEEDS_PER_CRAWL feeds', async () => { + const subCount = MAX_FEEDS_PER_CRAWL + 10; + let rootBody = ''; + for (let i = 0; i < subCount; i++) { + const url = `https://files.example.com/books/Kids/sub${i}/?opds`; + rootBody += dirEntry(`sub${i}`, url); + feeds[url] = feedXML( + `sub${i}`, + bookEntry(`urn:uuid:sub-${i}`, `Sub ${i}.epub`, `/dl/sub-${i}.epub`), + ); + } + feeds[BASE] = feedXML('Kids', rootBody); + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + expect(vi.mocked(fetchWithAuth).mock.calls.length).toBeLessThanOrEqual(MAX_FEEDS_PER_CRAWL); + // Root fetch consumes one slot from the budget. + expect(items).toHaveLength(MAX_FEEDS_PER_CRAWL - 1); + }); + + it('does not loop on subdirectories that link back to the root', async () => { + feeds[BASE] = feedXML( + 'Kids', + dirEntry('Ramona', '/books/Kids/Ramona/?opds') + + bookEntry('urn:uuid:rosie', 'Rosie.epub', '/dl/rosie.epub'), + ); + feeds[RAMONA_URL] = feedXML( + 'Ramona', + dirEntry('Kids', '/books/Kids/?opds') + + bookEntry('urn:uuid:ramona-1', 'Ramona.epub', '/dl/ramona.epub'), + ); + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + expect(items.map((i) => i.entryId).sort()).toEqual(['urn:uuid:ramona-1', 'urn:uuid:rosie']); + // Root fetched exactly once. + const rootFetches = vi.mocked(fetchWithAuth).mock.calls.filter((c) => c[0] === BASE); + expect(rootFetches).toHaveLength(1); + }); + + it('returns a subdirectory book only once when listed in two feeds', async () => { + feeds[BASE] = feedXML( + 'Kids', + dirEntry('Ramona', '/books/Kids/Ramona/?opds') + + bookEntry('urn:uuid:dup', 'Dup.epub', '/dl/dup.epub'), + ); + feeds[RAMONA_URL] = feedXML('Ramona', bookEntry('urn:uuid:dup', 'Dup.epub', '/dl/dup.epub')); + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + expect(items.filter((i) => i.entryId === 'urn:uuid:dup')).toHaveLength(1); + }); + + it('skips already-known entries found in subdirectories', async () => { + feeds[BASE] = feedXML('Kids', dirEntry('Ramona', '/books/Kids/Ramona/?opds')); + feeds[RAMONA_URL] = feedXML( + 'Ramona', + bookEntry('urn:uuid:known', 'Known.epub', '/dl/known.epub') + + bookEntry('urn:uuid:fresh', 'Fresh.epub', '/dl/fresh.epub'), + ); + + const state = { ...emptyState(), knownEntryIds: ['urn:uuid:known'] }; + const items = await checkFeedForNewItems(makeCatalog(), state); + expect(items.map((i) => i.entryId)).toEqual(['urn:uuid:fresh']); + }); + + it('follows rel=next pagination inside crawled subdirectories', async () => { + const PAGE2 = 'https://files.example.com/books/Kids/Ramona/?opds&page=2'; + feeds[BASE] = feedXML('Kids', dirEntry('Ramona', '/books/Kids/Ramona/?opds')); + feeds[RAMONA_URL] = feedXML( + 'Ramona', + bookEntry('urn:uuid:p1', 'Page1.epub', '/dl/p1.epub'), + ``, + ); + feeds[PAGE2] = feedXML('Ramona p2', bookEntry('urn:uuid:p2', 'Page2.epub', '/dl/p2.epub')); + + const items = await checkFeedForNewItems(makeCatalog(), emptyState()); + expect(items.map((i) => i.entryId).sort()).toEqual(['urn:uuid:p1', 'urn:uuid:p2']); + }); +}); + +describe('getSubsectionURLs', () => { + const baseURL = 'https://files.example.com/books/Kids/?opds'; + + it('returns resolved URLs for subsection navigation entries', () => { + const feed: OPDSFeed = { + metadata: { title: 'Kids' }, + links: [], + navigation: [ + { + title: 'Ramona/', + href: '/books/Kids/Ramona/?opds', + rel: 'subsection', + type: 'application/atom+xml;profile=opds-catalog', + properties: {}, + }, + ], + }; + expect(getSubsectionURLs(feed, baseURL)).toEqual([RAMONA_URL]); + }); + + it('accepts navigation entries without a type', () => { + const feed: OPDSFeed = { + metadata: { title: 'Kids' }, + links: [], + navigation: [{ title: 'Sub', href: 'Sub/?opds', properties: {} }], + }; + expect(getSubsectionURLs(feed, baseURL)).toEqual([ + 'https://files.example.com/books/Kids/Sub/?opds', + ]); + }); + + it('skips entries with non-catalog types', () => { + const feed: OPDSFeed = { + metadata: { title: 'Kids' }, + links: [], + navigation: [{ title: 'Readme', href: '/readme.html', type: 'text/html', properties: {} }], + }; + expect(getSubsectionURLs(feed, baseURL)).toEqual([]); + }); + + it('skips facet, self, up, search and start rels', () => { + const catalogType = 'application/atom+xml;profile=opds-catalog'; + const feed: OPDSFeed = { + metadata: { title: 'Kids' }, + links: [], + navigation: [ + { + title: 'F', + href: '/f', + rel: 'http://opds-spec.org/facet', + type: catalogType, + properties: {}, + }, + { title: 'S', href: '/s', rel: 'self', type: catalogType, properties: {} }, + { title: 'U', href: '/u', rel: 'up', type: catalogType, properties: {} }, + { title: 'Q', href: '/q', rel: 'search', type: catalogType, properties: {} }, + { title: 'T', href: '/t', rel: 'start', type: catalogType, properties: {} }, + ], + }; + expect(getSubsectionURLs(feed, baseURL)).toEqual([]); + }); + + it('returns empty for feeds without navigation', () => { + const feed: OPDSFeed = { metadata: { title: 'Kids' }, links: [] }; + expect(getSubsectionURLs(feed, baseURL)).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/readingWidget.test.ts b/apps/readest-app/src/__tests__/services/readingWidget.test.ts new file mode 100644 index 000000000..b6ae73ed6 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/readingWidget.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + selectReadingWidgetBooks, + computeReadingPercent, + buildReadingWidgetPayload, +} from '@/services/widget/readingWidget'; +import type { Book } from '@/types/book'; + +vi.mock('@/utils/bridge', () => ({ updateReadingWidget: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@/store/libraryStore', () => ({ + useLibraryStore: { + getState: () => ({ + library: [ + { + hash: 'a', + title: 'Ta', + author: 'Aa', + format: 'EPUB', + updatedAt: 2, + progress: [1, 2], + readingStatus: 'reading', + }, + { + hash: 'b', + title: 'Tb', + author: 'Ab', + format: 'EPUB', + updatedAt: 5, + readingStatus: 'finished', + }, + ], + }), + }, +})); + +const mk = (over: Partial): Book => + ({ hash: 'h', title: 'T', author: 'A', format: 'EPUB', updatedAt: 0, ...over }) as Book; + +describe('computeReadingPercent', () => { + it('rounds current/total to a 0-100 integer', () => { + expect(computeReadingPercent(mk({ progress: [72, 100] }))).toBe(72); + expect(computeReadingPercent(mk({ progress: [1, 3] }))).toBe(33); + }); + it('is 0 when progress missing or total is 0', () => { + expect(computeReadingPercent(mk({}))).toBe(0); + expect(computeReadingPercent(mk({ progress: [1, 0] }))).toBe(0); + }); + it('clamps to 100', () => { + expect(computeReadingPercent(mk({ progress: [120, 100] }))).toBe(100); + }); +}); + +describe('selectReadingWidgetBooks', () => { + it('keeps non-deleted, non-finished, non-abandoned and sorts by updatedAt desc', () => { + const books = [ + mk({ hash: 'a', updatedAt: 10, readingStatus: 'reading' }), + mk({ hash: 'b', updatedAt: 30, readingStatus: 'unread' }), + mk({ hash: 'c', updatedAt: 20, readingStatus: 'finished' }), + mk({ hash: 'd', updatedAt: 40, readingStatus: 'abandoned' }), + mk({ hash: 'e', updatedAt: 50, deletedAt: 123 }), + ]; + expect(selectReadingWidgetBooks(books).map((b) => b.hash)).toEqual(['b', 'a']); + }); + it('caps at the limit', () => { + const books = [1, 2, 3, 4].map((n) => mk({ hash: String(n), updatedAt: n })); + expect(selectReadingWidgetBooks(books, 3)).toHaveLength(3); + }); +}); + +import { refreshReadingWidget } from '@/services/widget/readingWidget'; + +const appServiceForBuild = { + isMobileApp: true, + resolveFilePath: vi.fn().mockResolvedValue('/data/Books'), +} as unknown as import('@/types/system').AppService; + +const labelsForBuild = { sectionTitle: 'Continue reading', emptyTitle: 'Empty' }; +const booksForBuild: Book[] = [mk({ hash: 'x', updatedAt: 1, progress: [1, 4] })]; + +describe('buildReadingWidgetPayload', () => { + it('includes tts field when provided', async () => { + const payload = await buildReadingWidgetPayload( + booksForBuild, + appServiceForBuild, + labelsForBuild, + { + active: true, + playing: false, + }, + ); + expect(payload.tts).toEqual({ active: true, playing: false }); + }); + + it('omits tts key when not provided', async () => { + const payload = await buildReadingWidgetPayload( + booksForBuild, + appServiceForBuild, + labelsForBuild, + ); + expect('tts' in payload).toBe(false); + }); +}); + +describe('refreshReadingWidget', () => { + const appService = { + isMobileApp: true, + resolveFilePath: vi.fn().mockResolvedValue('/data/Books'), + } as unknown as import('@/types/system').AppService; + + it('skips when not a mobile app', async () => { + const { updateReadingWidget } = await import('@/utils/bridge'); + await refreshReadingWidget({ ...appService, isMobileApp: false } as never, { + sectionTitle: 'Continue reading', + emptyTitle: 'Empty', + }); + expect(updateReadingWidget).not.toHaveBeenCalled(); + }); + + it('selects in-progress books and resolves cover paths', async () => { + const { updateReadingWidget } = await import('@/utils/bridge'); + await refreshReadingWidget(appService, { + sectionTitle: 'Continue reading', + emptyTitle: 'Empty', + }); + expect(updateReadingWidget).toHaveBeenCalledWith({ + books: [ + { hash: 'a', title: 'Ta', author: 'Aa', percent: 50, coverPath: '/data/Books/a/cover.png' }, + ], + sectionTitle: 'Continue reading', + emptyTitle: 'Empty', + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/articleIngest.test.ts b/apps/readest-app/src/__tests__/services/rss/articleIngest.test.ts new file mode 100644 index 000000000..434f40f21 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/articleIngest.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveArticleInput, + handleOpenArticle, + openFeedArticle, +} from '@/services/rss/articleIngest'; +import { md5Fingerprint } from '@/utils/md5'; +import type { Book } from '@/types/book'; +import type { RssFeed, RssFeedItem } from '@/types/rss'; + +const item = (over: Partial): RssFeedItem => ({ + id: '1', + title: 'A', + link: 'https://x.example.com/a', + read: false, + ...over, +}); + +describe('resolveArticleInput', () => { + it('uses feed content without network when contentHtml is substantial', () => { + const html = `

${'word '.repeat(60)}

`; + expect(resolveArticleInput(item({ contentHtml: html }), null)).toEqual({ + kind: 'article', + html, + url: 'https://x.example.com/a', + }); + }); + + it('falls back to page HTML when the feed has no full content', () => { + const page = '
full
'; + expect(resolveArticleInput(item({ contentHtml: undefined }), page)).toEqual({ + kind: 'page', + html: page, + url: 'https://x.example.com/a', + }); + }); + + it('throws when there is neither feed content nor a fetched page', () => { + expect(() => resolveArticleInput(item({ contentHtml: undefined }), null)).toThrow( + /no full content/i, + ); + }); +}); + +describe('handleOpenArticle', () => { + it('imports, marks read, and navigates on success', async () => { + const calls: string[] = []; + await handleOpenArticle({} as never, { + openArticle: async () => ({ hash: 'h1', title: 'A' }) as never, + updateBooks: async () => void calls.push('update'), + markRead: () => calls.push('read'), + navigate: (hash) => calls.push(`nav:${hash}`), + onError: () => calls.push('error'), + }); + expect(calls).toEqual(['update', 'read', 'nav:h1']); + }); + + it('reports an error and does not navigate on failure', async () => { + const calls: string[] = []; + await handleOpenArticle({} as never, { + openArticle: async () => { + throw new Error('fetch failed'); + }, + updateBooks: async () => void calls.push('update'), + markRead: () => calls.push('read'), + navigate: () => calls.push('nav'), + onError: (m) => calls.push(`error:${m}`), + }); + expect(calls).toEqual(['error:fetch failed']); + }); +}); + +describe('openFeedArticle grouping', () => { + it('tags the imported article into the per-feed group (groupId + groupName)', async () => { + const feed = { + id: 'f', + url: 'https://x/feed', + title: 'My Feed', + addedAt: 0, + items: [], + } as RssFeed; + const feedItem = { + id: 'i', + title: 'T', + link: 'https://x/a', + read: false, + contentHtml: `

${'word '.repeat(80)}

`, + } as RssFeedItem; + let captured: { groupId?: string; groupName?: string } | undefined; + const book = await openFeedArticle({ + item: feedItem, + feed, + books: [], + appService: {} as never, + settings: {} as never, + isLoggedIn: false, + translate: (k: string) => k, + convert: async () => ({ file: new File(['x'], 'a.epub'), title: 'T', author: '' }), + ingest: (async (opts: { groupId?: string; groupName?: string }) => { + captured = { groupId: opts.groupId, groupName: opts.groupName }; + return { hash: 'h1', title: 'T' } as Book; + }) as never, + }); + expect(book.hash).toBe('h1'); + expect(captured?.groupName).toBe('My Feed'); + expect(captured?.groupId).toBe(md5Fingerprint('My Feed')); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedArticleContent.test.ts b/apps/readest-app/src/__tests__/services/rss/feedArticleContent.test.ts new file mode 100644 index 000000000..8dd9a1080 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedArticleContent.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { resolveArticleHtml, extractArticle } from '@/services/rss/feedArticleContent'; +import type { RssFeedItem } from '@/types/rss'; + +const item = (over: Partial): RssFeedItem => ({ + id: '1', + title: 'A', + link: 'https://x/a', + read: false, + ...over, +}); + +describe('resolveArticleHtml', () => { + it('uses feed content when substantial', () => { + const html = `

${'word '.repeat(60)}

`; + expect(resolveArticleHtml(item({ contentHtml: html }))).toEqual({ html }); + }); + it('needs a page fetch when thin', () => { + expect(resolveArticleHtml(item({ contentHtml: undefined }))).toEqual({ needsPage: true }); + }); +}); + +describe('extractArticle', () => { + it('extracts readable content and strips scripts', () => { + const page = `

Hi

${'

Readable paragraph here.

'.repeat(10)}
`; + const out = extractArticle(page, 'https://x/a'); + expect(out).toContain('Readable paragraph'); + expect(out).not.toContain(' { + const page = `

Img

${'

Words here for readability.

'.repeat(10)}
`; + const out = extractArticle(page, 'https://example.com/article'); + expect(out).toContain('https://example.com/photo.jpg'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedBook.test.ts b/apps/readest-app/src/__tests__/services/rss/feedBook.test.ts new file mode 100644 index 000000000..1fb08cbd7 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedBook.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { createFeedBook, feedBookHash, generateFeedCoverSvg } from '@/services/rss/feedBook'; +import { parseFeedBookUrl } from '@/services/rss/feedBookUrl'; + +describe('generateFeedCoverSvg', () => { + it('embeds the RSS icon as the center avatar and shows the site name', () => { + const cover = generateFeedCoverSvg( + 'https://feeds.feedburner.com/ruanyifeng', + 'Ruan YiFeng Blog', + ); + expect(cover.mime).toBe('image/svg+xml'); + const svg = new TextDecoder().decode(cover.bytes); + expect(svg).toContain('data:image/svg+xml;base64,'); // icon embedded as avatar image + expect(svg).toContain('feeds.feedburner.com'); // hostname as siteName + expect(svg).toContain('Ruan YiFeng Blog'); // title rendered + }); +}); + +describe('createFeedBook', () => { + it('creates a virtual feed book carrying feedUrl in metadata', () => { + const feedUrl = 'https://feeds.feedburner.com/ruanyifeng'; + const book = createFeedBook(feedUrl, { title: 'Blog', items: [] }); + expect(book.hash).toBe(feedBookHash(feedUrl)); + expect(book.title).toBe('Blog'); + expect(parseFeedBookUrl(book.url!)).toEqual({ feedUrl }); + expect(book.metadata?.feedUrl).toBe(feedUrl); + expect(book.filePath).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedBookUrl.test.ts b/apps/readest-app/src/__tests__/services/rss/feedBookUrl.test.ts new file mode 100644 index 000000000..ee6db92b2 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedBookUrl.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { buildFeedBookUrl, parseFeedBookUrl, isFeedBookUrl } from '@/services/rss/feedBookUrl'; + +describe('feedBookUrl', () => { + it('round-trips a feed URL through the descriptor', () => { + const url = buildFeedBookUrl('https://feeds.feedburner.com/ruanyifeng'); + expect(url.startsWith('feed://')).toBe(true); + expect(isFeedBookUrl(url)).toBe(true); + expect(parseFeedBookUrl(url)).toEqual({ feedUrl: 'https://feeds.feedburner.com/ruanyifeng' }); + }); + it('rejects non-feed urls', () => { + expect(isFeedBookUrl('pse://x')).toBe(false); + expect(isFeedBookUrl('https://x/y')).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedDiscovery.test.ts b/apps/readest-app/src/__tests__/services/rss/feedDiscovery.test.ts new file mode 100644 index 000000000..4c0110c96 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedDiscovery.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { discoverFeedUrls } from '@/services/rss/feedDiscovery'; + +describe('discoverFeedUrls', () => { + it('extracts and absolutizes RSS/Atom/JSON alternate links', () => { + const html = ` + + + + + `; + expect(discoverFeedUrls(html, 'https://example.com/blog/')).toEqual([ + 'https://example.com/feed.xml', + 'https://cdn.example.com/atom', + 'https://example.com/blog/feed.json', + ]); + }); + + it('returns an empty array when no feed links are present', () => { + expect(discoverFeedUrls('', 'https://example.com')).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedGuardedFetch.test.ts b/apps/readest-app/src/__tests__/services/rss/feedGuardedFetch.test.ts new file mode 100644 index 000000000..000696a37 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedGuardedFetch.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { assertFetchAllowed } from '@/services/rss/feedGuardedFetch'; + +describe('assertFetchAllowed', () => { + it('allows public https/http hosts', () => { + expect(() => assertFetchAllowed('https://feeds.feedburner.com/ruanyifeng')).not.toThrow(); + expect(() => assertFetchAllowed('http://www.ruanyifeng.com/blog/x.html')).not.toThrow(); + }); + it('blocks private/loopback/non-http', () => { + expect(() => assertFetchAllowed('http://127.0.0.1/x')).toThrow(); + expect(() => assertFetchAllowed('http://192.168.1.1/admin')).toThrow(); + expect(() => assertFetchAllowed('file:///etc/passwd')).toThrow(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedManifest.test.ts b/apps/readest-app/src/__tests__/services/rss/feedManifest.test.ts new file mode 100644 index 000000000..004a21ac6 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedManifest.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { assignSlots, emptyManifest, slotForArticleId } from '@/services/rss/feedManifest'; +import type { ParsedFeed } from '@/types/rss'; + +const parsed = (ids: string[]): ParsedFeed => ({ + title: 'Blog', + items: ids.map((id) => ({ id, title: `T-${id}`, link: `https://x/${id}`, read: false })), +}); + +describe('assignSlots (content-hash slots)', () => { + it('slot is deterministic and independent of arrival order/other items', () => { + const m1 = assignSlots(emptyManifest('https://x/feed', 'Blog'), parsed(['a', 'b'])); + const m2 = assignSlots(emptyManifest('https://x/feed', 'Blog'), parsed(['z', 'b', 'a'])); + const slotA1 = m1.entries.find((e) => e.id === 'a')!.slot; + const slotA2 = m2.entries.find((e) => e.id === 'a')!.slot; + expect(slotA1).toBe(slotForArticleId('a')); + expect(slotA1).toBe(slotA2); + }); + + it('refresh preserves existing entries and read flags; new ids get their content-hash slot', () => { + let m = assignSlots(emptyManifest('https://x/feed', 'Blog'), parsed(['a', 'b'])); + m.entries.find((e) => e.id === 'a')!.read = true; // simulate read + // refresh: 'z' is new and appears FIRST in feed order; 'a','b' still present + m = assignSlots(m, parsed(['z', 'a', 'b'])); + const entryA = m.entries.find((e) => e.id === 'a')!; + const entryZ = m.entries.find((e) => e.id === 'z')!; + expect(entryA.slot).toBe(slotForArticleId('a')); // slot preserved + expect(entryA.read).toBe(true); // read flag preserved + expect(entryZ.slot).toBe(slotForArticleId('z')); // new entry gets hash slot + // a and b are in the entries (existing); z is appended + expect(m.entries.findIndex((e) => e.id === 'z')).toBeGreaterThan( + m.entries.findIndex((e) => e.id === 'b'), + ); + }); + + it('is idempotent when nothing new arrives', () => { + const m1 = assignSlots(emptyManifest('u', 'B'), parsed(['a', 'b'])); + const m2 = assignSlots(m1, parsed(['a', 'b'])); + expect(m2.entries).toEqual(m1.entries); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedParser.test.ts b/apps/readest-app/src/__tests__/services/rss/feedParser.test.ts new file mode 100644 index 000000000..9565a8c4e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedParser.test.ts @@ -0,0 +1,130 @@ +// src/__tests__/services/rss/feedParser.test.ts +import { describe, expect, it } from 'vitest'; +import { parseFeed } from '@/services/rss/feedParser'; + +const RSS = ` + + + Example Blog + https://example.com + Words about things + + First Post + https://example.com/first + https://example.com/first + Wed, 02 Jul 2025 10:00:00 GMT + A short summary. + Full body.

]]>
+
+
+
`; + +const ATOM = ` + + Atom Example + + + Atom Entry + + urn:uuid:1 + 2025-07-01T12:00:00Z + Atom summary. + <p>Atom body.</p> + +`; + +const JSON_FEED = JSON.stringify({ + version: 'https://jsonfeed.org/version/1.1', + title: 'JSON Example', + home_page_url: 'https://json.example.com', + items: [ + { + id: 'j1', + url: 'https://json.example.com/1', + title: 'JSON Item', + date_published: '2025-07-03T09:00:00Z', + summary: 'JSON summary.', + content_html: '

JSON body.

', + }, + ], +}); + +describe('parseFeed', () => { + it('parses RSS 2.0 channel metadata and items', () => { + const feed = parseFeed(RSS, 'https://example.com/feed.xml'); + expect(feed.title).toBe('Example Blog'); + expect(feed.siteUrl).toBe('https://example.com'); + expect(feed.items).toHaveLength(1); + const item = feed.items[0]!; + expect(item.title).toBe('First Post'); + expect(item.link).toBe('https://example.com/first'); + expect(item.id).toBe('https://example.com/first'); + expect(item.summary).toBe('A short summary.'); + expect(item.contentHtml).toContain('body'); + expect(item.publishedAt).toBe(new Date('Wed, 02 Jul 2025 10:00:00 GMT').toISOString()); + expect(item.read).toBe(false); + }); + + it('parses Atom feeds, preferring alternate link and content', () => { + const feed = parseFeed(ATOM, 'https://atom.example.com/feed'); + expect(feed.title).toBe('Atom Example'); + expect(feed.items[0]!.link).toBe('https://atom.example.com/e1'); + expect(feed.items[0]!.id).toBe('urn:uuid:1'); + expect(feed.items[0]!.contentHtml).toContain('Atom body.'); + }); + + it('parses JSON Feed 1.1', () => { + const feed = parseFeed(JSON_FEED, 'https://json.example.com/feed.json'); + expect(feed.title).toBe('JSON Example'); + expect(feed.items[0]!.link).toBe('https://json.example.com/1'); + expect(feed.items[0]!.contentHtml).toBe('

JSON body.

'); + }); + + it('falls back to the item link as id when no guid is present', () => { + const noGuid = RSS.replace('https://example.com/first', ''); + expect(parseFeed(noGuid, 'https://example.com/feed.xml').items[0]!.id).toBe( + 'https://example.com/first', + ); + }); + + it('throws on documents that are neither RSS/Atom nor JSON Feed', () => { + expect(() => parseFeed('not a feed', 'https://x/y')).toThrow( + 'Unrecognized feed format', + ); + }); + + it('uses a full-content as contentHtml when there is no content:encoded', () => { + const body = `

${'长文内容 '.repeat(40)}

link`; + const rss = ` + + Bloghttps://b.example.com + + Posthttp://b.example.com/p + + +`; + const item = parseFeed(rss, 'https://b.example.com/feed').items[0]!; + // Full HTML body is preserved for the reader (no page re-fetch needed). + expect(item.contentHtml).toContain('

'); + expect(item.contentHtml).toContain('长文内容'); + // The list-row summary is plain text — no raw tags. + expect(item.summary).toBeTruthy(); + expect(item.summary).not.toContain('<'); + expect(item.summary).toContain('长文内容'); + }); + + it('prefers content:encoded over description and keeps summary as plain text', () => { + const rss = ` + + B + + Phttps://b/p + plain summary + full body

]]> + +`; + const item = parseFeed(rss, 'https://b/feed').items[0]!; + expect(item.contentHtml).toContain('full body'); + expect(item.summary).toBe('plain summary'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/feedReader.test.ts b/apps/readest-app/src/__tests__/services/rss/feedReader.test.ts new file mode 100644 index 000000000..66888b04e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/feedReader.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { refreshFeedManifest, openFeedBookDoc } from '@/services/rss/feedReader'; +import { slotForArticleId } from '@/services/rss/feedManifest'; +import type { FileSystem } from '@/types/system'; +import type { ParsedFeed } from '@/types/rss'; + +function memFs() { + const store = new Map(); + const key = (p: string, b: string) => `${b}:${p}`; + return { + exists: async (p: string, b: string) => store.has(key(p, b)), + readFile: async (p: string, b: string) => { + const v = store.get(key(p, b)); + if (v === undefined) throw new Error('ENOENT'); + return v; + }, + writeFile: async (p: string, b: string, c: string) => { + store.set(key(p, b), c); + }, + } as unknown as FileSystem; +} + +const parsed = (items: Array<{ id: string; contentHtml?: string }>): ParsedFeed => ({ + title: 'Blog', + items: items.map((it) => ({ + id: it.id, + title: `T-${it.id}`, + link: `https://x/${it.id}`, + read: false, + contentHtml: it.contentHtml, + })), +}); + +describe('refreshFeedManifest', () => { + it('assigns slots and caches new entries once; page-fetches only thin ones', async () => { + const fs = memFs(); + const extractCalls: string[] = []; + const deps = { + fetchAndParse: async () => + parsed([{ id: 'a', contentHtml: `

${'x '.repeat(120)}

` }, { id: 'b' }]), + extractFor: async (e: FeedArticleEntryLike) => { + extractCalls.push(e.id); + return `

fetched ${e.id}

`; + }, + }; + let m = await refreshFeedManifest(fs, 'feedhash', 'https://x/feed', 'Blog', deps); + expect(m.entries.map((e) => e.id)).toEqual(['a', 'b']); + expect(extractCalls).toEqual(['b']); + + deps.fetchAndParse = async () => + parsed([{ id: 'c' }, { id: 'a', contentHtml: `

${'x '.repeat(120)}

` }, { id: 'b' }]); + m = await refreshFeedManifest(fs, 'feedhash', 'https://x/feed', 'Blog', deps); + expect([...m.entries].map((e) => e.id).sort()).toEqual(['a', 'b', 'c']); + expect(m.entries.find((e) => e.id === 'c')!.slot).toBe(slotForArticleId('c')); + expect(extractCalls).toEqual(['b', 'c']); + }); + + it('one failing extraction does not abort the refresh', async () => { + const fs = memFs(); + const deps = { + fetchAndParse: async () => + parsed([{ id: 'good', contentHtml: `

${'x '.repeat(120)}

` }, { id: 'bad' }]), + extractFor: async (e: FeedArticleEntryLike) => { + if (e.id === 'bad') throw new Error('network fail'); + return '

ok

'; + }, + }; + const m = await refreshFeedManifest(fs, 'fh', 'https://x/feed', 'Blog', deps); + // Both entries kept their manifest slots despite 'bad' failing: + expect(m.entries.map((e) => e.id).sort()).toEqual(['bad', 'good']); + // 'good' was cached from feed content; 'bad' skipped, not cached. + }); +}); + +type FeedArticleEntryLike = { id: string; link: string }; + +describe('openFeedBookDoc (stale-while-revalidate)', () => { + it('opens instantly from a warm manifest without awaiting the refresh', async () => { + const fs = memFs(); + // Seed: first refresh populates manifest + cache. + await refreshFeedManifest(fs, 'fh', 'https://x/feed', 'Blog', { + fetchAndParse: async () => parsed([{ id: 'a', contentHtml: `

${'x '.repeat(120)}

` }]), + }); + // Warm open: refresh hangs forever — open must still resolve. + let refreshStarted = false; + const never = new Promise(() => {}); + const doc = await openFeedBookDoc(fs, 'fh', 'https://x/feed', 'Blog', { + refresh: ((): Promise => { + refreshStarted = true; + return never; + }) as unknown as typeof refreshFeedManifest, + }); + expect(refreshStarted).toBe(true); // background refresh fired + expect(doc.sections).toHaveLength(1); // built from local manifest + }); + + it('first open (empty manifest) awaits the fetch', async () => { + const fs = memFs(); + const doc = await openFeedBookDoc(fs, 'fh2', 'https://x/feed', 'Blog', { + fetchAndParse: async () => parsed([{ id: 'a', contentHtml: `

${'x '.repeat(120)}

` }]), + }); + expect(doc.sections).toHaveLength(1); // fetched then built + }); + + it('first open survives a failing fetch with an empty book', async () => { + const fs = memFs(); + const doc = await openFeedBookDoc(fs, 'fh3', 'https://x/feed', 'Blog', { + fetchAndParse: async () => { + throw new Error('offline'); + }, + }); + expect(doc.sections).toHaveLength(0); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rss/makeFeedBook.test.ts b/apps/readest-app/src/__tests__/services/rss/makeFeedBook.test.ts new file mode 100644 index 000000000..05cef904e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rss/makeFeedBook.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { makeFeedBook } from '@/services/rss/makeFeedBook'; +import { CFI } from '@/libs/document'; +import type { FeedManifest } from '@/services/rss/feedManifest'; + +// Manifest with hash-style slots (non-sequential). Slot 7 is "older" (publishedAt earlier), +// slot 42 is "newer" (publishedAt later). Entries are in manifest order (42 first, 7 second) +// but should be sorted by date ascending in sections (7 first, 42 second). +const manifest: FeedManifest = { + feedUrl: 'u', + title: 'Blog', + entries: [ + { + id: 'a', + slot: 42, + title: 'A', + link: 'https://x/a', + read: false, + publishedAt: '2024-01-02T00:00:00Z', + }, + { + id: 'c', + slot: 7, + title: 'C', + link: 'https://x/c', + read: false, + publishedAt: '2024-01-01T00:00:00Z', + }, + ], +}; + +describe('makeFeedBook', () => { + it('builds one section per entry with correct CFIs (slot != array index)', async () => { + const book = await makeFeedBook(manifest, async (e) => `

body ${e.id}

`); + expect(book.sections).toHaveLength(2); + // After date-ascending sort: slot-7 (C, older) is index 0, slot-42 (A, newer) is index 1 + expect(book.sections[0]!.cfi).toBe(CFI.fake.fromIndex(7)); + expect(book.sections[1]!.cfi).toBe(CFI.fake.fromIndex(42)); // slot 42, not index 1 + expect(book.metadata.title).toBe('Blog'); + const doc0 = await book.sections[0]!.createDocument(); + expect(doc0.body.textContent).toContain('body c'); // C is slot-7, date-first + }); + + it('sorts sections by publishedAt ascending (entries with date before undated)', async () => { + const mixedManifest: FeedManifest = { + feedUrl: 'u', + title: 'Mixed', + entries: [ + { + id: 'newer', + slot: 100, + title: 'Newer', + link: 'https://x/newer', + read: false, + publishedAt: '2024-02-01T00:00:00Z', + }, + { id: 'undated', slot: 200, title: 'Undated', link: 'https://x/undated', read: false }, + { + id: 'older', + slot: 300, + title: 'Older', + link: 'https://x/older', + read: false, + publishedAt: '2024-01-01T00:00:00Z', + }, + ], + }; + const book = await makeFeedBook(mixedManifest, async (e) => `

body ${e.id}

`); + expect(book.sections[0]!.id).toBe('300'); // older date first + expect(book.sections[1]!.id).toBe('100'); // newer date second + expect(book.sections[2]!.id).toBe('200'); // undated last + }); + + it('resolveCFI resolves by slot id, not array index', async () => { + // slot-42 entry is at index 1 in manifest but becomes index 1 in sections too (after date sort), + // but the key test: slot-7 entry maps to sections[0] by content, not by slot number ordering. + const book = await makeFeedBook(manifest, async (e) => `

body ${e.id}

`); + const bookWithResolve = book as unknown as { + resolveCFI: (c: string) => { index: number; anchor: (doc: Document) => unknown }; + }; + expect(typeof bookWithResolve.resolveCFI).toBe('function'); + // slot-7 (C) is at array index 0 after date sort + const result7 = bookWithResolve.resolveCFI(CFI.fake.fromIndex(7)); + expect(result7.index).toBe(0); + // slot-42 (A) is at array index 1 after date sort + const result42 = bookWithResolve.resolveCFI(CFI.fake.fromIndex(42)); + expect(result42.index).toBe(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/select-new-importable-files.test.ts b/apps/readest-app/src/__tests__/services/select-new-importable-files.test.ts new file mode 100644 index 000000000..1bbde0f42 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/select-new-importable-files.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'vitest'; +import { selectNewImportableFiles } from '@/services/bookService'; + +const entry = (fullPath: string, size = 100_000) => ({ fullPath, size }); + +describe('selectNewImportableFiles', () => { + const opts = (over: Partial[1]> = {}) => ({ + extensions: ['epub', 'pdf', 'mobi'], + minSizeBytes: 20 * 1024, + existingPaths: new Set(), + osPlatform: 'linux' as const, + ...over, + }); + + test('keeps only supported extensions (case-insensitive)', () => { + const result = selectNewImportableFiles( + [entry('/b/a.EPUB'), entry('/b/c.pdf'), entry('/b/note.txt'), entry('/b/d.png')], + opts(), + ); + expect(result.map((e) => e.fullPath)).toEqual(['/b/a.EPUB', '/b/c.pdf']); + }); + + test('drops files below the min size', () => { + const result = selectNewImportableFiles( + [entry('/b/tiny.epub', 1024), entry('/b/ok.epub', 30_000)], + opts(), + ); + expect(result.map((e) => e.fullPath)).toEqual(['/b/ok.epub']); + }); + + test('drops files already present (exact path)', () => { + const result = selectNewImportableFiles( + [entry('/b/have.epub'), entry('/b/new.epub')], + opts({ existingPaths: new Set(['/b/have.epub']) }), + ); + expect(result.map((e) => e.fullPath)).toEqual(['/b/new.epub']); + }); + + test('matches existing paths case-insensitively on macos/ios/windows', () => { + const result = selectNewImportableFiles( + [entry('/B/Have.EPUB')], + // existingPaths keys are pre-normalized (lowercased) on these platforms + opts({ osPlatform: 'macos', existingPaths: new Set(['/b/have.epub']) }), + ); + expect(result).toEqual([]); + }); + + test('is case-sensitive on linux/android', () => { + const result = selectNewImportableFiles( + [entry('/B/Have.epub')], + opts({ osPlatform: 'linux', existingPaths: new Set(['/b/have.epub']) }), + ); + expect(result.map((e) => e.fullPath)).toEqual(['/B/Have.epub']); + }); + + test('returns [] when every scanned file is already present (quiet path)', () => { + const result = selectNewImportableFiles( + [entry('/b/a.epub'), entry('/b/b.epub')], + opts({ existingPaths: new Set(['/b/a.epub', '/b/b.epub']) }), + ); + expect(result).toEqual([]); + }); + + test('returns all new files when the library is empty', () => { + const result = selectNewImportableFiles([entry('/b/a.epub'), entry('/b/b.pdf')], opts()); + expect(result).toHaveLength(2); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/KOSyncClient.test.ts b/apps/readest-app/src/__tests__/services/sync/KOSyncClient.test.ts new file mode 100644 index 000000000..d6567045a --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/KOSyncClient.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { KOSyncClient } from '@/services/sync/KOSyncClient'; +import { KOSyncSettings } from '@/types/settings'; + +// The LAN-server branch of KOSyncClient.request uses window.fetch (mocked +// per-test); the Tauri HTTP plugin is never invoked here, so stub the import +// to keep the unit environment free of Tauri internals. +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); + +const makeConfig = (overrides: Partial = {}): KOSyncSettings => ({ + enabled: true, + // A LAN address makes request() take the direct window.fetch path. + serverUrl: 'http://192.168.1.50', + username: 'alice', + userkey: '', + password: '', + deviceId: 'device-1', + deviceName: 'Readest', + checksumMethod: 'binary', + strategy: 'prompt', + ...overrides, +}); + +type FetchMock = ReturnType; + +const setFetch = (impl: (...args: unknown[]) => unknown): FetchMock => { + const mock = vi.fn(impl) as FetchMock; + vi.stubGlobal('fetch', mock); + window.fetch = mock as unknown as typeof window.fetch; + return mock; +}; + +// Minimal Response-like object covering the fields KOSyncClient reads. +const htmlPage = (status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + }, +}); + +const jsonResponse = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +describe('KOSyncClient.connect – server validation', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fails when /users/auth returns 200 with a non-JSON (web UI) page', async () => { + // A wrong Server URL that lands on the server's static web UI returns the + // HTML index page with 200 OK. That must NOT be treated as a successful + // login (it isn't a KOReader sync endpoint). + setFetch(() => htmlPage(200)); + + const client = new KOSyncClient(makeConfig()); + const result = await client.connect('alice', 'secret'); + + expect(result.success).toBe(false); + }); + + it('succeeds when /users/auth returns a valid KOReader auth JSON', async () => { + setFetch(() => jsonResponse(200, { authorized: 'OK' })); + + const client = new KOSyncClient(makeConfig()); + const result = await client.connect('alice', 'secret'); + + expect(result.success).toBe(true); + }); + + it('fails when registration (/users/create) returns 200 with a non-JSON page', async () => { + // /users/auth → 401 routes connect() into the create path; a web UI that + // returns 200 HTML there must not be reported as a successful registration. + const mock = setFetch((url: unknown) => { + if (String(url).includes('/users/create')) return htmlPage(200); + return htmlPage(401); // auth fails -> triggers create + }); + + const client = new KOSyncClient(makeConfig()); + const result = await client.connect('alice', 'secret'); + + expect(result.success).toBe(false); + expect(mock).toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts index 6bdef6abf..8bc19eb34 100644 --- a/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/adapters/settings.test.ts @@ -74,6 +74,39 @@ describe('settingsAdapter', () => { expect(fields['kosync.serverUrl']).toBe('https://kosync.example'); }); + test('pack ∘ unpack round-trips WebDAV connection fields, dropping per-device state', () => { + const record: SettingsRemoteRecord = { + name: 'singleton', + patch: { + webdav: { + enabled: true, + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Books', + deviceId: 'this-device', + lastSyncedAt: 123, + }, + } as unknown as Partial, + }; + const fields = settingsAdapter.pack(record); + expect(fields['webdav.serverUrl']).toBe('https://dav.example.com'); + expect(fields['webdav.username']).toBe('alice'); + expect(fields['webdav.password']).toBe('hunter2'); + expect(fields['webdav.rootPath']).toBe('/Books'); + // Per-device bookkeeping must not ship. + expect(fields['webdav.enabled']).toBeUndefined(); + expect(fields['webdav.deviceId']).toBeUndefined(); + expect(fields['webdav.lastSyncedAt']).toBeUndefined(); + + const out = settingsAdapter.unpack(fields); + expect(out.patch.webdav?.serverUrl).toBe('https://dav.example.com'); + expect(out.patch.webdav?.username).toBe('alice'); + expect(out.patch.webdav?.password).toBe('hunter2'); + expect(out.patch.webdav?.rootPath).toBe('/Books'); + expect(out.patch.webdav?.enabled).toBeUndefined(); + }); + test('pack ∘ unpack round-trips object-valued nested fields (highlight palette)', () => { const customColors = { yellow: '#ffeb3b', blue: '#2196f3' }; const userColors = [{ name: 'mint', color: '#a8e6cf' }]; @@ -92,13 +125,53 @@ describe('settingsAdapter', () => { expect(out.patch.globalReadSettings?.userHighlightColors).toEqual(userColors); }); - test('declares encryptedFields covering kosync / readwise / hardcover credentials only (not serverUrl)', () => { + test('pack ∘ unpack round-trips S3 connection fields, dropping per-device state', () => { + const record: SettingsRemoteRecord = { + name: 'singleton', + patch: { + s3: { + enabled: true, + endpoint: 'https://acc.r2.cloudflarestorage.com', + region: 'auto', + bucket: 'readest', + accessKeyId: 'AKIA', + secretAccessKey: 'shh', + deviceId: 'this-device', + lastSyncedAt: 123, + providerSelectedAt: 456, + }, + } as unknown as Partial, + }; + const fields = settingsAdapter.pack(record); + expect(fields['s3.endpoint']).toBe('https://acc.r2.cloudflarestorage.com'); + expect(fields['s3.region']).toBe('auto'); + expect(fields['s3.bucket']).toBe('readest'); + expect(fields['s3.accessKeyId']).toBe('AKIA'); + expect(fields['s3.secretAccessKey']).toBe('shh'); + // Per-device bookkeeping must not ship. + expect(fields['s3.enabled']).toBeUndefined(); + expect(fields['s3.deviceId']).toBeUndefined(); + expect(fields['s3.lastSyncedAt']).toBeUndefined(); + expect(fields['s3.providerSelectedAt']).toBeUndefined(); + + const out = settingsAdapter.unpack(fields); + expect(out.patch.s3?.endpoint).toBe('https://acc.r2.cloudflarestorage.com'); + expect(out.patch.s3?.accessKeyId).toBe('AKIA'); + expect(out.patch.s3?.secretAccessKey).toBe('shh'); + expect(out.patch.s3?.enabled).toBeUndefined(); + }); + + test('declares encryptedFields covering kosync / readwise / hardcover / webdav / s3 credentials only (not serverUrl / endpoint)', () => { expect(settingsAdapter.encryptedFields).toEqual([ 'kosync.username', 'kosync.userkey', 'kosync.password', 'readwise.accessToken', 'hardcover.accessToken', + 'webdav.username', + 'webdav.password', + 's3.accessKeyId', + 's3.secretAccessKey', ]); }); @@ -106,6 +179,17 @@ describe('settingsAdapter', () => { expect(settingsAdapter.encryptedFields).not.toContain('kosync.serverUrl'); }); + test('webdav.serverUrl and webdav.rootPath are plaintext (not in encryptedFields)', () => { + expect(settingsAdapter.encryptedFields).not.toContain('webdav.serverUrl'); + expect(settingsAdapter.encryptedFields).not.toContain('webdav.rootPath'); + }); + + test('s3.endpoint / region / bucket are plaintext (not in encryptedFields)', () => { + expect(settingsAdapter.encryptedFields).not.toContain('s3.endpoint'); + expect(settingsAdapter.encryptedFields).not.toContain('s3.region'); + expect(settingsAdapter.encryptedFields).not.toContain('s3.bucket'); + }); + test('unpackRow reconstructs the patch from CRDT envelopes', () => { const userColors = [{ name: 'mint', color: '#a8e6cf' }]; const row = makeRow({ @@ -141,11 +225,91 @@ describe('SETTINGS_WHITELIST', () => { expect(SETTINGS_WHITELIST).toContain('dictionarySettings.providerOrder'); expect(SETTINGS_WHITELIST).toContain('dictionarySettings.providerEnabled'); expect(SETTINGS_WHITELIST).toContain('dictionarySettings.webSearches'); + // Dictionary popup font size (#4443) follows the user across devices. + expect(SETTINGS_WHITELIST).toContain('dictionarySettings.fontScale'); + }); + + test('includes the S3 connection fields but not its per-device bookkeeping', () => { + expect(SETTINGS_WHITELIST).toContain('s3.endpoint'); + expect(SETTINGS_WHITELIST).toContain('s3.region'); + expect(SETTINGS_WHITELIST).toContain('s3.bucket'); + expect(SETTINGS_WHITELIST).toContain('s3.accessKeyId'); + expect(SETTINGS_WHITELIST).toContain('s3.secretAccessKey'); + expect(SETTINGS_WHITELIST).not.toContain('s3.enabled'); + expect(SETTINGS_WHITELIST).not.toContain('s3.deviceId'); + expect(SETTINGS_WHITELIST).not.toContain('s3.providerSelectedAt'); }); test('does NOT sync dictionarySettings.defaultProviderId (per-device last-used tab)', () => { expect(SETTINGS_WHITELIST).not.toContain('dictionarySettings.defaultProviderId'); }); + + test('syncs library-scope proofread rules (issue #4700 — PC rules not reaching mobile)', () => { + expect(SETTINGS_WHITELIST).toContain('globalViewSettings.proofreadRules'); + }); + + test('syncs WebDAV connection + credentials (issue #4810 — credentials not synced)', () => { + expect(SETTINGS_WHITELIST).toContain('webdav.serverUrl'); + expect(SETTINGS_WHITELIST).toContain('webdav.username'); + expect(SETTINGS_WHITELIST).toContain('webdav.password'); + expect(SETTINGS_WHITELIST).toContain('webdav.rootPath'); + }); + + test('does NOT sync per-device WebDAV bookkeeping fields', () => { + // enabled / deviceId / lastSyncedAt / sync sub-toggles are per-device + // state — syncing them would auto-arm a fresh device or rotate its id. + expect(SETTINGS_WHITELIST).not.toContain('webdav.enabled'); + expect(SETTINGS_WHITELIST).not.toContain('webdav.deviceId'); + expect(SETTINGS_WHITELIST).not.toContain('webdav.lastSyncedAt'); + }); +}); + +describe('settingsAdapter proofread rules', () => { + test('pack ∘ unpack round-trips globalViewSettings.proofreadRules', () => { + const proofreadRules = [ + { + id: 'r1', + scope: 'library', + pattern: 'colour', + replacement: 'color', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1000, + wholeWord: true, + }, + ]; + const record: SettingsRemoteRecord = { + name: 'singleton', + patch: { + globalViewSettings: { proofreadRules }, + } as unknown as Partial, + }; + const fields = settingsAdapter.pack(record); + expect(fields['globalViewSettings.proofreadRules']).toEqual(proofreadRules); + const out = settingsAdapter.unpack(fields); + expect(out.patch.globalViewSettings?.proofreadRules).toEqual(proofreadRules); + }); + + test('unpackRow reconstructs proofread rules from a CRDT envelope', () => { + const proofreadRules = [ + { + id: 'r1', + scope: 'library', + pattern: 'teh', + replacement: 'the', + enabled: true, + isRegex: false, + caseSensitive: false, + order: 1000, + wholeWord: true, + }, + ]; + const row = makeRow({ 'globalViewSettings.proofreadRules': env(proofreadRules) }); + const out = settingsAdapter.unpackRow(row, ''); + expect(out).not.toBeNull(); + expect(out!.patch.globalViewSettings?.proofreadRules).toEqual(proofreadRules); + }); }); describe('readPath / writePath', () => { diff --git a/apps/readest-app/src/__tests__/services/sync/cloudSyncProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/cloudSyncProvider.test.ts new file mode 100644 index 000000000..9bcafbe80 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/cloudSyncProvider.test.ts @@ -0,0 +1,198 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import type { SystemSettings } from '@/types/settings'; + +vi.mock('@/utils/access', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isCloudSyncAllowed: vi.fn(actual.isCloudSyncAllowed), + }; +}); + +import { + applySyncBooksAutoEnable, + cloudProviderDisplayName, + getCloudSyncProvider, + isReadestCloudStorageActive, + resolveCloudSyncGate, + setCachedUserPlan, + settingsKeyForBackend, +} from '@/services/sync/cloudSyncProvider'; +import { isCloudSyncAllowed } from '@/utils/access'; + +const makeSettings = (overrides: Partial = {}): SystemSettings => + ({ + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...overrides, + }) as SystemSettings; + +beforeEach(() => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(true); + setCachedUserPlan('free'); +}); + +describe('getCloudSyncProvider', () => { + test('derives readest when no third-party provider is enabled', () => { + expect(getCloudSyncProvider(makeSettings())).toBe('readest'); + }); + + test('derives webdav when webdav is enabled', () => { + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + expect(getCloudSyncProvider(settings)).toBe('webdav'); + }); + + test('derives gdrive when google drive is enabled', () => { + const settings = makeSettings({ googleDrive: { enabled: true } } as Partial); + expect(getCloudSyncProvider(settings)).toBe('gdrive'); + }); + + test('derives s3 when s3 is enabled', () => { + const settings = makeSettings({ s3: { enabled: true } } as Partial); + expect(getCloudSyncProvider(settings)).toBe('s3'); + }); + + test("returns 'onedrive' when only OneDrive is enabled", () => { + expect(getCloudSyncProvider({ onedrive: { enabled: true } } as SystemSettings)).toBe( + 'onedrive', + ); + }); + + test('webdav wins deterministically over s3 when both are enabled (corrupt state)', () => { + const settings = makeSettings({ + webdav: { enabled: true }, + s3: { enabled: true }, + } as Partial); + expect(getCloudSyncProvider(settings)).toBe('webdav'); + }); + + test('webdav wins deterministically when both flags are enabled (corrupt state)', () => { + const settings = makeSettings({ + webdav: { enabled: true }, + googleDrive: { enabled: true }, + } as Partial); + expect(getCloudSyncProvider(settings)).toBe('webdav'); + }); + + test('defaults to readest for missing slices and missing settings', () => { + expect(getCloudSyncProvider({} as SystemSettings)).toBe('readest'); + expect(getCloudSyncProvider(null)).toBe('readest'); + expect(getCloudSyncProvider(undefined)).toBe('readest'); + }); +}); + +describe('resolveCloudSyncGate', () => { + test('readest provider is never paused, even when cloud sync is disallowed', () => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(false); + expect(resolveCloudSyncGate(makeSettings(), 'free')).toEqual({ + provider: 'readest', + paused: false, + }); + }); + + test('third-party provider stays selected but paused when disallowed (no silent readest fallback)', () => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(false); + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + expect(resolveCloudSyncGate(settings, 'free')).toEqual({ provider: 'webdav', paused: true }); + }); + + test('third-party provider is active when allowed', () => { + const settings = makeSettings({ googleDrive: { enabled: true } } as Partial); + expect(resolveCloudSyncGate(settings, 'plus')).toEqual({ provider: 'gdrive', paused: false }); + }); + + test('falls back to the cached user plan when no plan argument is given', () => { + vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free'); + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + + setCachedUserPlan('free'); + expect(resolveCloudSyncGate(settings).paused).toBe(true); + + setCachedUserPlan('pro'); + expect(resolveCloudSyncGate(settings).paused).toBe(false); + }); + + test('undefined cached plan is treated as free', () => { + vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free'); + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + setCachedUserPlan(undefined); + expect(resolveCloudSyncGate(settings).paused).toBe(true); + }); +}); + +describe('applySyncBooksAutoEnable (upgrade migration for already-enabled providers)', () => { + test('flips syncBooks on for an enabled webdav provider, mutating the given settings', () => { + const settings = makeSettings({ + webdav: { enabled: true, syncBooks: false }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(true); + expect(settings.webdav?.syncBooks).toBe(true); + }); + + test('flips syncBooks on for an enabled gdrive provider', () => { + const settings = makeSettings({ + googleDrive: { enabled: true, syncBooks: false }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(true); + expect(settings.googleDrive?.syncBooks).toBe(true); + }); + + test('no-op when readest is the provider', () => { + const settings = makeSettings(); + expect(applySyncBooksAutoEnable(settings)).toBe(false); + expect(settings.webdav?.syncBooks).toBeUndefined(); + }); + + test('no-op when syncBooks is already on', () => { + const settings = makeSettings({ + webdav: { enabled: true, syncBooks: true }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(false); + }); + + test('only the selected provider is flipped when both are enabled', () => { + const settings = makeSettings({ + webdav: { enabled: true, syncBooks: false }, + googleDrive: { enabled: true, syncBooks: false }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(true); + expect(settings.webdav?.syncBooks).toBe(true); + expect(settings.googleDrive?.syncBooks).toBe(false); + }); +}); + +describe('isReadestCloudStorageActive', () => { + test('true when readest is the derived provider', () => { + expect(isReadestCloudStorageActive(makeSettings())).toBe(true); + }); + + test('false when a third-party provider is selected', () => { + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + expect(isReadestCloudStorageActive(settings)).toBe(false); + }); + + test('false while paused: uploads must not silently resume to Readest Cloud', () => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(false); + const settings = makeSettings({ googleDrive: { enabled: true } } as Partial); + expect(isReadestCloudStorageActive(settings, 'free')).toBe(false); + }); +}); + +describe('settingsKeyForBackend', () => { + test('maps each backend kind to its settings slice', () => { + expect(settingsKeyForBackend('webdav')).toBe('webdav'); + expect(settingsKeyForBackend('gdrive')).toBe('googleDrive'); + expect(settingsKeyForBackend('s3')).toBe('s3'); + expect(settingsKeyForBackend('onedrive')).toBe('onedrive'); + }); +}); + +describe('cloudProviderDisplayName', () => { + test('names every provider kind', () => { + expect(cloudProviderDisplayName('webdav')).toBe('WebDAV'); + expect(cloudProviderDisplayName('gdrive')).toBe('Google Drive'); + expect(cloudProviderDisplayName('s3')).toBe('S3'); + expect(cloudProviderDisplayName('onedrive')).toBe('OneDrive'); + expect(cloudProviderDisplayName('readest')).toBe('Readest Cloud'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/appLocalStore.test.ts b/apps/readest-app/src/__tests__/services/sync/file/appLocalStore.test.ts new file mode 100644 index 000000000..3adb1d452 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/appLocalStore.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { Book } from '@/types/book'; +import type { AppService } from '@/types/system'; +import type { SystemSettings } from '@/types/settings'; +import type { EnvConfigType } from '@/services/environment'; +import { useLibraryStore } from '@/store/libraryStore'; +import { createAppLocalStore } from '@/services/sync/file/appLocalStore'; + +/** + * Regression test for the library-clobber data-loss path: when "Sync now" + * runs while the library store hasn't loaded yet (the app launched straight + * into the reader / settings, never mounting the Library view), the engine's + * addBookToLibrary / updateBookMetadata used to merge against the EMPTY + * in-memory library and persist a downloaded book (or a metadata update) as + * the *entire* library, wiping everything already on disk. The store bridge + * now hydrates from disk first. + */ + +const makeBook = (hash: string, overrides: Partial = {}): Book => ({ + hash, + format: 'EPUB', + title: `Book ${hash}`, + sourceTitle: `Book ${hash}`, + author: 'A', + createdAt: 1, + updatedAt: 1, + ...overrides, +}); + +let savedLibrary: Book[] | null; +let appService: AppService; +let envConfig: EnvConfigType; + +const onDisk = [makeBook('a'), makeBook('b')]; + +beforeEach(() => { + savedLibrary = null; + // Simulate the unloaded store: the user hasn't visited the Library view. + useLibraryStore.setState({ library: [], libraryLoaded: false, hashIndex: new Map() }); + appService = { + loadLibraryBooks: vi.fn(async () => onDisk.map((b) => ({ ...b }))), + saveLibraryBooks: vi.fn(async (books: Book[]) => { + savedLibrary = books; + }), + generateCoverImageUrl: vi.fn(async () => 'blob:cover'), + deleteBook: vi.fn(async () => {}), + } as unknown as AppService; + envConfig = { getAppService: async () => appService } as unknown as EnvConfigType; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const makeStore = () => + createAppLocalStore({ appService, settings: {} as SystemSettings, envConfig }); + +describe('createAppLocalStore — library hydration (data-loss guard)', () => { + test('addBookToLibrary keeps existing on-disk books when the store is unloaded', async () => { + await makeStore().addBookToLibrary(makeBook('c')); + + expect(appService.loadLibraryBooks).toHaveBeenCalledTimes(1); + expect(savedLibrary).not.toBeNull(); + const hashes = savedLibrary!.map((b) => b.hash).sort(); + // The downloaded book is appended; a, b must survive (no clobber to [c]). + expect(hashes).toEqual(['a', 'b', 'c']); + }); + + test('updateBookMetadata does not wipe the library when the store is unloaded', async () => { + await makeStore().updateBookMetadata(makeBook('a', { title: 'New Title', updatedAt: 9 })); + + expect(appService.loadLibraryBooks).toHaveBeenCalled(); + expect(savedLibrary).not.toBeNull(); + const hashes = savedLibrary!.map((b) => b.hash).sort(); + // Both books survive; a is updated in place, b is untouched. + expect(hashes).toEqual(['a', 'b']); + expect(savedLibrary!.find((b) => b.hash === 'a')!.title).toBe('New Title'); + }); + + test('addBookToLibrary merges against an already-loaded store without reloading', async () => { + useLibraryStore.getState().setLibrary([makeBook('a'), makeBook('b')]); + await makeStore().addBookToLibrary(makeBook('c')); + + expect(appService.loadLibraryBooks).not.toHaveBeenCalled(); + expect(savedLibrary!.map((b) => b.hash).sort()).toEqual(['a', 'b', 'c']); + }); + + test('deleteBookLocally removes the managed copy and persists the tombstone (#4860)', async () => { + useLibraryStore.getState().setLibrary([makeBook('a'), makeBook('b')]); + + await makeStore().deleteBookLocally(makeBook('a', { deletedAt: 500 })); + + // The managed local copy is removed via the 'local' delete action. + expect(appService.deleteBook).toHaveBeenCalledWith( + expect.objectContaining({ hash: 'a' }), + 'local', + ); + // The tombstone is persisted and the other book survives. + expect(savedLibrary!.map((b) => b.hash).sort()).toEqual(['a', 'b']); + expect(savedLibrary!.find((b) => b.hash === 'a')!.deletedAt).toBe(500); + // The deleted book drops off the visible shelf. + expect(useLibraryStore.getState().visibleLibrary.map((b) => b.hash)).toEqual(['b']); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/engine-auth-abort.test.ts b/apps/readest-app/src/__tests__/services/sync/file/engine-auth-abort.test.ts new file mode 100644 index 000000000..188c82054 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/engine-auth-abort.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { Book, BookConfig } from '@/types/book'; +import { FileSyncEngine } from '@/services/sync/file/engine'; +import { FileSyncError, type FileSyncProvider } from '@/services/sync/file/provider'; +import type { LocalStore } from '@/services/sync/file/localStore'; +import type { RemoteLibraryIndex } from '@/services/sync/file/wire'; + +/** + * Terminal-failure semantics (the Drive web token expiry incident): an + * unreadable index must not be treated as "no index yet" (which turns an + * expired session into an attempted mass re-upload and would drop peers' + * tombstones from the re-pushed index), and a mid-run AUTH_FAILED must stop + * the per-book march instead of failing every remaining book identically. + */ + +const makeBook = (hash: string): Book => ({ + hash, + format: 'EPUB', + title: `Book ${hash}`, + sourceTitle: `Book ${hash}`, + author: 'Author', + createdAt: 1, + updatedAt: 100, +}); + +const authError = () => + new FileSyncError('Google Drive session expired; reconnect in Settings', 'AUTH_FAILED', 401); + +const makeStore = (overrides: Partial = {}): LocalStore => ({ + loadConfig: async (): Promise => ({ updatedAt: 50, booknotes: [] }), + saveBookConfig: async () => {}, + loadBookFile: async () => null, + resolveLocalBookPath: async () => null, + saveBookFile: async () => {}, + prepareLocalBookPath: async () => '/local/path', + loadBookCover: async () => null, + saveBookCover: async () => {}, + addBookToLibrary: async () => {}, + updateBookMetadata: async () => {}, + deleteBookLocally: async () => {}, + ...overrides, +}); + +const baseProvider = (overrides: Partial = {}): FileSyncProvider => ({ + rootPath: '/', + readText: vi.fn(async () => null), + readBinary: vi.fn(async () => new ArrayBuffer(8)), + head: vi.fn(async () => null), + list: vi.fn(async () => []), + writeText: vi.fn(async () => {}), + writeBinary: vi.fn(async () => {}), + ensureDir: vi.fn(async () => {}), + deleteDir: vi.fn(async () => {}), + ...overrides, +}); + +const syncOptions = { strategy: 'silent', syncBooks: false, deviceId: 'd1' } as const; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +describe('FileSyncEngine terminal auth failures', () => { + test('an unreadable index aborts the run before any push (expired session != first sync)', async () => { + const provider = baseProvider({ + readText: vi.fn(async (path: string) => { + if (path.endsWith('library.json')) throw authError(); + return null; + }), + }); + const engine = new FileSyncEngine(provider, makeStore()); + + await expect( + engine.syncLibrary([makeBook('h1'), makeBook('h2')], syncOptions), + ).rejects.toMatchObject({ code: 'AUTH_FAILED' }); + + expect(provider.writeText).not.toHaveBeenCalled(); + expect(provider.writeBinary).not.toHaveBeenCalled(); + }); + + test('a mid-run AUTH_FAILED stops the march and skips the index re-push', async () => { + const books = Array.from({ length: 50 }, (_, i) => makeBook(`h${i}`)); + const writeText = vi.fn(async (path: string) => { + if (path.endsWith('config.json')) throw authError(); + // Recording an index write is the failure we care about below. + }); + const provider = baseProvider({ writeText }); + const engine = new FileSyncEngine(provider, makeStore()); + + await expect(engine.syncLibrary(books, syncOptions)).rejects.toMatchObject({ + code: 'AUTH_FAILED', + }); + + // The pool must stop scheduling once the session is known-dead: far + // fewer attempts than the 50-book library (bounded by in-flight work). + expect(writeText.mock.calls.length).toBeLessThanOrEqual(8); + // No library.json write from a run that could not read or sync anything. + const indexWrites = writeText.mock.calls.filter(([p]) => String(p).endsWith('library.json')); + expect(indexWrites).toHaveLength(0); + }); + + test('non-auth per-book failures still do not abort the rest (one bad apple)', async () => { + const books = Array.from({ length: 10 }, (_, i) => makeBook(`h${i}`)); + const capture: { index?: RemoteLibraryIndex } = {}; + const writeText = vi.fn(async (path: string, body: string) => { + if (path.endsWith('config.json')) throw new FileSyncError('boom', 'NETWORK', 500); + if (path.endsWith('library.json')) capture.index = JSON.parse(body) as RemoteLibraryIndex; + }); + const provider = baseProvider({ writeText }); + const engine = new FileSyncEngine(provider, makeStore()); + + const result = await engine.syncLibrary(books, syncOptions); + + expect(result.failures).toBe(10); + // The run completed and the index was still re-pushed. + expect(capture.index).toBeDefined(); + }); + + test('an absent index (404 -> null) keeps first-sync semantics', async () => { + const capture: { index?: RemoteLibraryIndex } = {}; + const writeText = vi.fn(async (path: string, body: string) => { + if (path.endsWith('library.json')) capture.index = JSON.parse(body) as RemoteLibraryIndex; + }); + const provider = baseProvider({ writeText }); + const engine = new FileSyncEngine(provider, makeStore()); + + const result = await engine.syncLibrary([makeBook('h1')], syncOptions); + + expect(result.failures).toBe(0); + expect(capture.index?.books.map((b) => b.hash)).toEqual(['h1']); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/engine-deletion-sync.test.ts b/apps/readest-app/src/__tests__/services/sync/file/engine-deletion-sync.test.ts new file mode 100644 index 000000000..a24b2249e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/engine-deletion-sync.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test, vi } from 'vitest'; + +import type { Book } from '@/types/book'; +import { FileSyncEngine } from '@/services/sync/file/engine'; +import type { FileSyncProvider } from '@/services/sync/file/provider'; +import type { LocalStore } from '@/services/sync/file/localStore'; +import type { RemoteLibraryIndex } from '@/services/sync/file/wire'; + +/** + * #4860: WebDAV deletions must propagate. Three behaviours are exercised here: + * 1. a peer's tombstone deletes the book locally (edit-wins-over-delete guard), + * 2. the deleted book's remote hash directory is GC'd off the server, + * 3. a tombstone for a book this device never had survives the index re-push + * (otherwise the deletion would silently vanish from library.json). + */ + +const makeBook = (hash: string, overrides: Partial = {}): Book => ({ + hash, + format: 'EPUB', + title: `Book ${hash}`, + sourceTitle: `Book ${hash}`, + author: 'A', + createdAt: 1, + updatedAt: 1, + ...overrides, +}); + +type Captured = { writes: { path: string; body: string }[]; deletedDirs: string[] }; + +const fakeProvider = ( + opts: Partial & { captured?: Captured } = {}, +): FileSyncProvider => ({ + rootPath: '/', + readText: opts.readText ?? (async () => null), + readBinary: opts.readBinary ?? (async () => null), + head: opts.head ?? (async () => null), + list: opts.list ?? (async () => []), + writeText: + opts.writeText ?? + (async (path: string, body: string) => { + opts.captured?.writes.push({ path, body }); + }), + writeBinary: opts.writeBinary ?? (async () => {}), + ensureDir: opts.ensureDir ?? (async () => {}), + deleteDir: + opts.deleteDir ?? + (async (path: string) => { + opts.captured?.deletedDirs.push(path); + }), + uploadStream: opts.uploadStream, + downloadStream: opts.downloadStream, +}); + +const fakeStore = (opts: Partial = {}): LocalStore => ({ + loadConfig: opts.loadConfig ?? (async () => null), + saveBookConfig: opts.saveBookConfig ?? (async () => {}), + loadBookFile: opts.loadBookFile ?? (async () => null), + resolveLocalBookPath: opts.resolveLocalBookPath ?? (async () => null), + saveBookFile: opts.saveBookFile ?? (async () => {}), + prepareLocalBookPath: opts.prepareLocalBookPath ?? (async () => '/local/dst'), + loadBookCover: opts.loadBookCover ?? (async () => null), + saveBookCover: opts.saveBookCover ?? (async () => {}), + addBookToLibrary: opts.addBookToLibrary ?? (async () => {}), + updateBookMetadata: opts.updateBookMetadata ?? (async () => {}), + deleteBookLocally: opts.deleteBookLocally ?? (async () => {}), +}); + +const makeIndex = (books: Book[]): RemoteLibraryIndex => ({ + schemaVersion: 1, + updatedAt: 1, + books, +}); + +const libraryWrite = (captured: Captured) => + captured.writes.find((w) => w.path.endsWith('library.json')); + +describe('FileSyncEngine.syncLibrary — deletion propagation (#4860)', () => { + test('deletes a book locally when a peer tombstoned it', async () => { + const captured: Captured = { writes: [], deletedDirs: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100, deletedAt: 200 })])) + : null, + captured, + }); + const deleteBookLocally = vi.fn<(b: Book) => Promise>(async () => {}); + const store = fakeStore({ deleteBookLocally }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + { strategy: 'silent', syncBooks: false, deviceId: 'd' }, + ); + + expect(deleteBookLocally).toHaveBeenCalledTimes(1); + expect(deleteBookLocally.mock.calls[0]![0].hash).toBe('h1'); + expect(deleteBookLocally.mock.calls[0]![0].deletedAt).toBe(200); + expect(res.booksDeleted).toBe(1); + + // The tombstone must be carried into the re-pushed index. + const idx = libraryWrite(captured); + expect(idx).toBeDefined(); + const parsed = JSON.parse(idx!.body) as RemoteLibraryIndex; + const h1 = parsed.books.find((b) => b.hash === 'h1'); + expect(h1?.deletedAt).toBe(200); + }); + + test('does not re-push the config of a book it just peer-deleted', async () => { + const captured: Captured = { writes: [], deletedDirs: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100, deletedAt: 200 })])) + : p.endsWith('config.json') + ? JSON.stringify({ schemaVersion: 1, bookHash: 'h1', config: {}, booknotes: [] }) + : null, + captured, + }); + // The book file is still on disk after a 'local' delete removes the copy, so + // loadConfig returns a real config — the push loop must still skip it. + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile: async () => ({ bytes: new ArrayBuffer(8), size: 8 }), + }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + { strategy: 'silent', syncBooks: true, deviceId: 'd' }, + ); + + expect(res.booksDeleted).toBe(1); + expect(res.configsUploaded).toBe(0); + expect(res.filesUploaded).toBe(0); + expect(captured.writes.filter((w) => w.path.endsWith('config.json'))).toHaveLength(0); + }); + + test('does not delete locally when the local copy was edited after the deletion', async () => { + const captured: Captured = { writes: [], deletedDirs: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 50, deletedAt: 100 })])) + : null, + captured, + }); + const deleteBookLocally = vi.fn<(b: Book) => Promise>(async () => {}); + const store = fakeStore({ deleteBookLocally }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + // Local edit (updatedAt 200) is newer than the remote deletion (100). + [makeBook('h1', { updatedAt: 200 })], + { strategy: 'silent', syncBooks: false, deviceId: 'd' }, + ); + + expect(deleteBookLocally).not.toHaveBeenCalled(); + expect(res.booksDeleted).toBe(0); + }); + + test('GCs the remote hash directory of a locally-deleted book', async () => { + const captured: Captured = { writes: [], deletedDirs: [] }; + const provider = fakeProvider({ + readText: async () => null, // fresh remote index + list: async (path: string) => + path.endsWith('/books') + ? [{ name: 'h1', path: '/Readest/books/h1', isDirectory: true }] + : [], + captured, + }); + const store = fakeStore(); + + await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100, deletedAt: 100 })], + { strategy: 'silent', syncBooks: true, deviceId: 'd' }, + ); + + expect(captured.deletedDirs).toContain('/Readest/books/h1'); + }); + + test('does not GC a hash dir that is no longer on the server', async () => { + const captured: Captured = { writes: [], deletedDirs: [] }; + const provider = fakeProvider({ + readText: async () => null, + list: async () => [], // the books dir is empty — nothing to GC + captured, + }); + const store = fakeStore(); + + await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100, deletedAt: 100 })], + { strategy: 'silent', syncBooks: true, deviceId: 'd' }, + ); + + expect(captured.deletedDirs).toHaveLength(0); + }); + + test('preserves a remote tombstone for a book this device never had', async () => { + const captured: Captured = { writes: [], deletedDirs: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify( + makeIndex([ + makeBook('h1', { updatedAt: 100 }), + makeBook('h2', { updatedAt: 100, deletedAt: 300 }), + ]), + ) + : null, + captured, + }); + const store = fakeStore(); + + // Local library only has h1; it has never seen h2. h1 is locally newer so + // the run is dirty and the index gets re-pushed — the union must carry + // h2's tombstone even though this device never materialised the book. + await new FileSyncEngine(provider, store).syncLibrary([makeBook('h1', { updatedAt: 200 })], { + strategy: 'silent', + syncBooks: false, + deviceId: 'd', + }); + + const idx = libraryWrite(captured); + expect(idx).toBeDefined(); + const parsed = JSON.parse(idx!.body) as RemoteLibraryIndex; + const h2 = parsed.books.find((b) => b.hash === 'h2'); + expect(h2?.deletedAt).toBe(300); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/engine-metadata-sync.test.ts b/apps/readest-app/src/__tests__/services/sync/file/engine-metadata-sync.test.ts new file mode 100644 index 000000000..5eb3c1d46 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/engine-metadata-sync.test.ts @@ -0,0 +1,367 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { Book, BookConfig, BookNote } from '@/types/book'; +import { FileSyncEngine } from '@/services/sync/file/engine'; +import type { FileSyncProvider } from '@/services/sync/file/provider'; +import type { LocalStore } from '@/services/sync/file/localStore'; +import type { RemoteBookConfig, RemoteLibraryIndex } from '@/services/sync/file/wire'; + +/** + * Behavior-preservation gate for the WebDAV→FileSyncEngine port (originally + * webdav-metadata-sync.test.ts for issue #4756). Instead of mocking the + * WebDAV transport client, we drive `FileSyncEngine` with a fake + * `FileSyncProvider` (routed by path) and a fake `LocalStore`, asserting the + * same last-writer-wins reconciliation on `book.updatedAt` and the same + * pull-merge-before-push discipline. + */ + +const makeLocalBook = (overrides: Partial = {}): Book => ({ + hash: 'h1', + format: 'EPUB', + title: 'Old Title', + sourceTitle: 'Old Title', + author: 'Old Author', + createdAt: 1, + updatedAt: 100, + ...overrides, +}); + +const makeRemoteIndex = (book: Book, updatedAt = book.updatedAt): RemoteLibraryIndex => ({ + schemaVersion: 1, + updatedAt, + books: [book], +}); + +const makeNote = (id: string, updatedAt: number): BookNote => ({ + id, + type: 'annotation', + cfi: `cfi-${id}`, + note: '', + createdAt: updatedAt, + updatedAt, +}); + +const makeRemoteConfig = (overrides: Partial = {}): RemoteBookConfig => ({ + schemaVersion: 1, + bookHash: 'h1', + config: { updatedAt: 100 }, + booknotes: [], + writerDeviceId: 'mobile', + writerVersion: 'readest-webdav-1', + updatedAt: 100, + ...overrides, +}); + +/** + * A fake provider routed by path: readText resolves the index for + * library.json and the supplied envelope for config.json. writeText captures + * whichever artefact a test cares about. No streaming methods, so the engine + * uses the buffered path (and the store's null loaders skip uploads). + */ +const makeProvider = ( + index: RemoteLibraryIndex | null, + remoteConfig: RemoteBookConfig | null, + capture: { index?: RemoteLibraryIndex | null; config?: RemoteBookConfig | null }, +): FileSyncProvider => ({ + rootPath: '/', + readText: vi.fn(async (path: string) => { + if (path.endsWith('library.json')) return index ? JSON.stringify(index) : null; + if (path.endsWith('config.json')) return remoteConfig ? JSON.stringify(remoteConfig) : null; + return null; + }), + readBinary: vi.fn(async () => new ArrayBuffer(8)), + head: vi.fn(async () => null), + list: vi.fn(async () => []), + writeText: vi.fn(async (path: string, body: string) => { + if (path.endsWith('library.json')) capture.index = JSON.parse(body) as RemoteLibraryIndex; + if (path.endsWith('config.json')) capture.config = JSON.parse(body) as RemoteBookConfig; + }), + writeBinary: vi.fn(async () => {}), + ensureDir: vi.fn(async () => {}), + deleteDir: vi.fn(async () => {}), +}); + +const makeStore = (overrides: Partial = {}): LocalStore => ({ + loadConfig: async (): Promise => ({ updatedAt: 50, booknotes: [] }), + saveBookConfig: async () => {}, + loadBookFile: async () => null, + resolveLocalBookPath: async () => null, + saveBookFile: async () => {}, + prepareLocalBookPath: async () => '/local/path', + loadBookCover: async () => null, + saveBookCover: async () => {}, + addBookToLibrary: async () => {}, + updateBookMetadata: async () => {}, + deleteBookLocally: async () => {}, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('FileSyncEngine metadata reconciliation (#4756)', () => { + test('pulls newer remote metadata for a book the device already has', async () => { + const local = makeLocalBook({ updatedAt: 100 }); + const remote = makeLocalBook({ title: 'New Title', author: 'New Author', updatedAt: 200 }); + const capture: { index?: RemoteLibraryIndex | null } = {}; + const provider = makeProvider(makeRemoteIndex(remote, 200), null, capture); + + const updateBookMetadata = vi.fn(async (_book: Book) => {}); + const saveBookCover = vi.fn(async (_book: Book, _bytes: ArrayBuffer) => {}); + const store = makeStore({ updateBookMetadata, saveBookCover }); + + const engine = new FileSyncEngine(provider, store); + const result = await engine.syncLibrary([local], { + strategy: 'silent', + syncBooks: false, + deviceId: 'pc-device', + }); + + expect(updateBookMetadata).toHaveBeenCalledTimes(1); + const merged = updateBookMetadata.mock.calls[0]![0]; + expect(merged.title).toBe('New Title'); + expect(merged.author).toBe('New Author'); + expect(result.metadataUpdated).toBe(1); + expect(saveBookCover).toHaveBeenCalledTimes(1); + + expect(capture.index).not.toBeNull(); + const indexedBook = capture.index!.books.find((b) => b.hash === 'h1')!; + expect(indexedBook.title).toBe('New Title'); + }); + + test('pulls newer remote group membership for a book the device already has (#4942)', async () => { + const local = makeLocalBook({ updatedAt: 100 }); + const remote = makeLocalBook({ groupId: 'g1', groupName: 'Sci-Fi', updatedAt: 200 }); + const capture: { index?: RemoteLibraryIndex | null } = {}; + const provider = makeProvider(makeRemoteIndex(remote, 200), null, capture); + + const updateBookMetadata = vi.fn(async (_book: Book) => {}); + const store = makeStore({ updateBookMetadata }); + + const engine = new FileSyncEngine(provider, store); + const result = await engine.syncLibrary([local], { + strategy: 'silent', + syncBooks: false, + deviceId: 'pc-device', + }); + + expect(updateBookMetadata).toHaveBeenCalledTimes(1); + const merged = updateBookMetadata.mock.calls[0]![0]; + expect(merged.groupId).toBe('g1'); + expect(merged.groupName).toBe('Sci-Fi'); + expect(result.metadataUpdated).toBe(1); + + const indexedBook = capture.index!.books.find((b) => b.hash === 'h1')!; + expect(indexedBook.groupId).toBe('g1'); + expect(indexedBook.groupName).toBe('Sci-Fi'); + }); + + test('pulls a peer readingStatus change even when local metadata is newer (#4634 semantics)', async () => { + // Peer marked the book Finished (status clock 250), then this device + // edited the title (book clock 300 > remote 200). Whole-book LWW alone + // would never apply the status change. + const local = makeLocalBook({ + title: 'Edited Locally', + readingStatus: 'reading', + readingStatusUpdatedAt: 100, + updatedAt: 300, + }); + const remote = makeLocalBook({ + title: 'Stale Remote Title', + readingStatus: 'finished', + readingStatusUpdatedAt: 250, + updatedAt: 200, + }); + const capture: { index?: RemoteLibraryIndex | null } = {}; + const provider = makeProvider(makeRemoteIndex(remote, 200), null, capture); + + const updateBookMetadata = vi.fn(async (_book: Book) => {}); + const store = makeStore({ updateBookMetadata }); + + const engine = new FileSyncEngine(provider, store); + const result = await engine.syncLibrary([local], { + strategy: 'silent', + syncBooks: false, + deviceId: 'pc-device', + }); + + expect(updateBookMetadata).toHaveBeenCalledTimes(1); + const merged = updateBookMetadata.mock.calls[0]![0]; + expect(merged.readingStatus).toBe('finished'); + expect(merged.readingStatusUpdatedAt).toBe(250); + expect(merged.title).toBe('Edited Locally'); + expect(result.metadataUpdated).toBe(1); + + const indexedBook = capture.index!.books.find((b) => b.hash === 'h1')!; + expect(indexedBook.readingStatus).toBe('finished'); + expect(indexedBook.title).toBe('Edited Locally'); + }); + + test('pulls newer remote tags for a book the device already has', async () => { + const local = makeLocalBook({ tags: ['old'], updatedAt: 100 }); + const remote = makeLocalBook({ tags: ['sf', 'fav'], updatedAt: 200 }); + const capture: { index?: RemoteLibraryIndex | null } = {}; + const provider = makeProvider(makeRemoteIndex(remote, 200), null, capture); + + const updateBookMetadata = vi.fn(async (_book: Book) => {}); + const store = makeStore({ updateBookMetadata }); + + const engine = new FileSyncEngine(provider, store); + await engine.syncLibrary([local], { + strategy: 'silent', + syncBooks: false, + deviceId: 'pc-device', + }); + + expect(updateBookMetadata).toHaveBeenCalledTimes(1); + expect(updateBookMetadata.mock.calls[0]![0].tags).toEqual(['sf', 'fav']); + const indexedBook = capture.index!.books.find((b) => b.hash === 'h1')!; + expect(indexedBook.tags).toEqual(['sf', 'fav']); + }); + + test('does not overwrite local metadata when the local copy is newer', async () => { + const local = makeLocalBook({ title: 'Local Newer', updatedAt: 300 }); + const remote = makeLocalBook({ title: 'Remote Older', updatedAt: 200 }); + const capture: { index?: RemoteLibraryIndex | null } = {}; + const provider = makeProvider(makeRemoteIndex(remote, 200), null, capture); + + const updateBookMetadata = vi.fn(async (_book: Book) => {}); + const store = makeStore({ updateBookMetadata }); + + const engine = new FileSyncEngine(provider, store); + const result = await engine.syncLibrary([local], { + strategy: 'silent', + syncBooks: false, + deviceId: 'pc-device', + }); + + expect(updateBookMetadata).not.toHaveBeenCalled(); + expect(result.metadataUpdated).toBe(0); + const indexedBook = capture.index!.books.find((b) => b.hash === 'h1')!; + expect(indexedBook.title).toBe('Local Newer'); + }); +}); + +describe('FileSyncEngine soft-delete propagation', () => { + test('tombstones a soft-deleted book in the pushed index', async () => { + const deleted = makeLocalBook({ deletedAt: 500, updatedAt: 100 }); + const capture: { index?: RemoteLibraryIndex | null } = {}; + const provider = makeProvider(null, null, capture); + + const engine = new FileSyncEngine(provider, makeStore()); + await engine.syncLibrary([deleted], { + strategy: 'silent', + syncBooks: false, + deviceId: 'd1', + }); + + // The deletion travels to peers as a tombstone in library.json. + const indexed = capture.index!.books.find((b) => b.hash === 'h1')!; + expect(indexed.deletedAt).toBe(500); + }); + + test('does not re-download a soft-deleted book whose remote dir still exists', async () => { + const deleted = makeLocalBook({ deletedAt: 500, updatedAt: 100 }); + const capture: { index?: RemoteLibraryIndex | null } = {}; + const provider = makeProvider(null, null, capture); + // The GC sweep is separate, so the deleted book's hash dir + file still + // exist remotely. Passing the deleted book in (it stays in allBooksMap) + // must keep the discovery pass from re-adding it as a download candidate. + provider.list = vi.fn(async (path: string) => { + if (path.endsWith('/books')) + return [{ name: 'h1', path: '/Readest/books/h1', isDirectory: true }]; + if (path.endsWith('/h1')) + return [{ name: 'book.epub', path: '/Readest/books/h1/book.epub', isDirectory: false }]; + return []; + }); + const addBookToLibrary = vi.fn(async (_book: Book) => {}); + const store = makeStore({ addBookToLibrary }); + + const engine = new FileSyncEngine(provider, store); + const result = await engine.syncLibrary([deleted], { + strategy: 'silent', + syncBooks: false, + deviceId: 'd1', + }); + + expect(addBookToLibrary).not.toHaveBeenCalled(); + expect(result.booksDownloaded).toBe(0); + expect(capture.index!.books.find((b) => b.hash === 'h1')!.deletedAt).toBe(500); + }); +}); + +describe('FileSyncEngine config merge before push (Sync now must not blind-overwrite)', () => { + test('unions remote booknotes into the pushed config instead of clobbering them', async () => { + // Local book is newer than the index, so incremental includes it in the + // push set (the only case where merge-before-push matters); the metadata + // pass stays a no-op since the index copy isn't newer. + const local = makeLocalBook({ updatedAt: 200 }); + const capture: { config?: RemoteBookConfig | null } = {}; + const provider = makeProvider( + makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100), + makeRemoteConfig({ config: { updatedAt: 100 }, booknotes: [makeNote('remote-note', 100)] }), + capture, + ); + const store = makeStore({ + loadConfig: async (): Promise => ({ + updatedAt: 50, + booknotes: [makeNote('local-note', 50)], + }), + }); + + const engine = new FileSyncEngine(provider, store); + await engine.syncLibrary([local], { strategy: 'silent', syncBooks: false, deviceId: 'pc' }); + + expect(capture.config).not.toBeNull(); + const ids = capture.config!.booknotes.map((n) => n.id).sort(); + expect(ids).toEqual(['local-note', 'remote-note']); + }); + + test('does not regress newer remote progress with an older local push', async () => { + // Local newer than the index -> pushed under incremental. The remote + // config is nonetheless ahead on progress, so the pull-merge must carry the + // remote page, not regress it. + const local = makeLocalBook({ updatedAt: 200 }); + const capture: { config?: RemoteBookConfig | null } = {}; + const provider = makeProvider( + makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100), + makeRemoteConfig({ config: { updatedAt: 200, progress: [50, 100] }, booknotes: [] }), + capture, + ); + const store = makeStore({ + loadConfig: async (): Promise => ({ + updatedAt: 50, + progress: [10, 100], + booknotes: [], + }), + }); + + const engine = new FileSyncEngine(provider, store); + await engine.syncLibrary([local], { strategy: 'silent', syncBooks: false, deviceId: 'pc' }); + + expect(capture.config!.config.progress).toEqual([50, 100]); + }); + + test('send strategy keeps the blind push (local authoritative, no pull-merge)', async () => { + const local = makeLocalBook({ updatedAt: 100 }); + const capture: { config?: RemoteBookConfig | null } = {}; + const provider = makeProvider( + null, + makeRemoteConfig({ config: { updatedAt: 200 }, booknotes: [makeNote('remote-note', 200)] }), + capture, + ); + const store = makeStore({ + loadConfig: async (): Promise => ({ + updatedAt: 50, + booknotes: [makeNote('local-note', 50)], + }), + }); + + const engine = new FileSyncEngine(provider, store); + await engine.syncLibrary([local], { strategy: 'send', syncBooks: false, deviceId: 'pc' }); + + const ids = capture.config!.booknotes.map((n) => n.id); + expect(ids).toEqual(['local-note']); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/engine-sync-paths.test.ts b/apps/readest-app/src/__tests__/services/sync/file/engine-sync-paths.test.ts new file mode 100644 index 000000000..07a90df92 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/engine-sync-paths.test.ts @@ -0,0 +1,958 @@ +import { describe, expect, test, vi } from 'vitest'; + +import type { Book, BookConfig } from '@/types/book'; +import { FileSyncEngine } from '@/services/sync/file/engine'; +import type { FileSyncProvider } from '@/services/sync/file/provider'; +import type { LocalStore } from '@/services/sync/file/localStore'; +import type { RemoteBookConfig, RemoteLibraryIndex } from '@/services/sync/file/wire'; + +/** + * Coverage for the engine paths the behavior-preservation gate + * (engine-metadata-sync) does not execute: streaming upload + HEAD + * short-circuit, remote-only discovery -> streaming download -> addBook, and + * the receive (pull-only) strategy. These carry the OOM-avoidance and + * idempotency value of the original WebDAV sync. + */ + +const makeBook = (hash: string, overrides: Partial = {}): Book => ({ + hash, + format: 'EPUB', + title: `Book ${hash}`, + sourceTitle: `Book ${hash}`, + author: 'A', + createdAt: 1, + updatedAt: 1, + ...overrides, +}); + +type Captured = { writes: { path: string; body: string }[] }; + +const fakeProvider = ( + opts: Partial & { captured?: Captured } = {}, +): FileSyncProvider => ({ + rootPath: '/', + readText: opts.readText ?? (async () => null), + readBinary: opts.readBinary ?? (async () => new ArrayBuffer(8)), + head: opts.head ?? (async () => null), + list: opts.list ?? (async () => []), + writeText: + opts.writeText ?? + (async (path: string, body: string) => { + opts.captured?.writes.push({ path, body }); + }), + writeBinary: opts.writeBinary ?? (async () => {}), + ensureDir: opts.ensureDir ?? (async () => {}), + deleteDir: opts.deleteDir ?? (async () => {}), + uploadStream: opts.uploadStream, + downloadStream: opts.downloadStream, +}); + +const fakeStore = (opts: Partial = {}): LocalStore => ({ + loadConfig: opts.loadConfig ?? (async () => null), + saveBookConfig: opts.saveBookConfig ?? (async () => {}), + loadBookFile: opts.loadBookFile ?? (async () => null), + resolveLocalBookPath: opts.resolveLocalBookPath ?? (async () => null), + saveBookFile: opts.saveBookFile ?? (async () => {}), + prepareLocalBookPath: opts.prepareLocalBookPath ?? (async () => '/local/dst'), + loadBookCover: opts.loadBookCover ?? (async () => null), + saveBookCover: opts.saveBookCover ?? (async () => {}), + addBookToLibrary: opts.addBookToLibrary ?? (async () => {}), + updateBookMetadata: opts.updateBookMetadata ?? (async () => {}), + deleteBookLocally: opts.deleteBookLocally ?? (async () => {}), +}); + +describe('FileSyncEngine.pushBookFile — streaming upload', () => { + test('streams via provider.uploadStream when remote is missing', async () => { + const uploadStream = vi.fn(async () => true); + const provider = fakeProvider({ head: async () => null, uploadStream }); + const store = fakeStore({ + resolveLocalBookPath: async () => ({ path: '/local/x.epub', size: 100 }), + }); + + const res = await new FileSyncEngine(provider, store).pushBookFile(makeBook('h1')); + + expect(res).toEqual({ uploaded: true }); + expect(uploadStream).toHaveBeenCalledTimes(1); + expect(uploadStream).toHaveBeenCalledWith( + expect.stringContaining('/Readest/books/h1/'), + '/local/x.epub', + ); + }); + + test('HEAD size match short-circuits without uploading', async () => { + const uploadStream = vi.fn(async () => true); + const provider = fakeProvider({ head: async () => ({ size: 100 }), uploadStream }); + const store = fakeStore({ + resolveLocalBookPath: async () => ({ path: '/local/x.epub', size: 100 }), + }); + + const res = await new FileSyncEngine(provider, store).pushBookFile(makeBook('h1')); + + expect(res).toEqual({ uploaded: false, reason: 'remote-matches' }); + expect(uploadStream).not.toHaveBeenCalled(); + }); + + test('retries the stream once before failing', async () => { + const uploadStream = vi + .fn<(remotePath: string, localPath: string) => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const provider = fakeProvider({ head: async () => null, uploadStream }); + const store = fakeStore({ + resolveLocalBookPath: async () => ({ path: '/local/x.epub', size: 100 }), + }); + + const res = await new FileSyncEngine(provider, store).pushBookFile(makeBook('h1')); + + expect(res).toEqual({ uploaded: true }); + expect(uploadStream).toHaveBeenCalledTimes(2); + }); +}); + +describe('FileSyncEngine.syncLibrary — remote discovery + streaming download', () => { + test('discovers a remote-only book, streams it down, and adds it to the library', async () => { + const downloadStream = vi.fn(async () => true); + const provider = fakeProvider({ + list: async (path: string) => + path.endsWith('/books') + ? [{ name: 'h2', path: '/Readest/books/h2', isDirectory: true }] + : [ + { + name: 'Remote.epub', + path: '/Readest/books/h2/Remote.epub', + isDirectory: false, + size: 50, + }, + ], + downloadStream, + }); + const addBookToLibrary = vi.fn<(book: Book) => Promise>(async () => {}); + const prepareLocalBookPath = vi.fn(async () => '/local/h2/Remote.epub'); + const store = fakeStore({ addBookToLibrary, prepareLocalBookPath }); + + const res = await new FileSyncEngine(provider, store).syncLibrary([], { + strategy: 'silent', + syncBooks: false, + deviceId: 'd', + }); + + expect(downloadStream).toHaveBeenCalledWith( + '/Readest/books/h2/Remote.epub', + '/local/h2/Remote.epub', + ); + expect(addBookToLibrary).toHaveBeenCalledTimes(1); + expect(addBookToLibrary.mock.calls[0]![0].hash).toBe('h2'); + expect(res.booksDownloaded).toBe(1); + }); +}); + +// The explicit per-book Download action (Book Details / bookshelf cloud +// button) routed to a third-party provider: fetch the binary from the hash +// dir (filename resolved by listing — titles go stale), plus cover + config +// best-effort. +describe('FileSyncEngine.downloadBookFile', () => { + const hashDirListing = (withFile: boolean) => async (p: string) => { + if (!p.endsWith('/books/h1')) return []; + const entries = [ + { name: 'config.json', path: '/Readest/books/h1/config.json', isDirectory: false, size: 1 }, + { name: 'cover.png', path: '/Readest/books/h1/cover.png', isDirectory: false, size: 2 }, + ]; + if (withFile) + entries.push({ + name: 'B.epub', + path: '/Readest/books/h1/B.epub', + isDirectory: false, + size: 9, + }); + return entries; + }; + + test('downloads the book file into the local store', async () => { + const saveBookFile = vi.fn(async () => {}); + const provider = fakeProvider({ + list: hashDirListing(true), + readBinary: async () => new ArrayBuffer(9), + }); + const store = fakeStore({ saveBookFile }); + + const ok = await new FileSyncEngine(provider, store).downloadBookFile(makeBook('h1')); + + expect(ok).toBe(true); + expect(saveBookFile).toHaveBeenCalledTimes(1); + }); + + test('returns false when the remote holds no book file', async () => { + const saveBookFile = vi.fn(async () => {}); + const provider = fakeProvider({ list: hashDirListing(false) }); + const store = fakeStore({ saveBookFile }); + + const ok = await new FileSyncEngine(provider, store).downloadBookFile(makeBook('h1')); + + expect(ok).toBe(false); + expect(saveBookFile).not.toHaveBeenCalled(); + }); + + test('prefers the streaming downloader when available', async () => { + const downloadStream = vi.fn(async () => true); + const prepareLocalBookPath = vi.fn(async () => '/local/h1/B.epub'); + const provider = fakeProvider({ list: hashDirListing(true), downloadStream }); + const store = fakeStore({ prepareLocalBookPath }); + + const ok = await new FileSyncEngine(provider, store).downloadBookFile(makeBook('h1')); + + expect(ok).toBe(true); + expect(downloadStream).toHaveBeenCalledWith('/Readest/books/h1/B.epub', '/local/h1/B.epub'); + }); +}); + +describe('FileSyncEngine.syncLibrary — receive strategy is pull-only', () => { + test('never writes (no config push, no index re-push) under receive', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ captured }); + const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) }); + + const res = await new FileSyncEngine(provider, store).syncLibrary([makeBook('h1')], { + strategy: 'receive', + syncBooks: true, + deviceId: 'd', + }); + + expect(captured.writes).toHaveLength(0); + expect(res.configsUploaded).toBe(0); + expect(res.filesUploaded).toBe(0); + }); +}); + +const makeIndex = (books: Book[], uploadedHashes?: string[]): RemoteLibraryIndex => ({ + schemaVersion: 1, + updatedAt: 1, + books, + ...(uploadedHashes ? { uploadedHashes } : {}), +}); + +const makeEnvelope = (over: Partial = {}): RemoteBookConfig => ({ + schemaVersion: 1, + bookHash: 'h1', + config: { updatedAt: 100 }, + booknotes: [], + writerDeviceId: 'peer', + writerVersion: 'readest-webdav-1', + updatedAt: 100, + ...over, +}); + +const configWrites = (captured: Captured) => + captured.writes.filter((w) => w.path.endsWith('config.json')); + +describe('FileSyncEngine.syncLibrary — incremental diff (default)', () => { + test('skips a book whose updatedAt matches the remote index', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + captured, + }); + const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + { + strategy: 'silent', + syncBooks: false, + deviceId: 'd', + }, + ); + + expect(configWrites(captured)).toHaveLength(0); + expect(res.configsUploaded).toBe(0); + expect(res.booksSynced).toBe(0); + // Nothing changed, so the byte-identical index is NOT re-pushed — a + // restamped copy would only churn the remote and defeat every other + // device's change detection. + expect(captured.writes.some((w) => w.path.endsWith('library.json'))).toBe(false); + }); + + test('pushes a book that is newer locally than the index', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + captured, + }); + const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 200 })], + { + strategy: 'silent', + syncBooks: false, + deviceId: 'd', + }, + ); + + expect(res.configsUploaded).toBe(1); + expect(res.booksSynced).toBe(1); + expect(configWrites(captured)).toHaveLength(1); + }); + + test('pulls config + metadata for a book newer in the index', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ + readText: async (p) => { + if (p.endsWith('library.json')) + return JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 200, title: 'Remote' })])); + if (p.endsWith('config.json')) + return JSON.stringify(makeEnvelope({ config: { updatedAt: 200, progress: [9, 10] } })); + return null; + }, + captured, + }); + const saveBookConfig = vi.fn(async () => {}); + const updateBookMetadata = vi.fn(async () => {}); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + saveBookConfig, + updateBookMetadata, + }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + { + strategy: 'silent', + syncBooks: false, + deviceId: 'd', + }, + ); + + expect(res.metadataUpdated).toBe(1); + expect(res.configsDownloaded).toBe(1); + expect(res.booksSynced).toBe(1); + expect(saveBookConfig).toHaveBeenCalledTimes(1); + // Remote is newer, so the book is NOT in the push set — no config.json PUT. + expect(configWrites(captured)).toHaveLength(0); + }); + + // #4856: enabling "Upload Book Files" AFTER the first (config-only) sync must + // upload the file even though the book's config is already in sync with the + // index (its updatedAt is unchanged, so the incremental cursor would skip it). + test('uploads the file when syncBooks is enabled after a config-only first sync', async () => { + const captured: Captured = { writes: [] }; + const binaryWrites: string[] = []; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + head: async () => null, // no book file on the remote yet + writeBinary: async (path: string) => { + binaryWrites.push(path); + }, + captured, + }); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile: async () => ({ bytes: new ArrayBuffer(10), size: 10 }), + }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100, downloadedAt: 1 })], + { + strategy: 'silent', + syncBooks: true, + deviceId: 'd', + }, + ); + + expect(res.filesUploaded).toBe(1); + expect(binaryWrites.some((p) => p.includes('/Readest/books/h1/'))).toBe(true); + // The config is already in sync, so it must NOT be re-pushed. + expect(configWrites(captured)).toHaveLength(0); + // The upload is recorded so the next incremental sync skips the probe. + const idx = JSON.parse( + captured.writes.find((w) => w.path.endsWith('library.json'))!.body, + ) as RemoteLibraryIndex; + expect(idx.uploadedHashes).toContain('h1'); + }); + + test('skips the file when the remote already has a same-size copy (syncBooks on)', async () => { + const captured: Captured = { writes: [] }; + const binaryWrites: string[] = []; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + head: async () => ({ size: 10 }), // remote already has the file, same size + writeBinary: async (path: string) => { + binaryWrites.push(path); + }, + captured, + }); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile: async () => ({ bytes: new ArrayBuffer(10), size: 10 }), + }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100, downloadedAt: 1 })], + { + strategy: 'silent', + syncBooks: true, + deviceId: 'd', + }, + ); + + expect(res.filesUploaded).toBe(0); + expect(res.filesAlreadyInSync).toBe(1); + expect(binaryWrites).toHaveLength(0); + expect(configWrites(captured)).toHaveLength(0); + // The freshly-verified file is now recorded so the next sync skips it. + const idx = JSON.parse( + captured.writes.find((w) => w.path.endsWith('library.json'))!.body, + ) as RemoteLibraryIndex; + expect(idx.uploadedHashes).toContain('h1'); + }); + + // A device that holds no local copy of a book (e.g. web with a cloud-only + // library) can never upload it, so the remote probe buys nothing — and at + // library scale it is a full per-book request storm on every sync. The + // no-source verdict must be reached from local state alone, with zero + // remote traffic; the device that does hold the bytes uploads and records. + test('spends no remote request on a no-source book', async () => { + const captured: Captured = { writes: [] }; + const head = vi.fn(async () => ({ size: 10 })); + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + head, + captured, + }); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile: async () => null, // no local copy on this device + }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + { strategy: 'silent', syncBooks: true, deviceId: 'd' }, + ); + + expect(head).not.toHaveBeenCalledWith(expect.stringContaining('/books/')); + expect(res.filesUploaded).toBe(0); + // Nothing recorded and nothing changed: the index is not re-pushed. + expect(captured.writes.some((w) => w.path.endsWith('library.json'))).toBe(false); + }); + + test('leaves a no-source book unrecorded when the remote lacks it too', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + head: async () => null, // not on the remote either + captured, + }); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile: async () => null, // no local copy on this device + }); + + await new FileSyncEngine(provider, store).syncLibrary([makeBook('h1', { updatedAt: 100 })], { + strategy: 'silent', + syncBooks: true, + deviceId: 'd', + }); + + // A device that does have the file must still be able to upload and + // record it later: nothing was recorded (no index write at all). + expect(captured.writes.some((w) => w.path.endsWith('library.json'))).toBe(false); + }); + + // #4856 perf: once a file is recorded in the index, an incremental sync must + // NOT HEAD-probe it again — the steady state stays O(changed), not O(library). + test('does not probe an already-recorded file (stays O(changed))', async () => { + const captured: Captured = { writes: [] }; + const head = vi.fn(async () => null); + const loadBookFile = vi.fn(async () => ({ bytes: new ArrayBuffer(10), size: 10 })); + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })], ['h1'])) + : null, + head, + captured, + }); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile, + }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + { strategy: 'silent', syncBooks: true, deviceId: 'd' }, + ); + + // No file work at all: no HEAD probe on the book, no bytes read, no upload. + expect(head).not.toHaveBeenCalledWith(expect.stringContaining('/books/')); + expect(loadBookFile).not.toHaveBeenCalled(); + expect(res.filesUploaded).toBe(0); + expect(res.filesAlreadyInSync).toBe(0); + expect(res.booksSynced).toBe(0); + // Nothing changed, so the index (and its record) is left untouched. + expect(captured.writes.some((w) => w.path.endsWith('library.json'))).toBe(false); + }); + + test('fullSync re-probes a recorded file (drift escape hatch)', async () => { + const head = vi.fn(async () => ({ size: 10 })); + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })], ['h1'])) + : null, + head, + captured: { writes: [] }, + }); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile: async () => ({ bytes: new ArrayBuffer(10), size: 10 }), + }); + + await new FileSyncEngine(provider, store).syncLibrary([makeBook('h1', { updatedAt: 100 })], { + strategy: 'silent', + syncBooks: true, + deviceId: 'd', + fullSync: true, + }); + + // Full Sync bypasses the record and re-verifies the file. + expect(head).toHaveBeenCalled(); + }); + + test('fullSync re-pushes an in-sync book', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + captured, + }); + const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) }); + + const res = await new FileSyncEngine(provider, store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + { + strategy: 'silent', + syncBooks: false, + deviceId: 'd', + fullSync: true, + }, + ); + + expect(res.configsUploaded).toBe(1); + expect(configWrites(captured)).toHaveLength(1); + }); +}); + +describe('FileSyncEngine.syncLibrary — bounded concurrency', () => { + const runWithConcurrency = async (concurrency: number | undefined, bookCount: number) => { + let inFlight = 0; + let maxInFlight = 0; + const loadConfig = async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight -= 1; + return { updatedAt: 1, booknotes: [] }; + }; + const books = Array.from({ length: bookCount }, (_, i) => + makeBook(`h${i}`, { updatedAt: 100 }), + ); + // No remote index -> every book is local-only -> all pushed. + const provider = fakeProvider({ captured: { writes: [] } }); + const store = fakeStore({ loadConfig }); + const res = await new FileSyncEngine(provider, store).syncLibrary(books, { + strategy: 'silent', + syncBooks: false, + deviceId: 'd', + concurrency, + }); + return { res, maxInFlight }; + }; + + test('caps in-flight work at the configured concurrency', async () => { + const { res, maxInFlight } = await runWithConcurrency(3, 8); + expect(res.configsUploaded).toBe(8); + expect(res.booksSynced).toBe(8); + expect(maxInFlight).toBe(3); + }); + + test('defaults to 4 when concurrency is omitted', async () => { + const { res, maxInFlight } = await runWithConcurrency(undefined, 8); + expect(res.configsUploaded).toBe(8); + expect(maxInFlight).toBe(4); + }); + + test('concurrency 1 runs strictly sequentially', async () => { + const { res, maxInFlight } = await runWithConcurrency(1, 4); + expect(res.configsUploaded).toBe(4); + expect(maxInFlight).toBe(1); + }); +}); + +// The idle-run request budget: a sync where nothing changed on either side +// must cost ONE metadata stat — no index download, no discovery listing, no +// index re-push. The remote etag (Drive md5 / WebDAV ETag) is the change +// signal: every peer mutation rewrites library.json, so an unchanged etag +// means no remote-side news. The cache is keyed on the (memoised) provider, +// so it survives across engine builds within a session. +describe('FileSyncEngine.syncLibrary — remote change detection', () => { + const opts = { strategy: 'silent', syncBooks: false, deviceId: 'd' } as const; + const indexWrites = (captured: Captured) => + captured.writes.filter((w) => w.path.endsWith('library.json')); + + const makeChangeProbeHarness = (etagOf: () => string) => { + const captured: Captured = { writes: [] }; + const readText = vi.fn(async (p: string) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + ); + const head = vi.fn(async (p: string) => + p.endsWith('library.json') ? { etag: etagOf() } : null, + ); + const list = vi.fn(async () => []); + const provider = fakeProvider({ readText, head, list, captured }); + const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) }); + return { captured, readText, head, list, provider, store }; + }; + + test('an unchanged remote index short-circuits: no pull, no discovery, no push', async () => { + const h = makeChangeProbeHarness(() => 'E1'); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + opts, + ); + expect(h.readText).toHaveBeenCalledTimes(1); // first run pulls + expect(h.list).toHaveBeenCalled(); // and discovers + + h.readText.mockClear(); + h.list.mockClear(); + // Fresh engine, same provider — mirrors one engine build per sync run. + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + opts, + ); + expect(h.readText).not.toHaveBeenCalled(); + expect(h.list).not.toHaveBeenCalled(); + expect(indexWrites(h.captured)).toHaveLength(0); // clean on both runs + }); + + test('a local change under an unchanged remote index pushes without re-pulling', async () => { + const h = makeChangeProbeHarness(() => 'E1'); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + opts, + ); + + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 200 })], + opts, + ); + // The index itself was never re-pulled (the push loop's config pull-merge + // reads config.json, which also goes through readText). + const libraryReads = h.readText.mock.calls.filter((c) => String(c[0]).endsWith('library.json')); + expect(libraryReads).toHaveLength(1); + expect(configWrites(h.captured)).toHaveLength(1); + expect(indexWrites(h.captured)).toHaveLength(1); + }); + + test('a changed remote etag re-pulls the index', async () => { + let etag = 'E1'; + const h = makeChangeProbeHarness(() => etag); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + opts, + ); + etag = 'E2'; + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100 })], + opts, + ); + expect(h.readText).toHaveBeenCalledTimes(2); + }); + + test('a local tombstone missing from the remote index still pushes the index', async () => { + const h = makeChangeProbeHarness(() => 'E1'); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100, deletedAt: 150 })], + opts, + ); + // No per-book counters fire for a tombstone, but the index MUST publish it. + const idx = JSON.parse(indexWrites(h.captured)[0]!.body) as RemoteLibraryIndex; + expect(idx.books.find((b) => b.hash === 'h1')?.deletedAt).toBe(150); + }); +}); + +// File-less hash dirs (config/cover only — legacy junk or peers that sync +// without "Upload Book Files") used to be re-listed by discovery on every +// run, forever. They are now recorded in the index once inspected and only +// re-checked when the index says their file arrived (uploadedHashes), or on +// a Full Sync. +describe('FileSyncEngine.syncLibrary — empty-dir record', () => { + const opts = { strategy: 'silent', syncBooks: false, deviceId: 'd' } as const; + const orphanListing = (withFile: boolean) => async (p: string) => { + if (p.endsWith('/books')) return [{ name: 'h9', path: '/Readest/books/h9', isDirectory: true }]; + const entries = [ + { name: 'config.json', path: '/Readest/books/h9/config.json', isDirectory: false, size: 1 }, + ]; + if (withFile) + entries.push({ + name: 'B.epub', + path: '/Readest/books/h9/B.epub', + isDirectory: false, + size: 9, + }); + return entries; + }; + + test('records an inspected file-less dir in the index', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h9', { updatedAt: 100 })])) + : null, + list: orphanListing(false), + captured, + }); + const res = await new FileSyncEngine(provider, fakeStore()).syncLibrary([], opts); + + expect(res.booksDownloaded).toBe(0); + const idx = JSON.parse( + captured.writes.find((w) => w.path.endsWith('library.json'))!.body, + ) as RemoteLibraryIndex; + expect(idx.emptyDirs).toContain('h9'); + }); + + test('skips a recorded file-less dir on the next discovery', async () => { + const captured: Captured = { writes: [] }; + const list = vi.fn(orphanListing(false)); + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify({ + ...makeIndex([makeBook('h9', { updatedAt: 100 })]), + emptyDirs: ['h9'], + }) + : null, + list, + captured, + }); + await new FileSyncEngine(provider, fakeStore()).syncLibrary([], opts); + + expect(list).toHaveBeenCalledWith(expect.stringContaining('/books')); + expect(list).not.toHaveBeenCalledWith('/Readest/books/h9'); + // Nothing changed — the record survives by NOT re-pushing the index. + expect(captured.writes.some((w) => w.path.endsWith('library.json'))).toBe(false); + }); + + test('re-inspects a recorded dir once the index says its file arrived', async () => { + const captured: Captured = { writes: [] }; + const provider = fakeProvider({ + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify({ + ...makeIndex([makeBook('h9', { updatedAt: 100 })], ['h9']), + emptyDirs: ['h9'], + }) + : null, + list: orphanListing(true), + readBinary: async () => new ArrayBuffer(9), + captured, + }); + const addBookToLibrary = vi.fn(async () => {}); + const res = await new FileSyncEngine(provider, fakeStore({ addBookToLibrary })).syncLibrary( + [], + opts, + ); + + expect(res.booksDownloaded).toBe(1); + const idx = JSON.parse( + captured.writes.find((w) => w.path.endsWith('library.json'))!.body, + ) as RemoteLibraryIndex; + expect(idx.emptyDirs ?? []).not.toContain('h9'); + }); +}); + +// Row-as-truth: the library row is authoritative for local file presence +// (import / download / delete all stamp downloadedAt; merges never let a +// peer clobber it). A book the row marks as absent is skipped without a +// single filesystem or remote probe — incremental sync stays pure metadata +// diffing. Row-vs-filesystem split-brain is healed by Full Sync, which +// bypasses the gate and audits the real filesystem. +describe('FileSyncEngine.syncLibrary — row-as-truth local file gate', () => { + test('a book whose row marks no local file is never probed at all', async () => { + const head = vi.fn(async (p: string) => (p.endsWith('library.json') ? { etag: 'E9' } : null)); + const provider = fakeProvider({ + head, + readText: async (p) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + captured: { writes: [] }, + }); + const loadBookFile = vi.fn(async () => null); + const resolveLocalBookPath = vi.fn(async () => null); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile, + resolveLocalBookPath, + }); + + // No downloadedAt, no filePath: this device has never had the file. + await new FileSyncEngine(provider, store).syncLibrary([makeBook('h1', { updatedAt: 100 })], { + strategy: 'silent', + syncBooks: true, + deviceId: 'd', + }); + + expect(loadBookFile).not.toHaveBeenCalled(); + expect(resolveLocalBookPath).not.toHaveBeenCalled(); + expect(head).not.toHaveBeenCalledWith(expect.stringContaining('/books/')); + }); +}); + +// The plugin:fs|exists storm (Tauri): every sync run re-walked all books +// whose file is recorded nowhere, paying two local fs probes per book per +// run just to relearn "no local source". The verdict is now memoised per +// provider session, keyed to the book's updatedAt — an unchanged library +// costs zero local and zero remote probes on repeat runs; only a locally +// updated book, Full Sync, or a fresh session re-qualifies a probe. +describe('FileSyncEngine.syncLibrary — no-source probe memo', () => { + const opts = { strategy: 'silent', syncBooks: true, deviceId: 'd' } as const; + + const makeProbeHarness = () => { + const head = vi.fn(async (p: string) => (p.endsWith('library.json') ? { etag: 'E1' } : null)); + const readText = vi.fn(async (p: string) => + p.endsWith('library.json') + ? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })])) + : null, + ); + const provider = fakeProvider({ head, readText, captured: { writes: [] } }); + // Drifted row: downloadedAt claims a local file, the filesystem disagrees. + const loadBookFile = vi.fn(async () => null); + const resolveLocalBookPath = vi.fn(async () => null); + const store = fakeStore({ + loadConfig: async () => ({ updatedAt: 1, booknotes: [] }), + loadBookFile, + resolveLocalBookPath, + }); + return { provider, store, head, loadBookFile }; + }; + + test('an unchanged no-source book is probed once per provider session', async () => { + const h = makeProbeHarness(); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100, downloadedAt: 1 })], + opts, + ); + expect(h.loadBookFile).toHaveBeenCalledTimes(1); // first run learns the verdict + + // Fresh engine, same (memoised) provider — one engine build per sync run. + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100, downloadedAt: 1 })], + opts, + ); + expect(h.loadBookFile).toHaveBeenCalledTimes(1); // no local re-probe + expect(h.head).not.toHaveBeenCalledWith(expect.stringContaining('/books/')); // no remote probe + }); + + test('a locally updated book is probed again', async () => { + const h = makeProbeHarness(); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100, downloadedAt: 1 })], + opts, + ); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 200, downloadedAt: 1 })], + opts, + ); + expect(h.loadBookFile).toHaveBeenCalledTimes(2); + }); + + test('fullSync bypasses the memo', async () => { + const h = makeProbeHarness(); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100, downloadedAt: 1 })], + opts, + ); + await new FileSyncEngine(h.provider, h.store).syncLibrary( + [makeBook('h1', { updatedAt: 100, downloadedAt: 1 })], + { ...opts, fullSync: true }, + ); + expect(h.loadBookFile).toHaveBeenCalledTimes(2); + }); +}); + +// The engine passes the FULL ancestor chain to ensureDir for every book, so a +// stateless provider (OneDrive create-folder, WebDAV MKCOL) re-creates the +// shared parents (/Readest, /Readest/books) on each book — a redundant round +// trip and a 409 "name already exists" flood at library scale. The engine now +// memoises ensured dirs (+ single-flights concurrent creates) for the session. +describe('FileSyncEngine — ensureDir session cache', () => { + const cfg = { updatedAt: 1, booknotes: [] } as unknown as BookConfig; + + test('creates each shared parent once across books', async () => { + const ensured: string[] = []; + const provider = fakeProvider({ + captured: { writes: [] }, + ensureDir: async (paths: string[]) => { + ensured.push(...paths); + }, + }); + const engine = new FileSyncEngine(provider, fakeStore()); + await engine.pushBookConfig(makeBook('h1'), cfg, 'd'); + await engine.pushBookConfig(makeBook('h2'), cfg, 'd'); + + // The shared parents are created exactly once despite two books... + expect(ensured.filter((d) => d === '/Readest')).toHaveLength(1); + expect(ensured.filter((d) => d === '/Readest/books')).toHaveLength(1); + // ...while each book's own hash dir is still created. + expect(ensured.filter((d) => d === '/Readest/books/h1')).toHaveLength(1); + expect(ensured.filter((d) => d === '/Readest/books/h2')).toHaveLength(1); + }); + + test('single-flights concurrent creates of the same shared parent', async () => { + let inFlightReadest = 0; + let maxInFlightReadest = 0; + const provider = fakeProvider({ + captured: { writes: [] }, + ensureDir: async (paths: string[]) => { + if (paths[0] === '/Readest') { + inFlightReadest += 1; + maxInFlightReadest = Math.max(maxInFlightReadest, inFlightReadest); + await new Promise((r) => setTimeout(r, 5)); + inFlightReadest -= 1; + } + }, + }); + const engine = new FileSyncEngine(provider, fakeStore()); + await Promise.all([ + engine.pushBookConfig(makeBook('h1'), cfg, 'd'), + engine.pushBookConfig(makeBook('h2'), cfg, 'd'), + engine.pushBookConfig(makeBook('h3'), cfg, 'd'), + ]); + + // Three book pushes race to ensure /Readest, but the in-flight lock collapses + // them to a single create. + expect(maxInFlightReadest).toBe(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/layout.test.ts b/apps/readest-app/src/__tests__/services/sync/file/layout.test.ts new file mode 100644 index 000000000..6fbb85db1 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/layout.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest'; +import { + buildBasePath, + buildBookDirPath, + buildBookConfigPath, + buildLibraryPath, + buildBookFilePath, + buildBookCoverPath, + ancestorsOf, + normalizeRoot, +} from '@/services/sync/file/layout'; +import type { Book } from '@/types/book'; + +const book = { + hash: 'h1', + format: 'EPUB', + title: 'My Book', + sourceTitle: 'My Book', + author: 'A', + createdAt: 1, + updatedAt: 1, +} as Book; + +describe('sync layout (frozen)', () => { + test('book tree paths under /Readest/books/', () => { + expect(buildBasePath('/')).toBe('/Readest'); + expect(buildBasePath('/MyDav')).toBe('/MyDav/Readest'); + expect(buildBookDirPath('/', 'h1')).toBe('/Readest/books/h1'); + expect(buildBookConfigPath('/', 'h1')).toBe('/Readest/books/h1/config.json'); + expect(buildBookCoverPath('/', 'h1')).toBe('/Readest/books/h1/cover.png'); + expect(buildLibraryPath('/')).toBe('/Readest/library.json'); + }); + + test('book file path uses safe title + ext inside the hash dir', () => { + expect(buildBookFilePath('/', book)).toBe('/Readest/books/h1/My Book.epub'); + }); + + test('normalizeRoot strips trailing slash, adds leading', () => { + expect(normalizeRoot('')).toBe('/'); + expect(normalizeRoot('books/')).toBe('/books'); + expect(normalizeRoot('/a/b/')).toBe('/a/b'); + }); + + test('ancestorsOf walks parents top-down excluding the leaf', () => { + expect(ancestorsOf('/a/b/c/file.json')).toEqual(['/a', '/a/b', '/a/b/c']); + expect(ancestorsOf('/file.json')).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/merge.test.ts b/apps/readest-app/src/__tests__/services/sync/file/merge.test.ts new file mode 100644 index 000000000..ca90a3980 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/merge.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, test } from 'vitest'; +import { + mergeNotes, + mergeBookConfig, + mergeBookMetadata, + isRemoteBookMetadataNewer, + shouldApplyRemoteBookMetadata, +} from '@/services/sync/file/merge'; +import type { Book, BookConfig, BookNote } from '@/types/book'; +import type { RemoteBookConfig } from '@/services/sync/file/wire'; + +const note = (id: string, updatedAt: number, deletedAt?: number): BookNote => + ({ + id, + type: 'annotation', + cfi: `c-${id}`, + note: '', + createdAt: updatedAt, + updatedAt, + deletedAt, + }) as BookNote; + +const envelope = (over: Partial = {}): RemoteBookConfig => ({ + schemaVersion: 1, + bookHash: 'h1', + config: { updatedAt: 100 }, + booknotes: [], + writerDeviceId: 'd', + writerVersion: 'readest-webdav-1', + updatedAt: 100, + ...over, +}); + +describe('mergeNotes (element-set CRDT)', () => { + test('union keeps ids from both sides', () => { + const out = mergeNotes([note('a', 1)], [note('b', 1)]) + .map((n) => n.id) + .sort(); + expect(out).toEqual(['a', 'b']); + }); + + test('newer updatedAt wins', () => { + const out = mergeNotes([note('a', 1)], [{ ...note('a', 5), note: 'remote' }]); + expect(out.find((n) => n.id === 'a')!.note).toBe('remote'); + }); + + test('local-newer keeps local fields', () => { + const out = mergeNotes( + [{ ...note('a', 9), note: 'local' }], + [{ ...note('a', 3), note: 'remote' }], + ); + expect(out.find((n) => n.id === 'a')!.note).toBe('local'); + }); + + test('deletedAt tombstone wins on updatedAt tie', () => { + const out = mergeNotes([note('a', 5)], [note('a', 5, 9)]); + expect(out.find((n) => n.id === 'a')!.deletedAt).toBe(9); + }); + + test('idempotent on identical input (id set + field values stable)', () => { + const a = [note('a', 1), note('b', 2)]; + const once = mergeNotes(a, a); + expect(once.map((n) => n.id).sort()).toEqual(['a', 'b']); + const twice = mergeNotes(once, once); + expect(twice.map((n) => n.id).sort()).toEqual(['a', 'b']); + }); + + test('commutative on the winning value per id', () => { + const l = [note('a', 5)]; + const r = [{ ...note('a', 9), note: 'remote' }]; + const lr = mergeNotes(l, r).find((n) => n.id === 'a')!; + const rl = mergeNotes(r, l).find((n) => n.id === 'a')!; + // Whichever order, the strictly-newer side (updatedAt 9) supplies `note`. + expect(lr.note).toBe('remote'); + expect(rl.note).toBe('remote'); + }); +}); + +describe('mergeBookConfig (LWW scalars + CRDT notes)', () => { + test('remote newer overrides scalars but unions notes', () => { + const local: BookConfig = { updatedAt: 50, progress: [1, 10], booknotes: [note('l', 50)] }; + const r = envelope({ + config: { updatedAt: 100, progress: [5, 10] }, + booknotes: [note('r', 100)], + }); + const { config, notes } = mergeBookConfig(local, r); + expect(config.progress).toEqual([5, 10]); + expect(notes.map((n) => n.id).sort()).toEqual(['l', 'r']); + expect(config.booknotes!.map((n) => n.id).sort()).toEqual(['l', 'r']); + }); + + test('local newer keeps local scalars, still unions notes', () => { + const local: BookConfig = { updatedAt: 200, progress: [9, 10], booknotes: [note('l', 200)] }; + const r = envelope({ + config: { updatedAt: 100, progress: [1, 10] }, + booknotes: [note('r', 100)], + }); + const { config, notes } = mergeBookConfig(local, r); + expect(config.progress).toEqual([9, 10]); + expect(notes.map((n) => n.id).sort()).toEqual(['l', 'r']); + }); + + test('null/undefined remote scalars are dropped (never clobber local)', () => { + const local: BookConfig = { updatedAt: 50, location: 'keepme', booknotes: [] }; + const r = envelope({ + config: { updatedAt: 100, location: undefined, xpointer: undefined }, + }); + const { config } = mergeBookConfig(local, r); + expect(config.location).toBe('keepme'); + }); +}); + +describe('mergeBookMetadata (LWW field subset)', () => { + test('overlays only metadata fields, preserves local file/progress fields', () => { + const local = { + hash: 'h', + title: 'L', + author: 'L', + sourceTitle: 'src', + filePath: '/p', + progress: [1, 2], + updatedAt: 1, + } as Book; + const remote = { hash: 'h', title: 'R', author: 'R', updatedAt: 9 } as Book; + const m = mergeBookMetadata(local, remote); + expect(m.title).toBe('R'); + expect(m.author).toBe('R'); + expect(m.sourceTitle).toBe('src'); + expect(m.filePath).toBe('/p'); + expect(m.progress).toEqual([1, 2]); + expect(m.updatedAt).toBe(9); + }); + + test('carries remote group membership (add-to-group) when remote is newer (#4942)', () => { + const local = { hash: 'h', title: 'T', author: 'A', updatedAt: 1 } as Book; + const remote = { + hash: 'h', + title: 'T', + author: 'A', + groupId: 'g1', + groupName: 'Sci-Fi', + updatedAt: 9, + } as Book; + const m = mergeBookMetadata(local, remote); + expect(m.groupId).toBe('g1'); + expect(m.groupName).toBe('Sci-Fi'); + }); + + test('propagates group removal when remote cleared membership (#4942)', () => { + const local = { + hash: 'h', + title: 'T', + author: 'A', + groupId: 'g1', + groupName: 'Sci-Fi', + updatedAt: 1, + } as Book; + const remote = { hash: 'h', title: 'T', author: 'A', updatedAt: 9 } as Book; + const m = mergeBookMetadata(local, remote); + expect(m.groupId).toBeUndefined(); + expect(m.groupName).toBeUndefined(); + }); + + test('carries remote tags when remote is newer; tag removal propagates', () => { + const local = { hash: 'h', title: 'T', author: 'A', tags: ['old'], updatedAt: 1 } as Book; + const tagged = { + hash: 'h', + title: 'T', + author: 'A', + tags: ['sf', 'fav'], + updatedAt: 9, + } as Book; + expect(mergeBookMetadata(local, tagged).tags).toEqual(['sf', 'fav']); + + const cleared = { hash: 'h', title: 'T', author: 'A', updatedAt: 9 } as Book; + expect(mergeBookMetadata(local, cleared).tags).toBeUndefined(); + }); + + test('keeps every local metadata field (incl. tags) when local is newer', () => { + const local = { hash: 'h', title: 'L', author: 'L', tags: ['mine'], updatedAt: 9 } as Book; + const remote = { hash: 'h', title: 'R', author: 'R', tags: ['theirs'], updatedAt: 1 } as Book; + const m = mergeBookMetadata(local, remote); + expect(m.title).toBe('L'); + expect(m.tags).toEqual(['mine']); + expect(m.updatedAt).toBe(9); + }); + + test('readingStatus merges on its own timestamp even when local metadata is newer (#4634 semantics)', () => { + // Asymmetric case: this device edited the title AFTER the peer marked + // the book Finished. Whole-book LWW would drop the status change. + const local = { + hash: 'h', + title: 'Edited locally', + author: 'A', + readingStatus: 'reading', + readingStatusUpdatedAt: 5, + updatedAt: 20, + } as Book; + const remote = { + hash: 'h', + title: 'Old title', + author: 'A', + readingStatus: 'finished', + readingStatusUpdatedAt: 15, + updatedAt: 10, + } as Book; + const m = mergeBookMetadata(local, remote); + expect(m.readingStatus).toBe('finished'); + expect(m.readingStatusUpdatedAt).toBe(15); + expect(m.title).toBe('Edited locally'); + expect(m.updatedAt).toBe(20); + }); + + test('keeps the local readingStatus when it is newer, even as remote metadata wins', () => { + const local = { + hash: 'h', + title: 'L', + author: 'A', + readingStatus: 'finished', + readingStatusUpdatedAt: 15, + updatedAt: 1, + } as Book; + const remote = { + hash: 'h', + title: 'R', + author: 'A', + readingStatus: 'reading', + readingStatusUpdatedAt: 5, + updatedAt: 9, + } as Book; + const m = mergeBookMetadata(local, remote); + expect(m.title).toBe('R'); + expect(m.readingStatus).toBe('finished'); + expect(m.readingStatusUpdatedAt).toBe(15); + }); + + test('merge is idempotent: re-merging the same remote is a no-op', () => { + const local = { + hash: 'h', + title: 'L', + author: 'A', + readingStatus: 'reading', + readingStatusUpdatedAt: 5, + tags: ['a'], + updatedAt: 1, + } as Book; + const remote = { + hash: 'h', + title: 'R', + author: 'A', + readingStatus: 'finished', + readingStatusUpdatedAt: 15, + tags: ['b'], + updatedAt: 9, + } as Book; + const once = mergeBookMetadata(local, remote); + const twice = mergeBookMetadata(once, remote); + expect(twice).toEqual(once); + }); +}); + +describe('shouldApplyRemoteBookMetadata', () => { + test('true when only the readingStatus timestamp is newer', () => { + const local = { updatedAt: 20, readingStatusUpdatedAt: 5 } as Book; + const remote = { updatedAt: 10, readingStatusUpdatedAt: 15 } as Book; + expect(shouldApplyRemoteBookMetadata(local, remote)).toBe(true); + }); + + test('true when book metadata is newer, false when neither is', () => { + expect(shouldApplyRemoteBookMetadata({ updatedAt: 1 } as Book, { updatedAt: 2 } as Book)).toBe( + true, + ); + expect( + shouldApplyRemoteBookMetadata( + { updatedAt: 2, readingStatusUpdatedAt: 2 } as Book, + { updatedAt: 2, readingStatusUpdatedAt: 2 } as Book, + ), + ).toBe(false); + }); + + test('tombstone on either side disqualifies', () => { + expect( + shouldApplyRemoteBookMetadata( + { updatedAt: 1 } as Book, + { updatedAt: 9, readingStatusUpdatedAt: 9, deletedAt: 9 } as Book, + ), + ).toBe(false); + expect( + shouldApplyRemoteBookMetadata( + { updatedAt: 1, deletedAt: 1 } as Book, + { updatedAt: 9 } as Book, + ), + ).toBe(false); + }); +}); + +describe('isRemoteBookMetadataNewer', () => { + test('strictly newer remote only', () => { + expect(isRemoteBookMetadataNewer({ updatedAt: 1 } as Book, { updatedAt: 2 } as Book)).toBe( + true, + ); + expect(isRemoteBookMetadataNewer({ updatedAt: 2 } as Book, { updatedAt: 2 } as Book)).toBe( + false, + ); + expect(isRemoteBookMetadataNewer({ updatedAt: 3 } as Book, { updatedAt: 2 } as Book)).toBe( + false, + ); + }); + + test('tombstone on either side disqualifies', () => { + expect( + isRemoteBookMetadataNewer({ updatedAt: 1 } as Book, { updatedAt: 9, deletedAt: 9 } as Book), + ).toBe(false); + expect( + isRemoteBookMetadataNewer({ updatedAt: 1, deletedAt: 1 } as Book, { updatedAt: 9 } as Book), + ).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/provider-conformance.test.ts b/apps/readest-app/src/__tests__/services/sync/file/provider-conformance.test.ts new file mode 100644 index 000000000..27a99015e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/provider-conformance.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider'; +import { FileSyncError } from '@/services/sync/file/provider'; +import type { FileSyncProvider } from '@/services/sync/file/provider'; +import type { WebDAVSettings } from '@/types/settings'; +import { runSemanticContract } from './providerSemanticContract'; + +/** + * Contract every {@link FileSyncProvider} must satisfy, exercised here against + * {@link createWebDAVProvider} over a `fetch` mock. When a second backend + * (Drive, Dropbox, …) arrives, lift this into a shared helper and pass a + * factory that builds the new provider over whatever transport it stubs. + */ +const runProviderConformance = ( + name: string, + makeProvider: () => FileSyncProvider, + mock: { ok: (status: number, body?: string | null, headers?: Record) => void }, +) => { + describe(`${name} — FileSyncProvider conformance`, () => { + test('readText resolves null on 404', async () => { + mock.ok(404, null); + expect(await makeProvider().readText('/Readest/x.json')).toBeNull(); + }); + + test('readText maps 401 to FileSyncError AUTH_FAILED', async () => { + mock.ok(401, ''); + const err = await makeProvider() + .readText('/Readest/x.json') + .catch((e) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect(err).toMatchObject({ code: 'AUTH_FAILED', status: 401 }); + }); + + test('readBinary resolves null on 404', async () => { + mock.ok(404, null); + expect(await makeProvider().readBinary('/Readest/x.bin')).toBeNull(); + }); + + test('head reads content-length, null on 404', async () => { + mock.ok(200, null, { 'content-length': '512' }); + expect(await makeProvider().head('/Readest/x')).toEqual({ size: 512, etag: undefined }); + mock.ok(404, null); + expect(await makeProvider().head('/Readest/x')).toBeNull(); + }); + + test('writeText succeeds on 201 Created', async () => { + mock.ok(201, ''); + await expect(makeProvider().writeText('/Readest/x.json', '{}')).resolves.toBeUndefined(); + }); + + test('deleteDir treats 404 as success', async () => { + mock.ok(404, null); + await expect(makeProvider().deleteDir('/Readest/books/gone')).resolves.toBeUndefined(); + }); + + test('list maps 401 to FileSyncError AUTH_FAILED', async () => { + mock.ok(401, ''); + const err = await makeProvider() + .list('/Readest/books') + .catch((e) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect(err).toMatchObject({ code: 'AUTH_FAILED', status: 401 }); + }); + + test('list maps 404 to FileSyncError NOT_FOUND', async () => { + mock.ok(404, ''); + const err = await makeProvider() + .list('/Readest/books') + .catch((e) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect(err).toMatchObject({ code: 'NOT_FOUND', status: 404 }); + }); + }); +}; + +const settings: WebDAVSettings = { + enabled: true, + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'secret', + rootPath: '/', +}; + +const ORIGINAL_FETCH = globalThis.fetch; +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + vi.restoreAllMocks(); +}); + +runProviderConformance('WebDAVProvider', () => createWebDAVProvider(settings), { + ok: (status, body = '', headers) => + fetchMock.mockResolvedValueOnce(new Response(body, { status, headers })), +}); + +// The same shared semantic contract Drive is held to, proving it is genuinely +// transport-agnostic (WebDAV does one fetch/op; Drive does several). +runSemanticContract('WebDAVProvider', () => { + const scenarioFetch = vi.fn(); + globalThis.fetch = scenarioFetch as unknown as typeof fetch; + return { + makeProvider: () => createWebDAVProvider(settings), + stageAbsent: () => scenarioFetch.mockResolvedValueOnce(new Response(null, { status: 404 })), + stageAuthFailure: () => scenarioFetch.mockResolvedValueOnce(new Response('', { status: 401 })), + }; +}); + +describe('WebDAVProvider construction', () => { + test('rootPath is normalised', () => { + expect(createWebDAVProvider({ ...settings, rootPath: '/MyDav/' }).rootPath).toBe('/MyDav'); + }); + + test('streaming methods are absent off-Tauri (web fallback)', () => { + const provider = createWebDAVProvider(settings); + expect(provider.uploadStream).toBeUndefined(); + expect(provider.downloadStream).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/providerRegistry.test.ts b/apps/readest-app/src/__tests__/services/sync/file/providerRegistry.test.ts new file mode 100644 index 000000000..a7f2427eb --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/providerRegistry.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/services/sync/providers/gdrive/buildGoogleDriveProvider', () => ({ + buildGoogleDriveProvider: vi.fn(), +})); + +vi.mock('@/services/sync/providers/onedrive/buildOneDriveProvider', () => ({ + buildOneDriveProvider: vi.fn(), +})); + +import { buildGoogleDriveProvider } from '@/services/sync/providers/gdrive/buildGoogleDriveProvider'; +import { buildOneDriveProvider } from '@/services/sync/providers/onedrive/buildOneDriveProvider'; +import { + createFileSyncProvider, + getEnabledFileSyncBackends, + resetFileSyncProviderCache, +} from '@/services/sync/file/providerRegistry'; +import type { FileSyncProvider } from '@/services/sync/file/provider'; +import type { S3Settings, WebDAVSettings } from '@/types/settings'; + +const webdav: WebDAVSettings = { + enabled: true, + serverUrl: 'https://dav.example.com', + username: 'u', + password: 'p', + rootPath: '/', +}; + +const s3: S3Settings = { + enabled: true, + endpoint: 'https://acc.r2.cloudflarestorage.com', + region: 'auto', + bucket: 'readest', + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', +}; + +afterEach(() => { + vi.clearAllMocks(); + resetFileSyncProviderCache(); +}); + +describe('getEnabledFileSyncBackends', () => { + test('lists only switched-on backends in a stable order', () => { + expect(getEnabledFileSyncBackends({})).toEqual([]); + expect(getEnabledFileSyncBackends({ webdav })).toEqual(['webdav']); + expect(getEnabledFileSyncBackends({ webdav, googleDrive: { enabled: true } })).toEqual([ + 'webdav', + 'gdrive', + ]); + expect( + getEnabledFileSyncBackends({ + webdav: { ...webdav, enabled: false }, + googleDrive: { enabled: true }, + }), + ).toEqual(['gdrive']); + }); + + test("getEnabledFileSyncBackends includes 'onedrive' when enabled", () => { + expect(getEnabledFileSyncBackends({ onedrive: { enabled: true } })).toContain('onedrive'); + }); +}); + +describe('createFileSyncProvider', () => { + test('builds a WebDAV provider from its settings', async () => { + const provider = await createFileSyncProvider('webdav', { webdav }); + expect(provider?.rootPath).toBe('/'); + }); + + test('returns null for webdav without settings', async () => { + expect(await createFileSyncProvider('webdav', {})).toBeNull(); + }); + + test('builds an S3 provider from its settings and memoises it', async () => { + const first = await createFileSyncProvider('s3', { s3 }); + expect(first?.rootPath).toBe('/'); + const second = await createFileSyncProvider('s3', { s3 }); + expect(second).toBe(first); + // A credential change rebuilds. + const third = await createFileSyncProvider('s3', { s3: { ...s3, secretAccessKey: 'X' } }); + expect(third).not.toBe(first); + }); + + test('returns null for s3 without settings', async () => { + expect(await createFileSyncProvider('s3', {})).toBeNull(); + }); + + test('delegates gdrive to buildGoogleDriveProvider', async () => { + const fake = { rootPath: '/' } as unknown as FileSyncProvider; + vi.mocked(buildGoogleDriveProvider).mockResolvedValueOnce(fake); + expect(await createFileSyncProvider('gdrive', {})).toBe(fake); + expect(buildGoogleDriveProvider).toHaveBeenCalledTimes(1); + }); + + // The provider is memoised per connection key so its path->id cache stays + // warm across surfaces (reader hook, library auto-sync, Sync now): a cold + // provider re-resolves /Readest, books/ and library.json by name query on + // every engine build, turning each book open/close/sync into a burst of + // redundant remote requests. + test('returns the same instance for an unchanged connection', async () => { + const first = await createFileSyncProvider('webdav', { webdav }); + const second = await createFileSyncProvider('webdav', { webdav }); + expect(second).toBe(first); + }); + + test('memoises the gdrive build (keychain probed once)', async () => { + const fake = { rootPath: '/' } as unknown as FileSyncProvider; + vi.mocked(buildGoogleDriveProvider).mockResolvedValue(fake); + await createFileSyncProvider('gdrive', {}); + await createFileSyncProvider('gdrive', {}); + expect(buildGoogleDriveProvider).toHaveBeenCalledTimes(1); + }); + + test('delegates onedrive to buildOneDriveProvider and does not throw', async () => { + vi.mocked(buildOneDriveProvider).mockResolvedValueOnce(null); + const result = await createFileSyncProvider('onedrive', { onedrive: { enabled: true } }); + expect(result).toBeNull(); + expect(buildOneDriveProvider).toHaveBeenCalledTimes(1); + }); + + test('rebuilds when the connection settings change', async () => { + const first = await createFileSyncProvider('webdav', { webdav }); + const second = await createFileSyncProvider('webdav', { + webdav: { ...webdav, password: 'changed' }, + }); + expect(second).not.toBe(first); + }); + + test('resetFileSyncProviderCache forces a rebuild', async () => { + const first = await createFileSyncProvider('webdav', { webdav }); + resetFileSyncProviderCache(); + const second = await createFileSyncProvider('webdav', { webdav }); + expect(second).not.toBe(first); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/providerSemanticContract.ts b/apps/readest-app/src/__tests__/services/sync/file/providerSemanticContract.ts new file mode 100644 index 000000000..cadafae21 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/providerSemanticContract.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest'; +import { FileSyncError } from '@/services/sync/file/provider'; +import type { FileSyncProvider } from '@/services/sync/file/provider'; + +/** + * Transport-agnostic semantics every {@link FileSyncProvider} must honor, + * regardless of whether the backend is path-addressed (WebDAV — one request per + * op) or id-addressed (Drive — several). The original WebDAV conformance suite + * baked in wire details (a `content-length` HEAD, a `list` 404 that throws) that + * Drive cannot satisfy as-is, so the genuinely shared invariants live here and + * each backend supplies a {@link ProviderScenario} that stages its own wire + * responses for the abstract situations below. + */ +export interface ProviderScenario { + makeProvider: () => FileSyncProvider; + /** Stage the next op so a read / head / delete sees an absent path. */ + stageAbsent: () => void; + /** Stage the next op so the backend returns an auth failure (HTTP 401). */ + stageAuthFailure: () => void; +} + +export const runSemanticContract = (name: string, makeScenario: () => ProviderScenario): void => { + describe(`${name} — FileSyncProvider semantic contract`, () => { + test('readText resolves null for an absent path', async () => { + const s = makeScenario(); + s.stageAbsent(); + expect(await s.makeProvider().readText('/Readest/x.json')).toBeNull(); + }); + + test('readBinary resolves null for an absent path', async () => { + const s = makeScenario(); + s.stageAbsent(); + expect(await s.makeProvider().readBinary('/Readest/x.bin')).toBeNull(); + }); + + test('head resolves null for an absent path', async () => { + const s = makeScenario(); + s.stageAbsent(); + expect(await s.makeProvider().head('/Readest/x')).toBeNull(); + }); + + test('readText maps an auth failure to FileSyncError AUTH_FAILED', async () => { + const s = makeScenario(); + s.stageAuthFailure(); + const err = await s + .makeProvider() + .readText('/Readest/x.json') + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect((err as FileSyncError).code).toBe('AUTH_FAILED'); + }); + + test('list maps an auth failure to FileSyncError AUTH_FAILED', async () => { + const s = makeScenario(); + s.stageAuthFailure(); + const err = await s + .makeProvider() + .list('/Readest/books') + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect((err as FileSyncError).code).toBe('AUTH_FAILED'); + }); + + test('deleteDir treats an absent target as success', async () => { + const s = makeScenario(); + s.stageAbsent(); + await expect(s.makeProvider().deleteDir('/Readest/books/gone')).resolves.toBeUndefined(); + }); + }); +}; diff --git a/apps/readest-app/src/__tests__/services/sync/file/runLibrarySync.test.ts b/apps/readest-app/src/__tests__/services/sync/file/runLibrarySync.test.ts new file mode 100644 index 000000000..e37daf330 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/runLibrarySync.test.ts @@ -0,0 +1,222 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { useSettingsStore } from '@/store/settingsStore'; +import { setCachedUserPlan } from '@/services/sync/cloudSyncProvider'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useFileSyncStore } from '@/store/fileSyncStore'; +import type { SystemSettings } from '@/types/settings'; + +const syncLibrary = vi.fn().mockResolvedValue({ booksSynced: 0 }); +const pushBookFile = vi.fn().mockResolvedValue({ uploaded: true }); +const pushBookCover = vi.fn().mockResolvedValue({ uploaded: true }); +const downloadBookFile = vi.fn().mockResolvedValue(true); + +vi.mock('@/services/sync/file/providerRegistry', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createFileSyncProvider: vi.fn(async () => ({}) as never), + }; +}); + +vi.mock('@/services/sync/file/appLocalStore', () => ({ + createAppLocalStore: vi.fn(() => ({}) as never), +})); + +vi.mock('@/services/sync/file/engine', () => ({ + FileSyncEngine: vi.fn(function (this: Record) { + this['syncLibrary'] = syncLibrary; + this['pushBookFile'] = pushBookFile; + this['pushBookCover'] = pushBookCover; + this['downloadBookFile'] = downloadBookFile; + }), +})); + +import { + runActiveFileBookDownload, + runActiveFileBookUpload, + runActiveFileLibrarySync, +} from '@/services/sync/file/runLibrarySync'; +import type { Book } from '@/types/book'; + +const makeBook = (hash: string): Book => ({ + hash, + format: 'EPUB', + title: `Book ${hash}`, + sourceTitle: `Book ${hash}`, + author: 'A', + createdAt: 1, + updatedAt: 1, +}); + +const translationFn = (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); + } + return key; +}; + +const envConfig = { + getAppService: vi.fn(async () => ({ saveSettings: vi.fn() }) as never), +} as never; + +const setProvider = (patch: Partial): void => { + useSettingsStore.setState({ + settings: { + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...patch, + } as SystemSettings, + setSettings: (s: SystemSettings) => useSettingsStore.setState({ settings: s }), + saveSettings: vi.fn(), + } as never); +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Third-party cloud sync is premium; run the suite as a paid plan. + setCachedUserPlan('pro'); + syncLibrary.mockClear().mockResolvedValue({ booksSynced: 0 }); + useFileSyncStore.setState({ byKind: {}, activeKind: null, lastErrorByKind: {} }); + useLibraryStore.setState({ library: [], libraryLoaded: true } as never); +}); + +describe('runActiveFileLibrarySync', () => { + test('returns false without syncing when readest is the provider', async () => { + setProvider({}); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull(); + expect(syncLibrary).not.toHaveBeenCalled(); + }); + + test('runs the engine for the active provider and clears lastError on success', async () => { + setProvider({ + webdav: { enabled: true, deviceId: 'd1', syncBooks: true, strategy: 'silent' }, + } as Partial); + useFileSyncStore.getState().setLastError('webdav', 'stale error'); + + syncLibrary.mockResolvedValueOnce({ booksSynced: 3 }); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toMatchObject({ + booksSynced: 3, + }); + + expect(syncLibrary).toHaveBeenCalledTimes(1); + const [, options] = syncLibrary.mock.calls[0]!; + expect(options.syncBooks).toBe(true); + expect(options.deviceId).toBe('d1'); + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBeNull(); + // Mutex released. + expect(useFileSyncStore.getState().activeKind).toBeNull(); + }); + + test('records lastError and releases the mutex when the engine fails', async () => { + setProvider({ + webdav: { enabled: true, deviceId: 'd1' }, + } as Partial); + syncLibrary.mockRejectedValueOnce(new Error('server unreachable')); + + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull(); + + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toContain('server unreachable'); + expect(useFileSyncStore.getState().activeKind).toBeNull(); + }); + + test('skips when the library has not loaded (would push an empty index)', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + useLibraryStore.setState({ libraryLoaded: false } as never); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull(); + expect(syncLibrary).not.toHaveBeenCalled(); + }); + + test('skips when another backend holds the library-sync mutex', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + useFileSyncStore.getState().beginSync('gdrive', 'busy'); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull(); + expect(syncLibrary).not.toHaveBeenCalled(); + }); +}); + +// The Book Details / bookshelf cloud buttons route here when a third-party +// provider is selected, instead of the (gated) Readest Cloud transfer queue. +describe('runActiveFileBookUpload', () => { + test('returns false without touching the engine when readest is the provider', async () => { + setProvider({}); + expect(await runActiveFileBookUpload(envConfig, makeBook('h1'))).toBe(false); + expect(pushBookFile).not.toHaveBeenCalled(); + }); + + test('pushes the file (plus cover) for the active provider', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + expect(await runActiveFileBookUpload(envConfig, makeBook('h1'))).toBe(true); + expect(pushBookFile).toHaveBeenCalledTimes(1); + expect(pushBookCover).toHaveBeenCalledTimes(1); + }); + + test('treats an already-mirrored file as success', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + pushBookFile.mockResolvedValueOnce({ uploaded: false, reason: 'remote-matches' }); + expect(await runActiveFileBookUpload(envConfig, makeBook('h1'))).toBe(true); + }); + + test('fails when the book has no local source', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + pushBookFile.mockResolvedValueOnce({ uploaded: false, reason: 'no-source' }); + expect(await runActiveFileBookUpload(envConfig, makeBook('h1'))).toBe(false); + }); + + test('returns false instead of throwing when the engine fails', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + pushBookFile.mockRejectedValueOnce(new Error('server unreachable')); + expect(await runActiveFileBookUpload(envConfig, makeBook('h1'))).toBe(false); + }); +}); + +describe('runActiveFileBookDownload', () => { + test('returns false without touching the engine when readest is the provider', async () => { + setProvider({}); + expect(await runActiveFileBookDownload(envConfig, makeBook('h1'))).toBe(false); + expect(downloadBookFile).not.toHaveBeenCalled(); + }); + + test('downloads via the engine and stamps downloadedAt', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + const book = makeBook('h1'); + expect(await runActiveFileBookDownload(envConfig, book)).toBe(true); + expect(downloadBookFile).toHaveBeenCalledTimes(1); + expect(book.downloadedAt).toBeTruthy(); + }); + + test('returns false and leaves the book unstamped when the remote has no file', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + downloadBookFile.mockResolvedValueOnce(false); + const book = makeBook('h1'); + expect(await runActiveFileBookDownload(envConfig, book)).toBe(false); + expect(book.downloadedAt).toBeUndefined(); + }); + + test('returns false instead of throwing when the engine fails', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + downloadBookFile.mockRejectedValueOnce(new Error('server unreachable')); + expect(await runActiveFileBookDownload(envConfig, makeBook('h1'))).toBe(false); + }); +}); + +// Paused means paused (#4959 contract): a free plan with a still-enabled +// third-party provider (downgrade) must not sync — neither the library run +// nor the per-book actions. +describe('premium gating of the active provider', () => { + test('library sync is skipped for a free plan', async () => { + setCachedUserPlan('free'); + setProvider({ webdav: { enabled: true, deviceId: 'd1' } } as Partial); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBeNull(); + expect(syncLibrary).not.toHaveBeenCalled(); + }); + + test('per-book upload and download are skipped for a free plan', async () => { + setCachedUserPlan('free'); + setProvider({ webdav: { enabled: true } } as Partial); + expect(await runActiveFileBookUpload(envConfig, makeBook('h1'))).toBe(false); + expect(await runActiveFileBookDownload(envConfig, makeBook('h1'))).toBe(false); + expect(pushBookFile).not.toHaveBeenCalled(); + expect(downloadBookFile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/file/wire.test.ts b/apps/readest-app/src/__tests__/services/sync/file/wire.test.ts new file mode 100644 index 000000000..15c058192 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/wire.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'vitest'; +import { + buildRemotePayload, + parseRemotePayload, + parseRemoteLibraryIndex, +} from '@/services/sync/file/wire'; +import type { Book, BookConfig } from '@/types/book'; + +const book = { + hash: 'h1', + metaHash: 'm1', + format: 'EPUB', + title: 'T', + author: 'A', + createdAt: 1, + updatedAt: 1, +} as Book; + +// Cast through unknown so a device-local field (viewSettings) can be present +// on the source config without fighting the BookConfig type — the point of +// the test is that it never reaches the wire. +const config = { + updatedAt: 42, + progress: [3, 10], + location: 'loc', + xpointer: 'xp', + booknotes: [], + viewSettings: { fontSize: 14 }, +} as unknown as BookConfig; + +describe('wire envelope (frozen)', () => { + test('buildRemotePayload trims to reading state + stable header', () => { + const p = buildRemotePayload(book, config, 'dev-1'); + expect(p.schemaVersion).toBe(1); + expect(p.writerVersion).toBe('readest-webdav-1'); + expect(p.writerDeviceId).toBe('dev-1'); + expect(p.bookHash).toBe('h1'); + expect(p.metaHash).toBe('m1'); + expect(p.config).toEqual({ progress: [3, 10], location: 'loc', xpointer: 'xp', updatedAt: 42 }); + // Device-local fields never travel. + expect('viewSettings' in p.config).toBe(false); + }); + + test('parseRemotePayload rejects null / non-JSON / wrong schema', () => { + expect(parseRemotePayload(null)).toBeNull(); + expect(parseRemotePayload('not json')).toBeNull(); + expect(parseRemotePayload(JSON.stringify({ schemaVersion: 2 }))).toBeNull(); + const ok = parseRemotePayload(JSON.stringify(buildRemotePayload(book, config, 'd'))); + expect(ok?.bookHash).toBe('h1'); + }); + + test('parseRemoteLibraryIndex rejects null / malformed / wrong schema', () => { + expect(parseRemoteLibraryIndex(null)).toBeNull(); + expect(parseRemoteLibraryIndex('{')).toBeNull(); + expect(parseRemoteLibraryIndex(JSON.stringify({ schemaVersion: 9, books: [] }))).toBeNull(); + const ok = parseRemoteLibraryIndex( + JSON.stringify({ schemaVersion: 1, books: [book], updatedAt: 5 }), + ); + expect(ok?.books).toHaveLength(1); + expect(ok?.updatedAt).toBe(5); + }); + + test('parseRemoteLibraryIndex preserves the optional uploadedHashes record (#4856)', () => { + const parsed = parseRemoteLibraryIndex( + JSON.stringify({ schemaVersion: 1, books: [book], updatedAt: 5, uploadedHashes: ['h1'] }), + ); + expect(parsed?.uploadedHashes).toEqual(['h1']); + // Legacy index without the field parses fine (treated as empty by the engine). + const legacy = parseRemoteLibraryIndex( + JSON.stringify({ schemaVersion: 1, books: [book], updatedAt: 5 }), + ); + expect(legacy?.uploadedHashes).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts b/apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts new file mode 100644 index 000000000..618c378aa --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import type { SystemSettings } from '@/types/settings'; +import { useFileSyncStore } from '@/store/fileSyncStore'; + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { + dispatch: vi.fn(), + }, +})); + +import { checkMixedFleetOnce } from '@/services/sync/fleetDetection'; +import { eventDispatcher } from '@/utils/event'; +import type { SyncClient } from '@/libs/sync'; + +const translationFn = (key: string) => key; + +const makeSyncClient = (books: unknown[] | null): SyncClient => + ({ + pullChanges: vi.fn(async () => ({ books, configs: null, notes: null })), + }) as unknown as SyncClient; + +const settingsWith = (patch: Partial): SystemSettings => + ({ + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...patch, + }) as SystemSettings; + +beforeEach(() => { + vi.clearAllMocks(); + useFileSyncStore.setState({ + byKind: {}, + activeKind: null, + lastErrorByKind: {}, + fleetNoticeShown: false, + }); +}); + +describe('checkMixedFleetOnce', () => { + test('no probe when readest is the provider', async () => { + const client = makeSyncClient([]); + expect(await checkMixedFleetOnce(client, settingsWith({}), translationFn)).toBe(false); + expect(client.pullChanges).not.toHaveBeenCalled(); + }); + + test('no probe without a providerSelectedAt anchor', async () => { + const client = makeSyncClient([]); + const settings = settingsWith({ webdav: { enabled: true } } as Partial); + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false); + expect(client.pullChanges).not.toHaveBeenCalled(); + }); + + test('probes read-only since the selection anchor and notifies when another writer exists', async () => { + const client = makeSyncClient([{ book_hash: 'h1' }]); + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(true); + + expect(client.pullChanges).toHaveBeenCalledWith(12345, 'books', undefined, undefined, 1); + expect(vi.mocked(eventDispatcher.dispatch)).toHaveBeenCalledWith( + 'toast', + expect.objectContaining({ + message: expect.stringContaining('Another device is still syncing'), + }), + ); + }); + + test('notifies only once per session', async () => { + const client = makeSyncClient([{ book_hash: 'h1' }]); + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + await checkMixedFleetOnce(client, settings, translationFn); + await checkMixedFleetOnce(client, settings, translationFn); + + expect(vi.mocked(eventDispatcher.dispatch)).toHaveBeenCalledTimes(1); + }); + + test('quiet when no newer rows exist', async () => { + const client = makeSyncClient([]); + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false); + expect(vi.mocked(eventDispatcher.dispatch)).not.toHaveBeenCalled(); + }); + + test('probe failures are silent (offline is not a fleet problem)', async () => { + const client = { + pullChanges: vi.fn(async () => { + throw new Error('Not authenticated'); + }), + } as unknown as SyncClient; + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false); + expect(vi.mocked(eventDispatcher.dispatch)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProvider.test.ts new file mode 100644 index 000000000..22a51bc8d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProvider.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + createGoogleDriveProvider, + type DriveAuth, + type FetchFn, +} from '@/services/sync/providers/gdrive/GoogleDriveProvider'; +import { FileSyncError } from '@/services/sync/file/provider'; +import { runSemanticContract } from '@/__tests__/services/sync/file/providerSemanticContract'; + +const auth: DriveAuth = { getAccessToken: async () => 'TOKEN' }; + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +const text = (body: string, status = 200): Response => new Response(body, { status }); +const folder = (id: string) => ({ id, mimeType: 'application/vnd.google-apps.folder' }); + +interface Harness { + provider: ReturnType; + fetchMock: ReturnType; + sleep: ReturnType; + /** URL of the n-th fetch call (0-based). */ + url: (n: number) => string; + /** Method of the n-th fetch call (0-based). */ + method: (n: number) => string | undefined; +} + +const makeDrive = (): Harness => { + const fetchMock = vi.fn(); + const sleep = vi.fn(async () => {}); + const provider = createGoogleDriveProvider(auth, fetchMock as unknown as FetchFn, { + sleep: sleep as unknown as (ms: number) => Promise, + }); + return { + provider, + fetchMock, + sleep, + url: (n) => fetchMock.mock.calls[n]?.[0] as string, + method: (n) => (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.method, + }; +}; + +// Drive must satisfy the same semantics as WebDAV; it just stages them over its +// id-addressed wire (an absent path = an empty `files.list`, not a 404 body). +runSemanticContract('GoogleDriveProvider', () => { + const fetchMock = vi.fn(); + return { + makeProvider: () => + createGoogleDriveProvider(auth, fetchMock as unknown as FetchFn, { sleep: async () => {} }), + stageAbsent: () => fetchMock.mockResolvedValueOnce(json({ files: [] })), + stageAuthFailure: () => fetchMock.mockResolvedValueOnce(json({}, 401)), + }; +}); + +describe('GoogleDriveProvider — Drive transport', () => { + test('readText resolves the path segment-by-segment then downloads, and caches ids', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // findChild('Readest') + .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) // findChild('x.json') + .mockResolvedValueOnce(text('HELLO')); // media download + expect(await h.provider.readText('/Readest/x.json')).toBe('HELLO'); + expect(h.fetchMock).toHaveBeenCalledTimes(3); + expect(h.url(2)).toContain('/XID?alt=media'); + expect(new URL(h.url(0)).searchParams.get('q')).toContain("name = 'Readest'"); + + // A second read hits the cached file id — only the media GET fires. + h.fetchMock.mockResolvedValueOnce(text('HELLO AGAIN')); + expect(await h.provider.readText('/Readest/x.json')).toBe('HELLO AGAIN'); + expect(h.fetchMock).toHaveBeenCalledTimes(4); + }); + + test('writeText uploads via create-then-name and auto-creates the parent folder', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [] })) // findChild('Readest') — missing + .mockResolvedValueOnce(json({ id: 'RID' })) // createFolder('Readest') + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // re-query winner + .mockResolvedValueOnce(json({ files: [] })) // findChild('new.json') — not exists + .mockResolvedValueOnce(json({ id: 'NID' })) // POST upload + .mockResolvedValueOnce(json({ id: 'NID' })); // PATCH name + reparent + await h.provider.writeText('/Readest/new.json', '{"a":1}'); + expect(h.fetchMock).toHaveBeenCalledTimes(6); + expect(h.method(1)).toBe('POST'); // create folder + expect(h.url(4)).toContain('uploadType=media'); + expect(h.method(4)).toBe('POST'); // create file bytes + expect(h.url(5)).toContain('addParents=RID'); + expect(h.method(5)).toBe('PATCH'); // name + reparent + }); + + test('list drains every nextPageToken page', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // findChild('Readest') + .mockResolvedValueOnce( + json({ + files: [ + { id: 'A', name: 'a.json' }, + { id: 'B', name: 'b.json' }, + ], + nextPageToken: 'T2', + }), + ) + .mockResolvedValueOnce(json({ files: [{ id: 'C', name: 'c.json' }] })); + const entries = await h.provider.list('/Readest'); + expect(entries.map((e) => e.name)).toEqual(['a.json', 'b.json', 'c.json']); + expect(entries[0]).toMatchObject({ path: '/Readest/a.json', isDirectory: false }); + expect(h.url(2)).toContain('pageToken=T2'); + }); + + test('head returns the byte size and the md5 etag', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) + .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) + .mockResolvedValueOnce(json({ id: 'XID', name: 'x.json', size: '1234', md5Checksum: 'abc' })); + expect(await h.provider.head('/Readest/x.json')).toEqual({ size: 1234, etag: 'abc' }); + }); + + test('deleteDir resolves the folder id and DELETEs it', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // Readest + .mockResolvedValueOnce(json({ files: [folder('BID')] })) // books + .mockResolvedValueOnce(json({ files: [folder('GID')] })) // gone + .mockResolvedValueOnce(new Response(null, { status: 204 })); // DELETE + await expect(h.provider.deleteDir('/Readest/books/gone')).resolves.toBeUndefined(); + expect(h.method(3)).toBe('DELETE'); + expect(h.url(3)).toContain('/GID'); + }); + + test('retries a 429 with backoff and then succeeds', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(new Response('', { status: 429 })) // findChild('Readest') throttled + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // retry succeeds + .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) + .mockResolvedValueOnce(text('OK')); + expect(await h.provider.readText('/Readest/x.json')).toBe('OK'); + expect(h.sleep).toHaveBeenCalledTimes(1); + expect(h.fetchMock).toHaveBeenCalledTimes(4); + }); + + test('retries a thrown transport error (mobile connection-reuse) and then succeeds', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // findChild('Readest') ok + // childrenQuery throws like the Tauri HTTP plugin does on Android when a + // pooled connection goes bad: a plain Error (not a TypeError). + .mockRejectedValueOnce( + new Error('error sending request for url (https://www.googleapis.com/...)'), + ) + .mockResolvedValueOnce(json({ files: [{ id: 'XID', name: 'x.json' }] })); // retry opens a fresh connection + const entries = await h.provider.list('/Readest'); + expect(entries.map((e) => e.name)).toEqual(['x.json']); + expect(h.sleep).toHaveBeenCalledTimes(1); + expect(h.fetchMock).toHaveBeenCalledTimes(3); + }); + + test('maps an exhausted thrown transport error to a NETWORK FileSyncError', async () => { + const h = makeDrive(); + // Every attempt throws the reqwest transport error; after the bounded + // retries it must surface as NETWORK (not UNKNOWN) so the engine treats it + // as transient. + h.fetchMock.mockRejectedValue( + new Error('error sending request for url (https://www.googleapis.com/...)'), + ); + const err = await h.provider.list('/Readest').catch((e: unknown) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect((err as FileSyncError).code).toBe('NETWORK'); + // 1 initial attempt + MAX_BACKOFF_RETRIES (4) = 5 calls on the first segment. + expect(h.fetchMock).toHaveBeenCalledTimes(5); + }); + + test('classifies a 403 rate-limit as NETWORK and a 403 permission error as AUTH_FAILED', async () => { + const rate = makeDrive(); + rate.fetchMock.mockResolvedValueOnce( + json({ error: { errors: [{ reason: 'userRateLimitExceeded' }] } }, 403), + ); + const rateErr = await rate.provider.readText('/Readest/x.json').catch((e: unknown) => e); + expect(rateErr).toBeInstanceOf(FileSyncError); + expect((rateErr as FileSyncError).code).toBe('NETWORK'); + + const perm = makeDrive(); + perm.fetchMock.mockResolvedValueOnce( + json({ error: { errors: [{ reason: 'insufficientPermissions' }] } }, 403), + ); + const permErr = await perm.provider.readText('/Readest/x.json').catch((e: unknown) => e); + expect((permErr as FileSyncError).code).toBe('AUTH_FAILED'); + }); + + test('findChild picks the lexicographically smallest id when duplicates exist', async () => { + const h = makeDrive(); + // Two folders both named "Readest" (a create race) — resolution must converge + // on the same one for every caller, so the smaller id wins deterministically. + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('B'), folder('A')] })) + .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) + .mockResolvedValueOnce(text('DATA')); + await h.provider.readText('/Readest/x.json'); + // The child lookup under "Readest" must query parent 'A', not 'B'. + expect(new URL(h.url(1)).searchParams.get('q')).toContain("'A' in parents"); + }); + + test('evicts a stale cached id on a 404 and re-resolves once', async () => { + const h = makeDrive(); + // Warm the cache. + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) + .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) + .mockResolvedValueOnce(text('FIRST')); + expect(await h.provider.readText('/Readest/x.json')).toBe('FIRST'); + + // Second read: cached XID now 404s (deleted + recreated remotely). The + // provider evicts, re-resolves the path fresh, and reads the new id. + h.fetchMock + .mockResolvedValueOnce(new Response(null, { status: 404 })) // media GET on stale XID + .mockResolvedValueOnce(json({ files: [folder('RID2')] })) // re-resolve Readest + .mockResolvedValueOnce(json({ files: [{ id: 'XID2' }] })) // re-resolve x.json + .mockResolvedValueOnce(text('SECOND')); // media GET on XID2 + expect(await h.provider.readText('/Readest/x.json')).toBe('SECOND'); + }); + + // Writes to a known path must not pay a lookup: the engine's steady state + // (config.json and library.json PUTs right after their pull) re-writes paths + // whose ids the read already cached, so the extra files.list per PUT was + // pure overhead at one query per book config and per index push. + test('writeText to a cached path PATCHes the known id without a lookup', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // findChild('Readest') + .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) // findChild('x.json') + .mockResolvedValueOnce(text('FIRST')); // media download + await h.provider.readText('/Readest/x.json'); + + h.fetchMock.mockResolvedValueOnce(json({ id: 'XID' })); // PATCH media update + await h.provider.writeText('/Readest/x.json', 'BODY'); + expect(h.fetchMock).toHaveBeenCalledTimes(4); + expect(h.url(3)).toContain('/upload/drive/v3/files/XID'); + expect(h.method(3)).toBe('PATCH'); + }); + + test('a stale cached id on write evicts and falls back to the full resolve', async () => { + const h = makeDrive(); + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // findChild('Readest') + .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) // findChild('x.json') + .mockResolvedValueOnce(text('FIRST')); // media download + await h.provider.readText('/Readest/x.json'); + + // Cached XID was deleted remotely: the fast-path PATCH 404s, the provider + // evicts and re-resolves, finds no existing file, and create-then-names. + h.fetchMock + .mockResolvedValueOnce(json({}, 404)) // PATCH on stale XID + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // re-resolve Readest + .mockResolvedValueOnce(json({ files: [] })) // findChild('x.json') — gone + .mockResolvedValueOnce(json({ id: 'NID' })) // POST upload + .mockResolvedValueOnce(json({ id: 'NID' })); // PATCH name + reparent + await h.provider.writeText('/Readest/x.json', 'BODY'); + expect(h.fetchMock).toHaveBeenCalledTimes(8); + + // The recreated file's id is cached: a follow-up read is one media GET. + h.fetchMock.mockResolvedValueOnce(text('AFTER')); + expect(await h.provider.readText('/Readest/x.json')).toBe('AFTER'); + expect(h.url(8)).toContain('/NID?alt=media'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProviderStream.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProviderStream.test.ts new file mode 100644 index 000000000..ee1e353bc --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProviderStream.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// Streaming is Tauri-only (it shells the bytes through the native upload plugin +// off the disk); force the platform probe on so the provider attaches the +// streaming methods, and stub the native transfer plugin. +vi.mock('@/services/environment', () => ({ isTauriAppPlatform: () => true })); +vi.mock('@/utils/transfer', () => ({ + tauriUpload: vi.fn(async () => '{"id":"NID"}'), + tauriDownload: vi.fn(async () => ({})), +})); + +import { + createGoogleDriveProvider, + type DriveAuth, + type FetchFn, +} from '@/services/sync/providers/gdrive/GoogleDriveProvider'; +import { tauriUpload, tauriDownload } from '@/utils/transfer'; + +const auth: DriveAuth = { getAccessToken: async () => 'TOKEN' }; + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +const folder = (id: string) => ({ id, mimeType: 'application/vnd.google-apps.folder' }); +const session = (location: string | null): Response => + new Response(JSON.stringify({}), { + status: 200, + headers: location ? { Location: location } : {}, + }); + +interface Harness { + provider: ReturnType; + fetchMock: ReturnType; + url: (n: number) => string; + method: (n: number) => string | undefined; + body: (n: number) => unknown; +} + +const makeDrive = (): Harness => { + const fetchMock = vi.fn(); + const provider = createGoogleDriveProvider(auth, fetchMock as unknown as FetchFn, { + sleep: async () => {}, + }); + return { + provider, + fetchMock, + url: (n) => fetchMock.mock.calls[n]?.[0] as string, + method: (n) => (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.method, + body: (n) => { + const raw = (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.body; + return typeof raw === 'string' ? JSON.parse(raw) : undefined; + }, + }; +}; + +// Resolve the three folder segments of /Readest/books/h to existing folders. +const stageFolders = (h: Harness) => { + h.fetchMock + .mockResolvedValueOnce(json({ files: [folder('RID')] })) // Readest + .mockResolvedValueOnce(json({ files: [folder('BID')] })) // books + .mockResolvedValueOnce(json({ files: [folder('HID')] })); // h +}; + +const BOOK = '/Readest/books/h/book.epub'; + +describe('GoogleDriveProvider — streaming', () => { + // The native-transfer mocks are module-level (shared); clear call history + // between tests so per-test call-count assertions stand alone. + beforeEach(() => { + vi.mocked(tauriUpload).mockClear(); + vi.mocked(tauriDownload).mockClear(); + }); + + test('exposes uploadStream/downloadStream on Tauri', () => { + const h = makeDrive(); + expect(typeof h.provider.uploadStream).toBe('function'); + expect(typeof h.provider.downloadStream).toBe('function'); + }); + + test('uploadStream creates a new file via a resumable session and streams the bytes', async () => { + const h = makeDrive(); + stageFolders(h); + h.fetchMock + .mockResolvedValueOnce(json({ files: [] })) // findChild('book.epub') — absent + .mockResolvedValueOnce(session('https://upload.example/session/abc')); // initiation + + const ok = await h.provider.uploadStream!(BOOK, '/disk/book.epub'); + expect(ok).toBe(true); + + // Initiation is a POST to the resumable create endpoint, metadata in the body. + expect(h.method(4)).toBe('POST'); + expect(h.url(4)).toContain('uploadType=resumable'); + expect(h.body(4)).toEqual({ name: 'book.epub', parents: ['HID'] }); + + // The bytes are PUT to the session URI from disk via the native plugin. + expect(tauriUpload).toHaveBeenCalledTimes(1); + const call = vi.mocked(tauriUpload).mock.calls[0]!; + expect(call[0]).toBe('https://upload.example/session/abc'); + expect(call[1]).toBe('/disk/book.epub'); + expect(call[2]).toBe('PUT'); + }); + + test('uploadStream overwrites an existing file by id (PATCH session), preserving the id', async () => { + const h = makeDrive(); + stageFolders(h); + h.fetchMock + .mockResolvedValueOnce(json({ files: [{ id: 'EXIST' }] })) // findChild — exists + .mockResolvedValueOnce(session('https://upload.example/session/upd')); + + const ok = await h.provider.uploadStream!(BOOK, '/disk/book.epub'); + expect(ok).toBe(true); + expect(h.method(4)).toBe('PATCH'); + expect(h.url(4)).toContain('/EXIST?uploadType=resumable'); + // No parent reparent on overwrite; only the name rides in the body. + expect(h.body(4)).toEqual({ name: 'book.epub' }); + }); + + test('uploadStream returns false (no throw) when the session has no Location, and skips the PUT', async () => { + const h = makeDrive(); + stageFolders(h); + h.fetchMock + .mockResolvedValueOnce(json({ files: [] })) // findChild — absent + .mockResolvedValueOnce(session(null)); // initiation without a session URI + + const ok = await h.provider.uploadStream!(BOOK, '/disk/book.epub'); + expect(ok).toBe(false); + expect(tauriUpload).not.toHaveBeenCalled(); + }); + + test('downloadStream resolves the id and streams to disk with a bearer token', async () => { + const h = makeDrive(); + stageFolders(h); + h.fetchMock.mockResolvedValueOnce(json({ files: [{ id: 'DID' }] })); // findChild('book.epub') + + const ok = await h.provider.downloadStream!(BOOK, '/disk/dst.epub'); + expect(ok).toBe(true); + expect(tauriDownload).toHaveBeenCalledTimes(1); + const call = vi.mocked(tauriDownload).mock.calls[0]!; + expect(call[0]).toContain('/DID?alt=media'); + expect(call[1]).toBe('/disk/dst.epub'); + expect(call[3]).toEqual({ Authorization: 'Bearer TOKEN' }); + }); + + test('downloadStream returns false when the remote file is absent, and skips the GET', async () => { + const h = makeDrive(); + stageFolders(h); + h.fetchMock.mockResolvedValueOnce(json({ files: [] })); // findChild — absent + + const ok = await h.provider.downloadStream!(BOOK, '/disk/dst.epub'); + expect(ok).toBe(false); + expect(tauriDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/WebDriveAuth.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/WebDriveAuth.test.ts new file mode 100644 index 000000000..498101fae --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/WebDriveAuth.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test, vi } from 'vitest'; +import { WebDriveAuth } from '@/services/sync/providers/gdrive/WebDriveAuth'; +import { FileSyncError } from '@/services/sync/file/provider'; +import type { FetchFn } from '@/services/sync/providers/gdrive/GoogleDriveProvider'; +import type { TokenSet } from '@/services/sync/providers/oauth/tokenEndpoint'; + +const fetchStub = vi.fn(async () => new Response('{}')) as unknown as FetchFn; +const token = (overrides: Partial = {}): TokenSet => ({ + accessToken: 'AT', + expiresAt: 10_000, + ...overrides, +}); + +describe('WebDriveAuth', () => { + test('returns the stored access token while it is valid', async () => { + const auth = new WebDriveAuth( + fetchStub, + () => token({ expiresAt: 10_000 }), + () => 0, + ); + expect(await auth.getAccessToken()).toBe('AT'); + }); + + test('throws AUTH_FAILED when the stored token has expired', async () => { + const auth = new WebDriveAuth( + fetchStub, + () => token({ expiresAt: 1_000 }), + () => 5_000, + ); + const err = await auth.getAccessToken().catch((e: unknown) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect((err as FileSyncError).code).toBe('AUTH_FAILED'); + }); + + test('throws AUTH_FAILED when there is no stored token (needs reconnect)', async () => { + const auth = new WebDriveAuth( + fetchStub, + () => null, + () => 0, + ); + const err = await auth.getAccessToken().catch((e: unknown) => e); + expect((err as FileSyncError).code).toBe('AUTH_FAILED'); + }); + + test('accountLabel reads the email from about.get', async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ user: { emailAddress: 'me@example.com' } }), { status: 200 }), + ) as unknown as FetchFn; + const auth = new WebDriveAuth( + fetchFn, + () => token(), + () => 0, + ); + expect(await auth.accountLabel()).toBe('me@example.com'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/reverseDnsRedirect.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/reverseDnsRedirect.test.ts new file mode 100644 index 000000000..a024dba5d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/reverseDnsRedirect.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest'; +import { + deriveReverseDnsRedirectScheme, + deriveReverseDnsRedirectUri, + isGoogleOAuthRedirectUrl, + matchesReverseDnsRedirect, +} from '@/services/sync/providers/gdrive/auth/reverseDnsRedirect'; + +const CLIENT_ID = '123456789-AbCdEf.apps.googleusercontent.com'; + +describe('reverseDnsRedirect', () => { + test('derives the scheme by stripping the googleusercontent suffix and lowercasing', () => { + expect(deriveReverseDnsRedirectScheme(CLIENT_ID)).toBe( + 'com.googleusercontent.apps.123456789-abcdef', + ); + }); + + test('derives the full redirect URI with a single-slash path', () => { + expect(deriveReverseDnsRedirectUri(CLIENT_ID)).toBe( + 'com.googleusercontent.apps.123456789-abcdef:/oauthredirect', + ); + }); + + test('tolerates a client id that is already just the identifier part', () => { + expect(deriveReverseDnsRedirectScheme('rawid')).toBe('com.googleusercontent.apps.rawid'); + }); + + test('matches a redirect URL case-insensitively, scheme-anchored', () => { + const scheme = deriveReverseDnsRedirectScheme(CLIENT_ID); + expect(matchesReverseDnsRedirect(`${scheme}:/oauthredirect?code=x&state=y`, scheme)).toBe(true); + // Windows may relaunch with a differently-cased scheme in argv. + expect(matchesReverseDnsRedirect(`${scheme.toUpperCase()}:/oauthredirect`, scheme)).toBe(true); + }); + + test('does not match a different scheme or a prefix scheme', () => { + const scheme = deriveReverseDnsRedirectScheme(CLIENT_ID); + expect(matchesReverseDnsRedirect('readest://auth-callback', scheme)).toBe(false); + // A scheme that is a strict prefix of a longer one must not false-match. + expect(matchesReverseDnsRedirect(`${scheme}extra:/x`, scheme)).toBe(false); + }); + + test('isGoogleOAuthRedirectUrl flags any Google reverse-DNS redirect for the ingress filter', () => { + expect( + isGoogleOAuthRedirectUrl('com.googleusercontent.apps.ANY-id:/oauthredirect?code=x'), + ).toBe(true); + expect(isGoogleOAuthRedirectUrl(deriveReverseDnsRedirectUri(CLIENT_ID))).toBe(true); + // Real app/book URLs must pass through to the consumers untouched. + expect(isGoogleOAuthRedirectUrl('readest://auth-callback')).toBe(false); + expect(isGoogleOAuthRedirectUrl('file:///Users/me/book.epub')).toBe(false); + expect(isGoogleOAuthRedirectUrl('https://web.readest.com/s/abc')).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webRedirectFlow.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webRedirectFlow.test.ts new file mode 100644 index 000000000..ad8883d73 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webRedirectFlow.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { + buildImplicitAuthUrl, + consumeOAuthState, + consumeReturnPath, + parseImplicitRedirect, + tokenSetFromRedirect, +} from '@/services/sync/providers/gdrive/auth/webRedirectFlow'; + +afterEach(() => { + window.sessionStorage.clear(); +}); + +describe('buildImplicitAuthUrl', () => { + test('builds the implicit (token) authorization URL with the expected params', () => { + const url = new URL( + buildImplicitAuthUrl({ + clientId: 'web.cid', + scope: 'https://www.googleapis.com/auth/drive.file', + redirectUri: 'http://localhost:3000/gdrive-callback', + state: 'STATE123', + }), + ); + expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth'); + expect(url.searchParams.get('client_id')).toBe('web.cid'); + expect(url.searchParams.get('response_type')).toBe('token'); + expect(url.searchParams.get('redirect_uri')).toBe('http://localhost:3000/gdrive-callback'); + expect(url.searchParams.get('scope')).toBe('https://www.googleapis.com/auth/drive.file'); + expect(url.searchParams.get('state')).toBe('STATE123'); + expect(url.searchParams.get('include_granted_scopes')).toBe('true'); + }); +}); + +describe('parseImplicitRedirect', () => { + test('parses a successful token fragment and computes a future expiry', () => { + const before = Date.now(); + const r = parseImplicitRedirect('#access_token=AT&token_type=Bearer&expires_in=3600&state=S1'); + expect(r.accessToken).toBe('AT'); + expect(r.state).toBe('S1'); + expect(r.error).toBeUndefined(); + expect(r.expiresAt).toBeGreaterThan(before + 3000 * 1000); + expect(tokenSetFromRedirect(r)).toEqual({ accessToken: 'AT', expiresAt: r.expiresAt }); + }); + + test('parses an error fragment and yields no token', () => { + const r = parseImplicitRedirect('#error=access_denied&state=S1'); + expect(r.error).toBe('access_denied'); + expect(r.accessToken).toBeUndefined(); + expect(tokenSetFromRedirect(r)).toBeNull(); + }); +}); + +describe('consumeOAuthState / consumeReturnPath', () => { + test('state is read once then cleared', () => { + window.sessionStorage.setItem('gdrive_web_oauth_state', 'XYZ'); + expect(consumeOAuthState()).toBe('XYZ'); + expect(consumeOAuthState()).toBeNull(); + }); + + test('return path falls back to "/" and rejects non-same-origin paths', () => { + expect(consumeReturnPath()).toBe('/'); + window.sessionStorage.setItem('gdrive_web_oauth_return', '/library?q=1'); + expect(consumeReturnPath()).toBe('/library?q=1'); + window.sessionStorage.setItem('gdrive_web_oauth_return', '//evil.com'); + expect(consumeReturnPath()).toBe('/'); + window.sessionStorage.setItem('gdrive_web_oauth_return', 'https://evil.com'); + expect(consumeReturnPath()).toBe('/'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts new file mode 100644 index 000000000..9efd7314b --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { + clearWebDriveToken, + hasValidWebDriveToken, + loadWebDriveToken, + saveWebDriveToken, +} from '@/services/sync/providers/gdrive/auth/webTokenStore'; + +afterEach(() => { + window.sessionStorage.clear(); +}); + +describe('webTokenStore', () => { + test('round-trips a token set through sessionStorage', () => { + expect(loadWebDriveToken()).toBeNull(); + saveWebDriveToken({ accessToken: 'AT', expiresAt: 123 }); + expect(loadWebDriveToken()).toEqual({ accessToken: 'AT', expiresAt: 123 }); + }); + + test('clear removes the stored token', () => { + saveWebDriveToken({ accessToken: 'AT', expiresAt: 123 }); + clearWebDriveToken(); + expect(loadWebDriveToken()).toBeNull(); + }); + + test('returns null for an unparseable stored value', () => { + window.sessionStorage.setItem('gdrive_web_token', 'not json'); + expect(loadWebDriveToken()).toBeNull(); + }); + + test('hasValidWebDriveToken reflects presence + expiry', () => { + expect(hasValidWebDriveToken(1_000)).toBe(false); // none stored + saveWebDriveToken({ accessToken: 'AT', expiresAt: 5_000 }); + expect(hasValidWebDriveToken(1_000)).toBe(true); // not yet expired + expect(hasValidWebDriveToken(9_000)).toBe(false); // expired + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts new file mode 100644 index 000000000..9a791f47d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); +vi.mock('@/services/environment', () => ({ + isTauriAppPlatform: vi.fn(), + isWebAppPlatform: vi.fn(), +})); +vi.mock('@/utils/bridge', () => ({ + isSyncKeychainAvailable: vi.fn(), + getSecureItem: vi.fn(), + setSecureItem: vi.fn(), + clearSecureItem: vi.fn(), +})); + +import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; +import { isSyncKeychainAvailable } from '@/utils/bridge'; +import { + buildGoogleDriveProvider, + getGoogleClientId, + getGoogleWebClientId, +} from '@/services/sync/providers/gdrive/buildGoogleDriveProvider'; + +const CLIENT_ID = 'cid.apps.googleusercontent.com'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe('buildGoogleDriveProvider', () => { + test('falls back to the baked official client id when the env override is unset', async () => { + vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', ''); + expect(getGoogleClientId()).toMatch(/\.apps\.googleusercontent\.com$/); + // With a baked default + keychain, Drive builds even without an env override. + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: true }); + expect(await buildGoogleDriveProvider()).not.toBeNull(); + }); + + test('the env override wins over the baked default', () => { + vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', 'forked.apps.googleusercontent.com'); + expect(getGoogleClientId()).toBe('forked.apps.googleusercontent.com'); + }); + + test('returns null off-Tauri (no secure token storage for the refresh token)', async () => { + vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', CLIENT_ID); + vi.mocked(isTauriAppPlatform).mockReturnValue(false); + expect(await buildGoogleDriveProvider()).toBeNull(); + expect(isSyncKeychainAvailable).not.toHaveBeenCalled(); + }); + + test('returns null when the keychain is unavailable', async () => { + vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', CLIENT_ID); + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: false }); + expect(await buildGoogleDriveProvider()).toBeNull(); + }); + + test('builds a provider when client id + keychain are available', async () => { + vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', CLIENT_ID); + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: true }); + const provider = await buildGoogleDriveProvider(); + expect(provider).not.toBeNull(); + expect(provider?.rootPath).toBe('/'); + }); + + test('web: falls back to the baked official web client id when the env override is unset', async () => { + vi.stubEnv('NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID', ''); + vi.mocked(isWebAppPlatform).mockReturnValue(true); + expect(getGoogleWebClientId()).toMatch(/\.apps\.googleusercontent\.com$/); + const provider = await buildGoogleDriveProvider(); + expect(provider).not.toBeNull(); + expect(provider?.rootPath).toBe('/'); + // The web path never touches the keychain. + expect(isSyncKeychainAvailable).not.toHaveBeenCalled(); + }); + + test('web: the env override wins over the baked web default', async () => { + vi.stubEnv('NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID', 'forked-web.apps.googleusercontent.com'); + vi.mocked(isWebAppPlatform).mockReturnValue(true); + expect(getGoogleWebClientId()).toBe('forked-web.apps.googleusercontent.com'); + const provider = await buildGoogleDriveProvider(); + expect(provider).not.toBeNull(); + expect(provider?.rootPath).toBe('/'); + expect(isSyncKeychainAvailable).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/connectGoogleDrive.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/connectGoogleDrive.test.ts new file mode 100644 index 000000000..0a3d028fb --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/connectGoogleDrive.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + connectGoogleDrive, + disconnectGoogleDrive, + DRIVE_FILE_SCOPE, +} from '@/services/sync/providers/gdrive/connectGoogleDrive'; +import { buildGoogleOAuthConfig } from '@/services/sync/providers/gdrive/googleOAuthConfig'; +import { FileSyncError } from '@/services/sync/file/provider'; +import type { FetchFn } from '@/services/sync/providers/gdrive/GoogleDriveProvider'; +import type { TokenPersistence } from '@/services/sync/providers/gdrive/driveTokenStore'; +import type { TokenSet } from '@/services/sync/providers/oauth/tokenEndpoint'; + +const tokens: TokenSet = { accessToken: 'AT', refreshToken: 'RT', expiresAt: 9_999_999_999_999 }; + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +const makePersistence = (): TokenPersistence & { + save: ReturnType; + clear: ReturnType; +} => ({ + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), +}); + +describe('connectGoogleDrive', () => { + test('runs OAuth with the drive.file scope, saves the token, returns the account label', async () => { + const persistence = makePersistence(); + const runOAuth = vi.fn(async () => tokens); + const fetchFn = vi.fn(async (url: string) => + url.includes('/about') + ? json({ user: { emailAddress: 'a@b.com' } }) + : new Response('', { status: 404 }), + ); + + const res = await connectGoogleDrive({ + clientId: 'cid', + fetchFn: fetchFn as unknown as FetchFn, + persistence, + runOAuth, + }); + + expect(runOAuth).toHaveBeenCalledWith(buildGoogleOAuthConfig('cid', DRIVE_FILE_SCOPE), fetchFn); + expect(persistence.save).toHaveBeenCalledWith(tokens); + expect(res.accountLabel).toBe('a@b.com'); + }); + + test('does NOT report connected when the token cannot be persisted', async () => { + const persistence = makePersistence(); + persistence.save.mockRejectedValueOnce(new FileSyncError('keychain denied', 'AUTH_FAILED')); + await expect( + connectGoogleDrive({ + clientId: 'cid', + fetchFn: vi.fn() as unknown as FetchFn, + persistence, + runOAuth: async () => tokens, + }), + ).rejects.toThrow(/keychain denied/); + }); + + test('account label is best-effort: null when about.get fails, token still saved', async () => { + const persistence = makePersistence(); + const fetchFn = vi.fn(async () => new Response('', { status: 500 })); + const res = await connectGoogleDrive({ + clientId: 'cid', + fetchFn: fetchFn as unknown as FetchFn, + persistence, + runOAuth: async () => tokens, + }); + expect(res.accountLabel).toBeNull(); + expect(persistence.save).toHaveBeenCalledWith(tokens); + }); +}); + +describe('disconnectGoogleDrive', () => { + test('clears the stored token', async () => { + const persistence = makePersistence(); + await disconnectGoogleDrive(persistence); + expect(persistence.clear).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveRest.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveRest.test.ts new file mode 100644 index 000000000..1a7f05888 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveRest.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'vitest'; +import { + aboutUrl, + childrenQuery, + deleteUrl, + escapeDriveLiteral, + FILES_ENDPOINT, + listQuery, + listUrl, + mediaDownloadUrl, + mediaUpdateUrl, + metadataUrl, + reparentUrl, + resumableCreateUrl, + resumableUpdateUrl, + simpleUploadUrl, +} from '@/services/sync/providers/gdrive/driveRest'; + +describe('driveRest', () => { + test('escapes single quotes in query literals', () => { + expect(escapeDriveLiteral("O'Brien")).toBe("O\\'Brien"); + }); + + test('escapes backslashes (first) so they cannot break out of the literal', () => { + // A lone backslash is doubled. + expect(escapeDriveLiteral('a\\b')).toBe('a\\\\b'); + // A trailing backslash would otherwise escape the closing quote. + expect(escapeDriveLiteral('dir\\')).toBe('dir\\\\'); + // Backslash-then-quote: backslash doubled, then the quote escaped — never + // \' (which Drive would read as an escaped quote from the original input). + expect(escapeDriveLiteral("a\\'b")).toBe("a\\\\\\'b"); + }); + + test('listQuery scopes by name + parent + not-trashed', () => { + expect(listQuery('config.json', 'PARENT')).toBe( + "name = 'config.json' and 'PARENT' in parents and trashed = false", + ); + }); + + test('childrenQuery enumerates live children of a parent', () => { + expect(childrenQuery('PARENT')).toBe("'PARENT' in parents and trashed = false"); + }); + + test('mediaDownloadUrl uses alt=media', () => { + expect(mediaDownloadUrl('FID')).toBe(`${FILES_ENDPOINT}/FID?alt=media`); + }); + + test('upload URLs use uploadType=media and request id/md5/size', () => { + expect(simpleUploadUrl()).toContain('uploadType=media'); + expect(simpleUploadUrl()).toContain('fields=id,md5Checksum,size'); + expect(mediaUpdateUrl('FID')).toContain('/FID?uploadType=media'); + }); + + test('resumable upload URLs use uploadType=resumable and request the id', () => { + expect(resumableCreateUrl()).toBe( + 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id', + ); + expect(resumableUpdateUrl('FID')).toBe( + 'https://www.googleapis.com/upload/drive/v3/files/FID?uploadType=resumable&fields=id', + ); + }); + + test('metadataUrl + deleteUrl target the file id', () => { + expect(metadataUrl('FID')).toBe( + `${FILES_ENDPOINT}/FID?fields=id,name,mimeType,size,modifiedTime,md5Checksum`, + ); + expect(deleteUrl('FID')).toBe(`${FILES_ENDPOINT}/FID`); + }); + + test('reparentUrl moves a file from one parent to another via query params', () => { + const url = new URL(reparentUrl('FID', 'NEWPARENT', 'root')); + expect(url.pathname.endsWith('/FID')).toBe(true); + expect(url.searchParams.get('addParents')).toBe('NEWPARENT'); + expect(url.searchParams.get('removeParents')).toBe('root'); + }); + + test('aboutUrl requests only the user identity fields', () => { + expect(aboutUrl()).toBe( + 'https://www.googleapis.com/drive/v3/about?fields=user(displayName,emailAddress)', + ); + }); + + test('listUrl requests nextPageToken + a page size and threads a page token', () => { + const first = new URL(listUrl(childrenQuery('PARENT'))); + expect(first.searchParams.get('q')).toBe("'PARENT' in parents and trashed = false"); + expect(first.searchParams.get('fields')).toBe( + 'nextPageToken,files(id,name,mimeType,size,modifiedTime,md5Checksum)', + ); + expect(first.searchParams.get('pageSize')).toBe('1000'); + expect(first.searchParams.get('pageToken')).toBeNull(); + + const next = new URL(listUrl(childrenQuery('PARENT'), 'TOKEN2')); + expect(next.searchParams.get('pageToken')).toBe('TOKEN2'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveTokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveTokenStore.test.ts new file mode 100644 index 000000000..e4292af42 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveTokenStore.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/services/environment', () => ({ isTauriAppPlatform: vi.fn() })); + +import { isTauriAppPlatform } from '@/services/environment'; +import { + createDriveTokenPersistence, + DRIVE_TOKEN_KEY, +} from '@/services/sync/providers/gdrive/driveTokenStore'; + +afterEach(() => vi.clearAllMocks()); + +describe('driveTokenStore', () => { + test('DRIVE_TOKEN_KEY is the stable Drive keychain slot', () => { + expect(DRIVE_TOKEN_KEY).toBe('gdrive_token_set'); + }); + + test('createDriveTokenPersistence returns null off-Tauri', async () => { + vi.mocked(isTauriAppPlatform).mockReturnValue(false); + expect(await createDriveTokenPersistence()).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/googleDriveAuth.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/googleDriveAuth.test.ts new file mode 100644 index 000000000..0e0d061f9 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/googleDriveAuth.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test, vi } from 'vitest'; +import { resolveGoogleAccountLabel } from '@/services/sync/providers/gdrive/googleDriveAuth'; +import type { FetchFn } from '@/services/sync/providers/oauth/tokenEndpoint'; + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('resolveGoogleAccountLabel', () => { + test('returns the email address from about.get', async () => { + const fetchFn = vi.fn(async () => + json({ user: { emailAddress: 'a@b.com', displayName: 'A B' } }), + ) as unknown as FetchFn; + expect(await resolveGoogleAccountLabel('AT', fetchFn)).toBe('a@b.com'); + }); + + test('falls back to the display name when emailAddress is absent', async () => { + const fetchFn = vi.fn(async () => json({ user: { displayName: 'A B' } })) as unknown as FetchFn; + expect(await resolveGoogleAccountLabel('AT', fetchFn)).toBe('A B'); + }); + + test('returns null on a non-ok response', async () => { + const fetchFn = vi.fn(async () => json({}, 500)) as unknown as FetchFn; + expect(await resolveGoogleAccountLabel('AT', fetchFn)).toBeNull(); + }); + + test('sends the access token as a bearer header against the about.get URL', async () => { + const fetchFn = vi.fn(async () => json({ user: {} })) as unknown as FetchFn; + await resolveGoogleAccountLabel('AT', fetchFn); + expect(fetchFn).toHaveBeenCalledWith( + expect.stringContaining('/about'), + expect.objectContaining({ headers: { Authorization: 'Bearer AT' } }), + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/googleOAuthConfig.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/googleOAuthConfig.test.ts new file mode 100644 index 000000000..1dc1601e0 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/googleOAuthConfig.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest'; +import { buildGoogleOAuthConfig } from '@/services/sync/providers/gdrive/googleOAuthConfig'; + +describe('buildGoogleOAuthConfig', () => { + test('derives the reverse-DNS redirect and Google endpoints from the client id', () => { + const cfg = buildGoogleOAuthConfig( + '209390247301-abc.apps.googleusercontent.com', + 'https://www.googleapis.com/auth/drive.file', + ); + expect(cfg.redirectUri).toBe('com.googleusercontent.apps.209390247301-abc:/oauthredirect'); + expect(cfg.redirectScheme).toBe('com.googleusercontent.apps.209390247301-abc'); + expect(cfg.authEndpoint).toBe('https://accounts.google.com/o/oauth2/v2/auth'); + expect(cfg.tokenEndpoint).toBe('https://oauth2.googleapis.com/token'); + expect(cfg.authParams).toEqual({ access_type: 'offline', prompt: 'consent' }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/keychainTokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/keychainTokenStore.test.ts new file mode 100644 index 000000000..9784c0f23 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/keychainTokenStore.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/utils/bridge', () => ({ + getSecureItem: vi.fn(), + setSecureItem: vi.fn(), + clearSecureItem: vi.fn(), + isSyncKeychainAvailable: vi.fn(), +})); +vi.mock('@/services/environment', () => ({ isTauriAppPlatform: vi.fn() })); + +import { + clearSecureItem, + getSecureItem, + isSyncKeychainAvailable, + setSecureItem, +} from '@/utils/bridge'; +import { isTauriAppPlatform } from '@/services/environment'; +import { FileSyncError } from '@/services/sync/file/provider'; +import { + createKeychainTokenPersistence, + KeychainTokenPersistence, +} from '@/services/sync/providers/oauth/keychainTokenStore'; + +const KEY = 'gdrive_token_set'; +const LABEL = 'Google Drive'; + +const tokens = { accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 }; + +afterEach(() => vi.clearAllMocks()); + +describe('KeychainTokenPersistence', () => { + test('save serialises through the keyed secure-KV and fails loud on rejection', async () => { + vi.mocked(setSecureItem).mockResolvedValueOnce({ success: true }); + await new KeychainTokenPersistence(KEY, LABEL).save(tokens); + expect(setSecureItem).toHaveBeenCalledWith({ + key: KEY, + value: JSON.stringify(tokens), + }); + + vi.mocked(setSecureItem).mockResolvedValueOnce({ success: false, error: 'denied' }); + await expect(new KeychainTokenPersistence(KEY, LABEL).save(tokens)).rejects.toBeInstanceOf( + FileSyncError, + ); + }); + + test('save error message includes the provider label', async () => { + vi.mocked(setSecureItem).mockResolvedValueOnce({ success: false, error: 'denied' }); + await expect(new KeychainTokenPersistence(KEY, LABEL).save(tokens)).rejects.toThrow(LABEL); + }); + + test('load parses a stored token set; returns null when absent or on error', async () => { + vi.mocked(getSecureItem).mockResolvedValueOnce({ value: JSON.stringify(tokens) }); + expect(await new KeychainTokenPersistence(KEY, LABEL).load()).toEqual(tokens); + + vi.mocked(getSecureItem).mockResolvedValueOnce({ error: 'no item' }); + expect(await new KeychainTokenPersistence(KEY, LABEL).load()).toBeNull(); + + vi.mocked(getSecureItem).mockResolvedValueOnce({}); + expect(await new KeychainTokenPersistence(KEY, LABEL).load()).toBeNull(); + }); + + test('clear delegates to the keyed secure-KV', async () => { + vi.mocked(clearSecureItem).mockResolvedValueOnce({ success: true }); + await new KeychainTokenPersistence(KEY, LABEL).clear(); + expect(clearSecureItem).toHaveBeenCalledWith({ key: KEY }); + }); +}); + +describe('createKeychainTokenPersistence', () => { + test('returns null off-Tauri (no ephemeral fallback for the refresh token)', async () => { + vi.mocked(isTauriAppPlatform).mockReturnValue(false); + expect(await createKeychainTokenPersistence(KEY, LABEL)).toBeNull(); + expect(isSyncKeychainAvailable).not.toHaveBeenCalled(); + }); + + test('returns a keychain store when the probe reports available', async () => { + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValueOnce({ available: true }); + expect(await createKeychainTokenPersistence(KEY, LABEL)).toBeInstanceOf( + KeychainTokenPersistence, + ); + }); + + test('returns null when the keychain is unavailable', async () => { + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValueOnce({ available: false }); + expect(await createKeychainTokenPersistence(KEY, LABEL)).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthAndroid.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthAndroid.test.ts new file mode 100644 index 000000000..d0efbc5c5 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthAndroid.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('@/app/auth/utils/nativeAuth', () => ({ + authWithCustomTab: vi.fn(async ({ authUrl }: { authUrl: string }) => { + // The native Custom Tab echoes the redirect, with the exact `state` the + // flow put on the consent URL. + const state = new URL(authUrl).searchParams.get('state'); + return { + redirectUrl: `com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=${state}`, + }; + }), +})); + +import { runAndroidOAuth } from '@/services/sync/providers/oauth/oauthAndroid'; +import { authWithCustomTab } from '@/app/auth/utils/nativeAuth'; +import type { FetchFn } from '@/services/sync/providers/oauth/tokenEndpoint'; +import type { OAuthClientConfig } from '@/services/sync/providers/oauth/oauthFlow'; + +const CLIENT_ID = 'cid.apps.googleusercontent.com'; + +const CONFIG: OAuthClientConfig = { + clientId: CLIENT_ID, + scope: 'drive.file', + authEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + redirectUri: 'com.googleusercontent.apps.cid:/oauthredirect', + redirectScheme: 'com.googleusercontent.apps.cid', + authParams: { access_type: 'offline', prompt: 'consent' }, +}; + +const tokenJson = (): Response => + new Response(JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +describe('runAndroidOAuth', () => { + test('opens a Custom Tab, captures the redirect, and exchanges the code', async () => { + const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn; + const tokens = await runAndroidOAuth(CONFIG, fetchFn); + + expect(authWithCustomTab).toHaveBeenCalledTimes(1); + // The Custom Tab is handed the consent URL (with the PKCE challenge + state). + const authUrl = vi.mocked(authWithCustomTab).mock.calls[0]![0].authUrl; + expect(authUrl).toContain('code_challenge='); + expect(authUrl).toContain('accounts.google.com'); + expect(tokens.accessToken).toBe('AT'); + expect(tokens.refreshToken).toBe('RT'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('propagates a CSRF state mismatch from the redirect', async () => { + vi.mocked(authWithCustomTab).mockResolvedValueOnce({ + redirectUrl: 'com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=ATTACKER', + }); + const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn; + await expect(runAndroidOAuth(CONFIG, fetchFn)).rejects.toThrow(/state mismatch/i); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthDesktop.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthDesktop.test.ts new file mode 100644 index 000000000..04cb30d3c --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthDesktop.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + runDesktopDeepLinkOAuth, + type DesktopDeepLinkDeps, +} from '@/services/sync/providers/oauth/oauthDesktop'; +import type { OAuthClientConfig } from '@/services/sync/providers/oauth/oauthFlow'; + +const CLIENT_ID = 'cid.apps.googleusercontent.com'; +const REDIRECT = 'com.googleusercontent.apps.cid:/oauthredirect'; + +const CONFIG: OAuthClientConfig = { + clientId: CLIENT_ID, + scope: 'drive.file', + authEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + redirectUri: REDIRECT, + redirectScheme: 'com.googleusercontent.apps.cid', + authParams: { access_type: 'offline', prompt: 'consent' }, +}; + +const tokenJson = (): Response => + new Response(JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +const exchangeFetch = () => vi.fn(async () => tokenJson()); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const baseDeps = (over: Partial): DesktopDeepLinkDeps => ({ + openDefaultBrowser: vi.fn(async () => {}), + spawnFreshBrowser: vi.fn(async () => {}), + subscribeRedirects: vi.fn(async () => () => {}), + fallbackDelayMs: 25_000, + connectDeadlineMs: 900_000, + ...over, +}); + +// These tests use real (tiny) timeouts rather than fake timers: the PKCE +// challenge runs on `crypto.subtle.digest`, which resolves on Node's threadpool +// and is therefore not flushed by fake timers. +describe('runDesktopDeepLinkOAuth', () => { + test('opens the default browser, captures the routed redirect, and exchanges the code', async () => { + let onUrl!: (url: string) => void; + const deps = baseDeps({ + subscribeRedirects: vi.fn(async (cb) => { + onUrl = cb; + return () => {}; + }), + // Simulate the OS routing the redirect back once consent opens, echoing + // the exact `state` the flow generated (read off the consent URL). + openDefaultBrowser: vi.fn(async (url) => { + const state = new URL(url).searchParams.get('state'); + queueMicrotask(() => onUrl(`${REDIRECT}?code=CODE&state=${state}`)); + }), + }); + const fetchFn = exchangeFetch(); + + const tokens = await runDesktopDeepLinkOAuth(CONFIG, fetchFn, deps); + + expect(deps.openDefaultBrowser).toHaveBeenCalledTimes(1); + expect(tokens.accessToken).toBe('AT'); + expect(tokens.refreshToken).toBe('RT'); + expect(fetchFn).toHaveBeenCalledTimes(1); + // The redirect arrived in time, so the cold-browser fallback never fired. + expect(deps.spawnFreshBrowser).not.toHaveBeenCalled(); + }); + + test('fires the cold-browser fallback with the consent URL, then rejects at the deadline', async () => { + const spawnFreshBrowser = vi.fn(async (_url: string) => {}); + const deps = baseDeps({ + subscribeRedirects: vi.fn(async () => () => {}), + spawnFreshBrowser, + fallbackDelayMs: 10, + connectDeadlineMs: 50, + }); + const result = runDesktopDeepLinkOAuth(CONFIG, exchangeFetch(), deps).catch( + (e: Error) => e.message, + ); + + await delay(90); + expect(spawnFreshBrowser).toHaveBeenCalledTimes(1); + expect(spawnFreshBrowser.mock.calls[0]![0]).toContain('accounts.google.com'); + expect(await result).toMatch(/did not complete in time/); + }); + + test('rejects when no redirect arrives before the hard deadline', async () => { + const deps = baseDeps({ + subscribeRedirects: vi.fn(async () => () => {}), + fallbackDelayMs: 1_000_000, + connectDeadlineMs: 20, + }); + await expect(runDesktopDeepLinkOAuth(CONFIG, exchangeFetch(), deps)).rejects.toThrow( + /did not complete in time/, + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthFlow.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthFlow.test.ts new file mode 100644 index 000000000..dc9b27d60 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthFlow.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test, vi } from 'vitest'; +import { runOAuthFlow, type OAuthFlowDeps } from '@/services/sync/providers/oauth/oauthFlow'; + +const REDIRECT_URI = 'com.googleusercontent.apps.cid:/oauthredirect'; +const AUTH_ENDPOINT = 'https://example.com/oauth2/authorize'; + +const makeDeps = (overrides: Partial, order: string[]): OAuthFlowDeps => ({ + createPkcePair: async () => ({ verifier: 'VER', challenge: 'CHAL' }), + newState: () => 'STATE', + clientId: 'cid', + redirectUri: REDIRECT_URI, + authEndpoint: AUTH_ENDPOINT, + openUrl: async () => { + order.push('open'); + }, + awaitRedirect: async () => { + order.push('await'); + return `${REDIRECT_URI}?code=CODE&state=STATE`; + }, + exchange: async () => ({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 }), + ...overrides, +}); + +describe('runOAuthFlow', () => { + test('arms the redirect capture before opening, then exchanges the code', async () => { + const order: string[] = []; + let openedUrl = ''; + const exchange = vi.fn(async () => ({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 })); + const deps = makeDeps( + { + openUrl: async (url) => { + order.push('open'); + openedUrl = url; + }, + exchange, + }, + order, + ); + + const tokens = await runOAuthFlow('drive.file', deps); + + // The capture must be armed before the consent URL is opened (redirect race). + expect(order).toEqual(['await', 'open']); + expect(openedUrl).toContain('code_challenge=CHAL'); + expect(openedUrl).toContain('state=STATE'); + expect(exchange).toHaveBeenCalledWith({ + code: 'CODE', + verifier: 'VER', + redirectUri: REDIRECT_URI, + }); + expect(tokens).toEqual({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 }); + }); + + test('propagates a CSRF state mismatch from the redirect', async () => { + const order: string[] = []; + const deps = makeDeps( + { awaitRedirect: async () => `${REDIRECT_URI}?code=CODE&state=ATTACKER` }, + order, + ); + await expect(runOAuthFlow('drive.file', deps)).rejects.toThrow(/state mismatch/i); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthIos.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthIos.test.ts new file mode 100644 index 000000000..0795af6a5 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/oauthIos.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('@/app/auth/utils/nativeAuth', () => ({ + authWithSafari: vi.fn(async ({ authUrl }: { authUrl: string; callbackScheme?: string }) => { + // The native web-auth session echoes the redirect, with the exact `state` + // the flow put on the consent URL. + const state = new URL(authUrl).searchParams.get('state'); + return { + redirectUrl: `com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=${state}`, + }; + }), +})); + +import { runIosOAuth } from '@/services/sync/providers/oauth/oauthIos'; +import { authWithSafari } from '@/app/auth/utils/nativeAuth'; +import type { FetchFn } from '@/services/sync/providers/oauth/tokenEndpoint'; +import type { OAuthClientConfig } from '@/services/sync/providers/oauth/oauthFlow'; + +const CLIENT_ID = 'cid.apps.googleusercontent.com'; + +const CONFIG: OAuthClientConfig = { + clientId: CLIENT_ID, + scope: 'drive.file', + authEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + redirectUri: 'com.googleusercontent.apps.cid:/oauthredirect', + redirectScheme: 'com.googleusercontent.apps.cid', + authParams: { access_type: 'offline', prompt: 'consent' }, +}; + +const tokenJson = (): Response => + new Response(JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +describe('runIosOAuth', () => { + test('opens a web-auth session keyed to the reverse-DNS scheme and exchanges the code', async () => { + const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn; + const tokens = await runIosOAuth(CONFIG, fetchFn); + + expect(authWithSafari).toHaveBeenCalledTimes(1); + const arg = vi.mocked(authWithSafari).mock.calls[0]![0]; + // The session is handed the consent URL (with the PKCE challenge + state)... + expect(arg.authUrl).toContain('code_challenge='); + expect(arg.authUrl).toContain('accounts.google.com'); + // ...and the bare reverse-DNS callback scheme (no path) it must intercept. + expect(arg.callbackScheme).toBe('com.googleusercontent.apps.cid'); + expect(tokens.accessToken).toBe('AT'); + expect(tokens.refreshToken).toBe('RT'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + test('propagates a CSRF state mismatch from the redirect', async () => { + vi.mocked(authWithSafari).mockResolvedValueOnce({ + redirectUrl: 'com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=ATTACKER', + }); + const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn; + await expect(runIosOAuth(CONFIG, fetchFn)).rejects.toThrow(/state mismatch/i); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/parseRedirect.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/parseRedirect.test.ts new file mode 100644 index 000000000..c83060e29 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/parseRedirect.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest'; +import { parseRedirect } from '@/services/sync/providers/oauth/parseRedirect'; + +const REDIRECT_URI = 'com.googleusercontent.apps.cid:/oauthredirect'; + +describe('parseRedirect', () => { + test('returns the code when target, state and code are all valid', () => { + const url = `${REDIRECT_URI}?code=AUTH_CODE&state=STATE`; + expect(parseRedirect(url, 'STATE', REDIRECT_URI)).toEqual({ code: 'AUTH_CODE' }); + }); + + test('rejects a URL aimed at a different scheme/path (target guard)', () => { + const url = 'readest://auth-callback?code=AUTH_CODE&state=STATE'; + expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/target mismatch/i); + }); + + test('rejects a right-scheme but wrong-path redirect', () => { + const url = 'com.googleusercontent.apps.cid:/somethingelse?code=AUTH_CODE&state=STATE'; + expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/target mismatch/i); + }); + + test('surfaces a provider error param ahead of the CSRF/code checks', () => { + const url = `${REDIRECT_URI}?error=access_denied&state=WRONG`; + expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/access_denied/); + }); + + test('rejects a state mismatch (CSRF guard)', () => { + const url = `${REDIRECT_URI}?code=AUTH_CODE&state=WRONG`; + expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/state mismatch/i); + }); + + test('rejects a redirect with no code', () => { + const url = `${REDIRECT_URI}?state=STATE`; + expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow( + /missing the authorization code/i, + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/persistedOAuth.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/persistedOAuth.test.ts new file mode 100644 index 000000000..4e5dbc6dd --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/persistedOAuth.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test, vi } from 'vitest'; +import { PersistedOAuth } from '@/services/sync/providers/oauth/persistedOAuth'; +import type { FetchFn, TokenSet } from '@/services/sync/providers/oauth/tokenEndpoint'; +import type { TokenPersistence } from '@/services/sync/providers/oauth/keychainTokenStore'; + +const TOKEN_ENDPOINT = 'https://example.com/token'; + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +const makePersistence = (initial: TokenSet | null = null) => { + let stored = initial; + return { + load: vi.fn(async () => stored), + save: vi.fn(async (t: TokenSet) => { + stored = t; + }), + clear: vi.fn(async () => { + stored = null; + }), + } satisfies TokenPersistence & { save: ReturnType }; +}; + +/** Stub account-label resolver: the shared class only needs to delegate to it. */ +const stubResolveAccountLabel = vi.fn(async () => 'stub-label'); + +describe('PersistedOAuth', () => { + test('returns the seeded access token without any network or load', async () => { + const fetchFn = vi.fn(); + const persistence = makePersistence(); + const auth = new PersistedOAuth({ + clientId: 'cid', + tokenEndpoint: TOKEN_ENDPOINT, + fetchFn: fetchFn as unknown as FetchFn, + persistence, + providerLabel: 'Test', + resolveAccountLabel: stubResolveAccountLabel, + initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 1000 }, + now: () => 500, + }); + expect(await auth.getAccessToken()).toBe('AT'); + expect(fetchFn).not.toHaveBeenCalled(); + expect(persistence.load).not.toHaveBeenCalled(); + }); + + test('lazily loads tokens from persistence when not seeded', async () => { + const persistence = makePersistence({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 1000 }); + const auth = new PersistedOAuth({ + clientId: 'cid', + tokenEndpoint: TOKEN_ENDPOINT, + fetchFn: vi.fn() as unknown as FetchFn, + persistence, + providerLabel: 'Test', + resolveAccountLabel: stubResolveAccountLabel, + now: () => 500, + }); + expect(await auth.getAccessToken()).toBe('AT'); + expect(persistence.load).toHaveBeenCalledTimes(1); + }); + + test('refreshes an expired token, carries the old refresh token forward, saves once', async () => { + const persistence = makePersistence(); + // Some providers (e.g. Google) omit refresh_token on a refresh response. + const fetchFn = vi.fn(async () => json({ access_token: 'AT2', expires_in: 3600 })); + const auth = new PersistedOAuth({ + clientId: 'cid', + tokenEndpoint: TOKEN_ENDPOINT, + fetchFn: fetchFn as unknown as FetchFn, + persistence, + providerLabel: 'Test', + resolveAccountLabel: stubResolveAccountLabel, + initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 0 }, + now: () => 1000, + }); + expect(await auth.getAccessToken()).toBe('AT2'); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(persistence.save).toHaveBeenCalledTimes(1); + const saved = persistence.save.mock.calls[0]![0] as TokenSet; + expect(saved).toMatchObject({ accessToken: 'AT2', refreshToken: 'RT' }); + }); + + test('collapses concurrent refreshes into a single network call + single save', async () => { + let resolveFetch!: (res: Response) => void; + const fetchFn = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const persistence = makePersistence(); + const auth = new PersistedOAuth({ + clientId: 'cid', + tokenEndpoint: TOKEN_ENDPOINT, + fetchFn: fetchFn as unknown as FetchFn, + persistence, + providerLabel: 'Test', + resolveAccountLabel: stubResolveAccountLabel, + initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 0 }, + now: () => 1000, + }); + + const p1 = auth.getAccessToken(); + const p2 = auth.getAccessToken(); + const p3 = auth.getAccessToken(); + resolveFetch(json({ access_token: 'AT2', expires_in: 3600 })); + expect(await Promise.all([p1, p2, p3])).toEqual(['AT2', 'AT2', 'AT2']); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(persistence.save).toHaveBeenCalledTimes(1); + }); + + test('throws AUTH_FAILED naming the provider when there are no tokens at all', async () => { + const auth = new PersistedOAuth({ + clientId: 'cid', + tokenEndpoint: TOKEN_ENDPOINT, + fetchFn: vi.fn() as unknown as FetchFn, + persistence: makePersistence(null), + providerLabel: 'Test', + resolveAccountLabel: stubResolveAccountLabel, + }); + await expect(auth.getAccessToken()).rejects.toMatchObject({ code: 'AUTH_FAILED' }); + await expect(auth.getAccessToken()).rejects.toThrow(/Test is not connected/); + }); + + test('throws AUTH_FAILED when expired with no refresh token', async () => { + const auth = new PersistedOAuth({ + clientId: 'cid', + tokenEndpoint: TOKEN_ENDPOINT, + fetchFn: vi.fn() as unknown as FetchFn, + persistence: makePersistence(), + providerLabel: 'Test', + resolveAccountLabel: stubResolveAccountLabel, + initialTokens: { accessToken: 'AT', expiresAt: 0 }, + now: () => 1000, + }); + await expect(auth.getAccessToken()).rejects.toMatchObject({ code: 'AUTH_FAILED' }); + }); + + test('accountLabel delegates to the injected resolver with the current access token', async () => { + const resolveAccountLabel = vi.fn(async (accessToken: string) => `label-for-${accessToken}`); + const auth = new PersistedOAuth({ + clientId: 'cid', + tokenEndpoint: TOKEN_ENDPOINT, + fetchFn: vi.fn() as unknown as FetchFn, + persistence: makePersistence(), + providerLabel: 'Test', + resolveAccountLabel, + initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 9999 }, + now: () => 0, + }); + expect(await auth.accountLabel()).toBe('label-for-AT'); + expect(resolveAccountLabel).toHaveBeenCalledWith('AT', expect.any(Function)); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/pkce.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/pkce.test.ts new file mode 100644 index 000000000..f661eef9b --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/pkce.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest'; +import { + buildAuthUrl, + computeChallenge, + createPkcePair, +} from '@/services/sync/providers/oauth/pkce'; + +describe('pkce', () => { + test('computeChallenge matches the RFC 7636 Appendix B known-answer vector', async () => { + // RFC 7636 §B: verifier → S256 challenge. Catches the classic bug of hashing + // raw verifier bytes instead of the ASCII octets of the verifier string. + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + expect(await computeChallenge(verifier)).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + + test('createPkcePair yields a base64url verifier and its derived challenge', async () => { + const { verifier, challenge } = await createPkcePair(); + // base64url alphabet only (no +, /, or =), and within the RFC length window. + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(challenge).toBe(await computeChallenge(verifier)); + }); + + test('buildAuthUrl sets the PKCE + offline-consent query parameters', () => { + const url = new URL( + buildAuthUrl({ + clientId: 'cid', + redirectUri: 'com.googleusercontent.apps.cid:/oauthredirect', + scope: 'https://www.googleapis.com/auth/drive.file', + challenge: 'CHALLENGE', + state: 'STATE', + authEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', + extraParams: { access_type: 'offline', prompt: 'consent' }, + }), + ); + expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth'); + const p = url.searchParams; + expect(p.get('client_id')).toBe('cid'); + expect(p.get('redirect_uri')).toBe('com.googleusercontent.apps.cid:/oauthredirect'); + expect(p.get('response_type')).toBe('code'); + expect(p.get('scope')).toBe('https://www.googleapis.com/auth/drive.file'); + expect(p.get('code_challenge')).toBe('CHALLENGE'); + expect(p.get('code_challenge_method')).toBe('S256'); + expect(p.get('state')).toBe('STATE'); + expect(p.get('access_type')).toBe('offline'); + expect(p.get('prompt')).toBe('consent'); + }); + + test('buildAuthUrl uses the given authEndpoint and appends extraParams', () => { + const url = new URL( + buildAuthUrl({ + clientId: 'CID', + redirectUri: 'readest-onedrive://auth', + scope: 'Files.ReadWrite.AppFolder offline_access', + challenge: 'CH', + state: 'ST', + authEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + extraParams: { prompt: 'select_account' }, + }), + ); + expect(url.origin + url.pathname).toBe( + 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + ); + expect(url.searchParams.get('code_challenge')).toBe('CH'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('prompt')).toBe('select_account'); + expect(url.searchParams.get('access_type')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/oauth/tokenEndpoint.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/oauth/tokenEndpoint.test.ts new file mode 100644 index 000000000..1b5bca254 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/oauth/tokenEndpoint.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + exchangeCode, + refreshAccessToken, + type FetchFn, +} from '@/services/sync/providers/oauth/tokenEndpoint'; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('tokenEndpoint', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test('exchangeCode posts the PKCE form (no client secret) and resolves a TokenSet', async () => { + let captured: { url: string; init: RequestInit } | undefined; + const fetchFn = vi.fn(async (url: string, init: RequestInit) => { + captured = { url, init }; + return jsonResponse({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }); + }); + const tokens = await exchangeCode( + { + code: 'C', + verifier: 'V', + clientId: 'cid', + redirectUri: 'R', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + }, + fetchFn, + ); + expect(captured?.url).toBe('https://oauth2.googleapis.com/token'); + const form = new URLSearchParams(captured?.init.body as string); + expect(form.get('grant_type')).toBe('authorization_code'); + expect(form.get('code')).toBe('C'); + expect(form.get('code_verifier')).toBe('V'); + expect(form.get('client_id')).toBe('cid'); + expect(form.get('redirect_uri')).toBe('R'); + expect(form.has('client_secret')).toBe(false); + // Date.now() pinned to 0, margin 60s, so expiry = (3600 - 60) * 1000. + expect(tokens).toEqual({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 3540 * 1000 }); + }); + + test('refreshAccessToken posts a refresh grant with no redirect_uri or secret', async () => { + let captured: { init: RequestInit } | undefined; + const fetchFn = vi.fn(async (_url: string, init: RequestInit) => { + captured = { init }; + return jsonResponse({ access_token: 'AT2', expires_in: 3600 }); + }); + const tokens = await refreshAccessToken( + { refreshToken: 'RT', clientId: 'cid', tokenEndpoint: 'https://oauth2.googleapis.com/token' }, + fetchFn, + ); + const form = new URLSearchParams(captured?.init.body as string); + expect(form.get('grant_type')).toBe('refresh_token'); + expect(form.get('refresh_token')).toBe('RT'); + expect(form.get('client_id')).toBe('cid'); + expect(form.has('client_secret')).toBe(false); + // Google omits the refresh token on refresh; the caller keeps the old one. + expect(tokens.refreshToken).toBeUndefined(); + }); + + test('clamps expiry so a token shorter than the margin is not already-expired', async () => { + const fetchFn = async () => jsonResponse({ access_token: 'AT', expires_in: 30 }); + const tokens = await exchangeCode( + { + code: 'C', + verifier: 'V', + clientId: 'cid', + redirectUri: 'R', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + }, + fetchFn, + ); + expect(tokens.expiresAt).toBe(0); + }); + + test('throws with the Google error detail on a non-2xx response', async () => { + const fetchFn = async () => + jsonResponse({ error: 'invalid_grant', error_description: 'bad code' }, 400); + await expect( + exchangeCode( + { + code: 'C', + verifier: 'V', + clientId: 'cid', + redirectUri: 'R', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + }, + fetchFn, + ), + ).rejects.toThrow(/HTTP 400: invalid_grant — bad code/); + }); + + test('exchangeCode posts to the given tokenEndpoint', async () => { + let seenUrl = ''; + const fetchFn = (async (url: string) => { + seenUrl = url; + return new Response(JSON.stringify({ access_token: 'A', expires_in: 3600 }), { status: 200 }); + }) as unknown as FetchFn; + await exchangeCode( + { + code: 'C', + verifier: 'V', + clientId: 'CID', + redirectUri: 'r', + tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + }, + fetchFn, + ); + expect(seenUrl).toBe('https://login.microsoftonline.com/common/oauth2/v2.0/token'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProvider.contract.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProvider.contract.test.ts new file mode 100644 index 000000000..cd0eca040 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProvider.contract.test.ts @@ -0,0 +1,29 @@ +import { runSemanticContract, type ProviderScenario } from '../../file/providerSemanticContract'; +import { + createOneDriveProvider, + type FetchFn, +} from '@/services/sync/providers/onedrive/OneDriveProvider'; + +runSemanticContract('OneDrive', (): ProviderScenario => { + let status = 200; + let body = ''; + const fetchFn = (async () => + new Response(body, { + status, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as FetchFn; + return { + makeProvider: () => + createOneDriveProvider({ getAccessToken: async () => 'T' }, fetchFn, { + sleep: () => Promise.resolve(), + }), + stageAbsent: () => { + status = 404; + body = JSON.stringify({ error: { code: 'itemNotFound' } }); + }, + stageAuthFailure: () => { + status = 401; + body = JSON.stringify({ error: { code: 'unauthenticated' } }); + }, + }; +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProvider.test.ts new file mode 100644 index 000000000..a391b4bfe --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProvider.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test, vi } from 'vitest'; +import { FileSyncError } from '@/services/sync/file/provider'; +import { + createOneDriveProvider, + type FetchFn, +} from '@/services/sync/providers/onedrive/OneDriveProvider'; + +const auth = { getAccessToken: async () => 'TOKEN' }; +const noSleep = () => Promise.resolve(); +const make = (fetchFn: FetchFn) => createOneDriveProvider(auth, fetchFn, { sleep: noSleep }); +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +describe('OneDriveProvider', () => { + test('readText returns null on 404', async () => { + const fetchFn = (async () => new Response('', { status: 404 })) as unknown as FetchFn; + expect(await make(fetchFn).readText('/Readest/x.json')).toBeNull(); + }); + + test('readText returns the body and sends a bearer token', async () => { + let auth = ''; + const fetchFn = (async (_url: string, init?: RequestInit) => { + auth = (init?.headers as Record)?.['Authorization'] ?? ''; + return new Response('hello', { status: 200 }); + }) as unknown as FetchFn; + expect(await make(fetchFn).readText('/Readest/x.json')).toBe('hello'); + expect(auth).toBe('Bearer TOKEN'); + }); + + test('head maps size + cTag to FileHead', async () => { + const fetchFn = (async () => json({ size: 12, cTag: 'CTAG', file: {} })) as unknown as FetchFn; + expect(await make(fetchFn).head('/Readest/x.json')).toEqual({ size: 12, etag: 'CTAG' }); + }); + + test('head returns null on 404', async () => { + const fetchFn = (async () => new Response('', { status: 404 })) as unknown as FetchFn; + expect(await make(fetchFn).head('/Readest/x.json')).toBeNull(); + }); + + test('list drains @odata.nextLink and maps folder vs file', async () => { + const page1 = json({ + value: [ + { name: 'books', folder: {} }, + { name: 'library.json', size: 3, file: {} }, + ], + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/next-page', + }); + const page2 = json({ value: [{ name: 'more.json', size: 1, file: {} }] }); + const fetchFn = vi + .fn() + .mockResolvedValueOnce(page1) + .mockResolvedValueOnce(page2) as unknown as FetchFn; + const entries = await make(fetchFn).list('/Readest'); + expect(entries.map((e) => [e.name, e.isDirectory])).toEqual([ + ['books', true], + ['library.json', false], + ['more.json', false], + ]); + }); + + test('writeText PUTs to the content endpoint', async () => { + let method = ''; + let url = ''; + const fetchFn = (async (u: string, init?: RequestInit) => { + url = u; + method = init?.method ?? ''; + return json({ id: '1' }, 201); + }) as unknown as FetchFn; + await make(fetchFn).writeText('/Readest/x.json', '{}'); + expect(method).toBe('PUT'); + expect(url).toContain('/approot:/Readest/x.json:/content'); + }); + + test('ensureDir creates each folder and treats 409 nameAlreadyExists as success', async () => { + const calls: string[] = []; + const fetchFn = (async (u: string, init?: RequestInit) => { + calls.push(`${init?.method} ${u}`); + return json({ error: { code: 'nameAlreadyExists' } }, 409); + }) as unknown as FetchFn; + await expect(make(fetchFn).ensureDir(['/Readest', '/Readest/books'])).resolves.toBeUndefined(); + expect(calls.length).toBe(2); + expect(calls[0]).toContain('POST'); + }); + + test('deleteDir tolerates a 404', async () => { + const fetchFn = (async () => new Response('', { status: 404 })) as unknown as FetchFn; + await expect(make(fetchFn).deleteDir('/Readest/books/gone')).resolves.toBeUndefined(); + }); + + test('maps 401 to FileSyncError AUTH_FAILED', async () => { + const fetchFn = (async () => + json({ error: { code: 'unauthenticated' } }, 401)) as unknown as FetchFn; + const err = await make(fetchFn) + .list('/Readest') + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect((err as FileSyncError).code).toBe('AUTH_FAILED'); + }); + + test('retries a 429 then succeeds', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 429, headers: { 'Retry-After': '0' } })) + .mockResolvedValueOnce(json({ value: [] })) as unknown as FetchFn; + expect(await make(fetchFn).list('/Readest')).toEqual([]); + expect((fetchFn as unknown as ReturnType).mock.calls.length).toBe(2); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProviderStream.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProviderStream.test.ts new file mode 100644 index 000000000..076ad584e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/OneDriveProviderStream.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// Streaming is Tauri-only (it shells the bytes through the native transfer +// plugin off the disk); force the platform probe on so the provider attaches +// the streaming methods, and stub the native transfer + fs plugins. +vi.mock('@/services/environment', () => ({ isTauriAppPlatform: () => true })); +vi.mock('@/utils/transfer', () => ({ + tauriUpload: vi.fn(async () => ''), + tauriDownload: vi.fn(async () => ({})), +})); +vi.mock('@tauri-apps/plugin-fs', () => ({ + stat: vi.fn(async () => ({ size: 42 })), +})); + +import { + createOneDriveProvider, + type OneDriveAuth, + type FetchFn, +} from '@/services/sync/providers/onedrive/OneDriveProvider'; +import { tauriUpload, tauriDownload } from '@/utils/transfer'; +import { stat } from '@tauri-apps/plugin-fs'; + +const auth: OneDriveAuth = { getAccessToken: async () => 'TOKEN' }; +const noSleep = () => Promise.resolve(); + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +interface Harness { + provider: ReturnType; + fetchMock: ReturnType; + url: (n: number) => string; + method: (n: number) => string | undefined; + body: (n: number) => unknown; +} + +const makeOneDrive = (): Harness => { + const fetchMock = vi.fn(); + const provider = createOneDriveProvider(auth, fetchMock as unknown as FetchFn, { + sleep: noSleep, + }); + return { + provider, + fetchMock, + url: (n) => fetchMock.mock.calls[n]?.[0] as string, + method: (n) => (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.method, + body: (n) => { + const raw = (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.body; + return typeof raw === 'string' ? JSON.parse(raw) : undefined; + }, + }; +}; + +const BOOK = '/Readest/books/h/book.epub'; + +describe('OneDriveProvider — streaming', () => { + beforeEach(() => { + vi.mocked(tauriUpload).mockClear(); + vi.mocked(tauriDownload).mockClear(); + vi.mocked(stat).mockClear(); + }); + + test('exposes uploadStream/downloadStream on Tauri', () => { + const h = makeOneDrive(); + expect(typeof h.provider.uploadStream).toBe('function'); + expect(typeof h.provider.downloadStream).toBe('function'); + }); + + test('uploadStream POSTs createUploadSession and streams the bytes to the returned uploadUrl', async () => { + const h = makeOneDrive(); + h.fetchMock.mockResolvedValueOnce(json({ uploadUrl: 'https://upload.example/session/abc' })); + + const ok = await h.provider.uploadStream!(BOOK, '/disk/book.epub'); + expect(ok).toBe(true); + + expect(h.method(0)).toBe('POST'); + expect(h.url(0)).toContain('createUploadSession'); + expect(h.body(0)).toEqual({ item: { '@microsoft.graph.conflictBehavior': 'replace' } }); + + expect(tauriUpload).toHaveBeenCalledTimes(1); + const call = vi.mocked(tauriUpload).mock.calls[0]!; + expect(call[0]).toBe('https://upload.example/session/abc'); + expect(call[1]).toBe('/disk/book.epub'); + expect(call[2]).toBe('PUT'); + const headers = call[4] as unknown as Record; + expect(headers['Content-Range']).toBe('bytes 0-41/42'); + }); + + test('uploadStream returns false (no throw) when the session response has no uploadUrl', async () => { + const h = makeOneDrive(); + h.fetchMock.mockResolvedValueOnce(json({})); + + const ok = await h.provider.uploadStream!(BOOK, '/disk/book.epub'); + expect(ok).toBe(false); + expect(tauriUpload).not.toHaveBeenCalled(); + }); + + test('uploadStream returns false when the transport throws', async () => { + const h = makeOneDrive(); + h.fetchMock.mockRejectedValueOnce(new Error('network down')); + + const ok = await h.provider.uploadStream!(BOOK, '/disk/book.epub'); + expect(ok).toBe(false); + }); + + test('downloadStream GETs the content URL and streams to disk with a bearer token', async () => { + const h = makeOneDrive(); + + const ok = await h.provider.downloadStream!(BOOK, '/disk/dst.epub'); + expect(ok).toBe(true); + expect(tauriDownload).toHaveBeenCalledTimes(1); + const call = vi.mocked(tauriDownload).mock.calls[0]!; + expect(call[0]).toContain('/approot:/Readest/books/h/book.epub:/content'); + expect(call[1]).toBe('/disk/dst.epub'); + expect(call[3]).toEqual({ Authorization: 'Bearer TOKEN' }); + }); + + test('downloadStream returns false when the transport throws', async () => { + const h = makeOneDrive(); + vi.mocked(tauriDownload).mockRejectedValueOnce(new Error('network down')); + + const ok = await h.provider.downloadStream!(BOOK, '/disk/dst.epub'); + expect(ok).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/buildOneDriveProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/buildOneDriveProvider.test.ts new file mode 100644 index 000000000..942c4ae21 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/buildOneDriveProvider.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); +vi.mock('@/services/environment', () => ({ + isTauriAppPlatform: vi.fn(), + isWebAppPlatform: vi.fn(), +})); +vi.mock('@/utils/bridge', () => ({ + isSyncKeychainAvailable: vi.fn(), + getSecureItem: vi.fn(), + setSecureItem: vi.fn(), + clearSecureItem: vi.fn(), +})); + +import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; +import { isSyncKeychainAvailable } from '@/utils/bridge'; +import { + buildOneDriveProvider, + getMicrosoftClientId, +} from '@/services/sync/providers/onedrive/buildOneDriveProvider'; + +const CLIENT_ID = 'ms-client-id'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe('buildOneDriveProvider', () => { + test('falls back to the baked official client id when the env override is unset', async () => { + vi.stubEnv('NEXT_PUBLIC_MICROSOFT_CLIENT_ID', ''); + // The baked default is a GUID (the Azure Application (client) ID). + expect(getMicrosoftClientId()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + // With a baked default + keychain, OneDrive builds even without an env override. + vi.mocked(isWebAppPlatform).mockReturnValue(false); + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: true }); + expect(await buildOneDriveProvider()).not.toBeNull(); + }); + + test('the env override wins over the baked default', () => { + vi.stubEnv('NEXT_PUBLIC_MICROSOFT_CLIENT_ID', CLIENT_ID); + expect(getMicrosoftClientId()).toBe(CLIENT_ID); + }); + + test('web: builds a provider when a client id is configured', async () => { + vi.stubEnv('NEXT_PUBLIC_MICROSOFT_CLIENT_ID', CLIENT_ID); + vi.mocked(isWebAppPlatform).mockReturnValue(true); + const provider = await buildOneDriveProvider(); + expect(provider).not.toBeNull(); + expect(provider?.rootPath).toBe('/'); + // The web path never touches the keychain. + expect(isSyncKeychainAvailable).not.toHaveBeenCalled(); + }); + + test('web: builds with the baked client id when the env override is unset', async () => { + vi.stubEnv('NEXT_PUBLIC_MICROSOFT_CLIENT_ID', ''); + vi.mocked(isWebAppPlatform).mockReturnValue(true); + const provider = await buildOneDriveProvider(); + expect(provider).not.toBeNull(); + expect(provider?.rootPath).toBe('/'); + expect(isSyncKeychainAvailable).not.toHaveBeenCalled(); + }); + + test('returns null off-Tauri (no secure token storage for the refresh token)', async () => { + vi.stubEnv('NEXT_PUBLIC_MICROSOFT_CLIENT_ID', CLIENT_ID); + vi.mocked(isWebAppPlatform).mockReturnValue(false); + vi.mocked(isTauriAppPlatform).mockReturnValue(false); + expect(await buildOneDriveProvider()).toBeNull(); + expect(isSyncKeychainAvailable).not.toHaveBeenCalled(); + }); + + test('returns null when the keychain is unavailable', async () => { + vi.stubEnv('NEXT_PUBLIC_MICROSOFT_CLIENT_ID', CLIENT_ID); + vi.mocked(isWebAppPlatform).mockReturnValue(false); + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: false }); + expect(await buildOneDriveProvider()).toBeNull(); + }); + + test('builds a provider when client id + keychain are available', async () => { + vi.stubEnv('NEXT_PUBLIC_MICROSOFT_CLIENT_ID', CLIENT_ID); + vi.mocked(isWebAppPlatform).mockReturnValue(false); + vi.mocked(isTauriAppPlatform).mockReturnValue(true); + vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: true }); + const provider = await buildOneDriveProvider(); + expect(provider).not.toBeNull(); + expect(provider?.rootPath).toBe('/'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/connectOneDrive.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/connectOneDrive.test.ts new file mode 100644 index 000000000..35d7dc4a0 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/connectOneDrive.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + connectOneDrive, + disconnectOneDrive, +} from '@/services/sync/providers/onedrive/connectOneDrive'; +import { buildMicrosoftOAuthConfig } from '@/services/sync/providers/onedrive/microsoftOAuthConfig'; +import { FileSyncError } from '@/services/sync/file/provider'; +import type { FetchFn } from '@/services/sync/providers/onedrive/OneDriveProvider'; +import type { TokenPersistence } from '@/services/sync/providers/onedrive/onedriveTokenStore'; +import type { TokenSet } from '@/services/sync/providers/oauth/tokenEndpoint'; + +const tokens: TokenSet = { accessToken: 'AT', refreshToken: 'RT', expiresAt: 9_999_999_999_999 }; +const config = buildMicrosoftOAuthConfig('cid'); + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +const makePersistence = (): TokenPersistence & { + save: ReturnType; + clear: ReturnType; +} => ({ + load: vi.fn(async () => null), + save: vi.fn(async () => {}), + clear: vi.fn(async () => {}), +}); + +describe('connectOneDrive', () => { + test('runs OAuth with the injected config, saves the token, returns the account label', async () => { + const persistence = makePersistence(); + const runOAuth = vi.fn(async () => tokens); + const fetchFn = vi.fn(async (url: string) => + url.includes('/me?') + ? json({ userPrincipalName: 'a@b.com' }) + : new Response('', { status: 404 }), + ); + + const res = await connectOneDrive({ + config, + fetchFn: fetchFn as unknown as FetchFn, + persistence, + runOAuth, + }); + + expect(runOAuth).toHaveBeenCalledWith(config, fetchFn); + expect(persistence.save).toHaveBeenCalledWith(tokens); + expect(res.accountLabel).toBe('a@b.com'); + }); + + test('does NOT report connected when the token cannot be persisted', async () => { + const persistence = makePersistence(); + persistence.save.mockRejectedValueOnce(new FileSyncError('keychain denied', 'AUTH_FAILED')); + await expect( + connectOneDrive({ + config, + fetchFn: vi.fn() as unknown as FetchFn, + persistence, + runOAuth: async () => tokens, + }), + ).rejects.toThrow(/keychain denied/); + }); + + test('account label is best-effort: null when /me fails, token still saved', async () => { + const persistence = makePersistence(); + const fetchFn = vi.fn(async () => new Response('', { status: 500 })); + const res = await connectOneDrive({ + config, + fetchFn: fetchFn as unknown as FetchFn, + persistence, + runOAuth: async () => tokens, + }); + expect(res.accountLabel).toBeNull(); + expect(persistence.save).toHaveBeenCalledWith(tokens); + }); +}); + +describe('disconnectOneDrive', () => { + test('clears the stored token', async () => { + const persistence = makePersistence(); + await disconnectOneDrive(persistence); + expect(persistence.clear).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/graphRest.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/graphRest.test.ts new file mode 100644 index 000000000..f7f9dc184 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/graphRest.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest'; +import { + childrenUrl, + contentUrl, + createChildUrl, + deleteItemUrl, + encodeGraphPath, + itemUrl, + meUrl, + uploadSessionUrl, +} from '@/services/sync/providers/onedrive/graphRest'; + +const APPROOT = 'https://graph.microsoft.com/v1.0/me/drive/special/approot'; + +describe('graphRest', () => { + test('encodeGraphPath strips slashes and encodes each segment', () => { + expect(encodeGraphPath('/Readest/books/a b/config.json')).toBe( + 'Readest/books/a%20b/config.json', + ); + expect(encodeGraphPath('/')).toBe(''); + }); + + test('itemUrl addresses by path with a $select', () => { + expect(itemUrl('/Readest/x.json', 'size,cTag,file')).toBe( + `${APPROOT}:/Readest/x.json?$select=size,cTag,file`, + ); + }); + + test('contentUrl targets the content endpoint', () => { + expect(contentUrl('/Readest/x.json')).toBe(`${APPROOT}:/Readest/x.json:/content`); + }); + + test('childrenUrl of a nested path uses the colon form; root uses no colon', () => { + expect(childrenUrl('/Readest/books')).toBe( + `${APPROOT}:/Readest/books:/children?$select=name,size,cTag,file,folder&$top=200`, + ); + expect(childrenUrl('/')).toBe( + `${APPROOT}/children?$select=name,size,cTag,file,folder&$top=200`, + ); + }); + + test('createChildUrl targets the parent children collection', () => { + expect(createChildUrl('/Readest')).toBe(`${APPROOT}:/Readest:/children`); + expect(createChildUrl('/')).toBe(`${APPROOT}/children`); + }); + + test('deleteItemUrl + uploadSessionUrl + meUrl', () => { + expect(deleteItemUrl('/Readest/x')).toBe(`${APPROOT}:/Readest/x`); + expect(uploadSessionUrl('/Readest/b.epub')).toBe( + `${APPROOT}:/Readest/b.epub:/createUploadSession`, + ); + expect(meUrl()).toBe( + 'https://graph.microsoft.com/v1.0/me?$select=userPrincipalName,mail,displayName', + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/microsoftOAuthConfig.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/microsoftOAuthConfig.test.ts new file mode 100644 index 000000000..8c4345bb7 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/microsoftOAuthConfig.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; +import { + buildMicrosoftOAuthConfig, + isOneDriveOAuthRedirectUrl, +} from '@/services/sync/providers/onedrive/microsoftOAuthConfig'; + +describe('microsoftOAuthConfig', () => { + test('builds the Microsoft OAuth config with the custom-scheme redirect', () => { + const cfg = buildMicrosoftOAuthConfig('CID'); + expect(cfg.clientId).toBe('CID'); + expect(cfg.scope).toBe('Files.ReadWrite.AppFolder offline_access User.Read'); + expect(cfg.authEndpoint).toBe('https://login.microsoftonline.com/common/oauth2/v2.0/authorize'); + expect(cfg.tokenEndpoint).toBe('https://login.microsoftonline.com/common/oauth2/v2.0/token'); + expect(cfg.redirectUri).toBe('readest-onedrive://auth'); + expect(cfg.redirectScheme).toBe('readest-onedrive'); + expect(cfg.authParams).toEqual({ prompt: 'select_account' }); + }); + + test('isOneDriveOAuthRedirectUrl flags the OneDrive redirect scheme', () => { + expect(isOneDriveOAuthRedirectUrl('readest-onedrive://auth?code=x&state=y')).toBe(true); + expect(isOneDriveOAuthRedirectUrl('readest://auth-callback')).toBe(false); + expect(isOneDriveOAuthRedirectUrl('file:///Users/me/book.epub')).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/onedriveAuth.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/onedriveAuth.test.ts new file mode 100644 index 000000000..a0c9e512b --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/onedriveAuth.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; +import { resolveOneDriveAccountLabel } from '@/services/sync/providers/onedrive/onedriveAuth'; +import type { FetchFn } from '@/services/sync/providers/oauth/tokenEndpoint'; + +describe('resolveOneDriveAccountLabel', () => { + test('reads userPrincipalName from /me, falling back to mail then displayName', async () => { + const upn = (async () => + new Response( + JSON.stringify({ userPrincipalName: 'a@b.com', mail: 'm@b.com', displayName: 'A' }), + { status: 200 }, + )) as unknown as FetchFn; + expect(await resolveOneDriveAccountLabel('T', upn)).toBe('a@b.com'); + const mailOnly = (async () => + new Response(JSON.stringify({ mail: 'm@b.com', displayName: 'A' }), { + status: 200, + })) as unknown as FetchFn; + expect(await resolveOneDriveAccountLabel('T', mailOnly)).toBe('m@b.com'); + }); + test('returns null on a non-ok response', async () => { + const fetchFn = (async () => new Response('', { status: 401 })) as unknown as FetchFn; + expect(await resolveOneDriveAccountLabel('T', fetchFn)).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/onedriveTokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/onedriveTokenStore.test.ts new file mode 100644 index 000000000..18d875f22 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/onedriveTokenStore.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest'; +import { + ONEDRIVE_TOKEN_KEY, + createOneDriveTokenPersistence, +} from '@/services/sync/providers/onedrive/onedriveTokenStore'; + +describe('onedriveTokenStore', () => { + test('ONEDRIVE_TOKEN_KEY is "onedrive_token_set"', () => { + expect(ONEDRIVE_TOKEN_KEY).toBe('onedrive_token_set'); + }); + + test('createOneDriveTokenPersistence returns null off-Tauri (jsdom)', async () => { + const result = await createOneDriveTokenPersistence(); + expect(result).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/onedrive/webAuthCodeFlow.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/webAuthCodeFlow.test.ts new file mode 100644 index 000000000..4b7d00d32 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/onedrive/webAuthCodeFlow.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FetchFn } from '@/services/sync/providers/oauth/tokenEndpoint'; +import { + WEB_OAUTH_CALLBACK_PATH, + beginWebOneDriveRedirect, + clearWebOneDriveToken, + consumeReturnPath, + consumeWebOAuthState, + consumeWebPkceVerifier, + exchangeWebAuthCode, + hasValidWebOneDriveToken, + loadWebOneDriveToken, + oneDriveWebRedirectUri, + saveWebOneDriveToken, + webOneDriveTokenPersistence, +} from '@/services/sync/providers/onedrive/webAuthCodeFlow'; + +beforeEach(() => { + window.sessionStorage.clear(); + Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost:3000', assign: vi.fn() }, + writable: true, + }); +}); + +afterEach(() => { + window.sessionStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('oneDriveWebRedirectUri', () => { + test('builds the callback URI from the current origin', () => { + expect(oneDriveWebRedirectUri()).toBe(`http://localhost:3000${WEB_OAUTH_CALLBACK_PATH}`); + expect(WEB_OAUTH_CALLBACK_PATH).toBe('/onedrive-callback'); + }); +}); + +describe('beginWebOneDriveRedirect', () => { + test('mints + stores a PKCE verifier, state, and return path, then redirects with an auth-code URL', async () => { + void beginWebOneDriveRedirect({ clientId: 'web-client-id', returnPath: '/library?x=1' }); + + await vi.waitFor(() => { + expect(window.location.assign).toHaveBeenCalled(); + }); + + const verifier = window.sessionStorage.getItem('onedrive_web_oauth_verifier'); + const state = window.sessionStorage.getItem('onedrive_web_oauth_state'); + const returnPath = window.sessionStorage.getItem('onedrive_web_oauth_return'); + expect(verifier).toBeTruthy(); + expect(state).toBeTruthy(); + expect(returnPath).toBe('/library?x=1'); + + const assignMock = window.location.assign as unknown as ReturnType; + const url = new URL(assignMock.mock.calls[0]![0] as string); + expect(url.origin + url.pathname).toBe( + 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + ); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('code_challenge')).toBeTruthy(); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('client_id')).toBe('web-client-id'); + expect(url.searchParams.get('state')).toBe(state); + expect(url.searchParams.get('redirect_uri')).toBe(oneDriveWebRedirectUri()); + expect(url.searchParams.get('prompt')).toBe('select_account'); + }); + + test('the returned promise never resolves (navigation unloads the page)', async () => { + const promise = beginWebOneDriveRedirect({ clientId: 'cid', returnPath: '/' }); + const raced = await Promise.race([ + promise.then(() => 'resolved'), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 20)), + ]); + expect(raced).toBe('timeout'); + }); +}); + +describe('consumeWebOAuthState / consumeWebPkceVerifier / consumeReturnPath', () => { + test('state is read once then cleared', () => { + window.sessionStorage.setItem('onedrive_web_oauth_state', 'STATE1'); + expect(consumeWebOAuthState()).toBe('STATE1'); + expect(consumeWebOAuthState()).toBeNull(); + }); + + test('verifier is read once then cleared', () => { + window.sessionStorage.setItem('onedrive_web_oauth_verifier', 'VERIFIER1'); + expect(consumeWebPkceVerifier()).toBe('VERIFIER1'); + expect(consumeWebPkceVerifier()).toBeNull(); + }); + + test('return path falls back to "/" and rejects non-same-origin paths', () => { + expect(consumeReturnPath()).toBe('/'); + window.sessionStorage.setItem('onedrive_web_oauth_return', '/library?q=1'); + expect(consumeReturnPath()).toBe('/library?q=1'); + window.sessionStorage.setItem('onedrive_web_oauth_return', '//evil.com'); + expect(consumeReturnPath()).toBe('/'); + window.sessionStorage.setItem('onedrive_web_oauth_return', 'https://evil.com'); + expect(consumeReturnPath()).toBe('/'); + }); +}); + +describe('exchangeWebAuthCode', () => { + test('POSTs to the MS token endpoint with grant_type=authorization_code + code_verifier', async () => { + const fetchFn: FetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'AT', + refresh_token: 'RT', + expires_in: 3600, + }), + } as Response); + + const tokens = await exchangeWebAuthCode({ + clientId: 'web-client-id', + code: 'AUTH_CODE', + verifier: 'VERIFIER', + redirectUri: 'http://localhost:3000/onedrive-callback', + fetchFn, + }); + + expect(fetchFn).toHaveBeenCalledTimes(1); + const [endpoint, init] = (fetchFn as ReturnType).mock.calls[0]!; + expect(endpoint).toBe('https://login.microsoftonline.com/common/oauth2/v2.0/token'); + const body = new URLSearchParams(init.body as string); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('AUTH_CODE'); + expect(body.get('code_verifier')).toBe('VERIFIER'); + expect(body.get('client_id')).toBe('web-client-id'); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/onedrive-callback'); + + expect(tokens.accessToken).toBe('AT'); + expect(tokens.refreshToken).toBe('RT'); + expect(tokens.expiresAt).toBeGreaterThan(Date.now()); + }); +}); + +describe('web token store', () => { + test('round-trips a token set through sessionStorage', () => { + expect(loadWebOneDriveToken()).toBeNull(); + saveWebOneDriveToken({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 }); + expect(loadWebOneDriveToken()).toEqual({ + accessToken: 'AT', + refreshToken: 'RT', + expiresAt: 123, + }); + }); + + test('clear removes the stored token', () => { + saveWebOneDriveToken({ accessToken: 'AT', expiresAt: 123 }); + clearWebOneDriveToken(); + expect(loadWebOneDriveToken()).toBeNull(); + }); + + test('returns null for an unparseable stored value', () => { + window.sessionStorage.setItem('onedrive_web_token', 'not json'); + expect(loadWebOneDriveToken()).toBeNull(); + }); + + test('hasValidWebOneDriveToken reflects presence + expiry', () => { + expect(hasValidWebOneDriveToken(1_000)).toBe(false); // none stored + saveWebOneDriveToken({ accessToken: 'AT', expiresAt: 5_000 }); + expect(hasValidWebOneDriveToken(1_000)).toBe(true); // not yet expired + expect(hasValidWebOneDriveToken(9_000)).toBe(false); // expired + }); +}); + +describe('webOneDriveTokenPersistence', () => { + test('load/save/clear round-trip through sessionStorage', async () => { + expect(await webOneDriveTokenPersistence.load()).toBeNull(); + + await webOneDriveTokenPersistence.save({ + accessToken: 'AT', + refreshToken: 'RT', + expiresAt: 999, + }); + expect(await webOneDriveTokenPersistence.load()).toEqual({ + accessToken: 'AT', + refreshToken: 'RT', + expiresAt: 999, + }); + expect(window.sessionStorage.getItem('onedrive_web_token')).toBeTruthy(); + + await webOneDriveTokenPersistence.clear(); + expect(await webOneDriveTokenPersistence.load()).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/s3/S3Provider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/s3/S3Provider.test.ts new file mode 100644 index 000000000..3ae0a2781 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/s3/S3Provider.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createS3Provider, type S3FetchFn } from '@/services/sync/providers/s3/S3Provider'; +import { FileSyncError } from '@/services/sync/file/provider'; +import { runSemanticContract } from '@/__tests__/services/sync/file/providerSemanticContract'; + +const config = { + endpoint: 'https://acc.r2.cloudflarestorage.com', + region: 'auto', + bucket: 'readest', + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', +}; + +const xml = (body: string, status = 200): Response => + new Response(body, { status, headers: { 'content-type': 'application/xml' } }); +const text = (body: string, status = 200): Response => new Response(body, { status }); + +const emptyList = ` +false`; + +const listPage = (opts: { + prefixes?: string[]; + keys?: { key: string; size?: number; modified?: string }[]; + next?: string; +}): string => ` + + ${opts.next ? `${opts.next}` : ''} + ${(opts.prefixes ?? []).map((p) => `${p}`).join('')} + ${(opts.keys ?? []) + .map( + (k) => + `${k.key}${k.size ?? 0}${ + k.modified ?? '2026-01-01T00:00:00.000Z' + }`, + ) + .join('')} +`; + +interface Harness { + provider: ReturnType; + fetchMock: ReturnType; + sleep: ReturnType; + url: (n: number) => string; + method: (n: number) => string | undefined; + headers: (n: number) => Record; +} + +const makeS3 = (): Harness => { + const fetchMock = vi.fn(); + const sleep = vi.fn(async () => {}); + const provider = createS3Provider(config, fetchMock as unknown as S3FetchFn, { + sleep: sleep as unknown as (ms: number) => Promise, + }); + return { + provider, + fetchMock, + sleep, + url: (n) => fetchMock.mock.calls[n]?.[0] as string, + method: (n) => (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.method, + headers: (n) => + ((fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.headers ?? {}) as Record< + string, + string + >, + }; +}; + +// S3 must satisfy the same semantics as WebDAV / Drive. Absent paths are a +// 404 for object ops but an empty 200 listing for deleteDir's prefix scan, +// so the staged response dispatches on the request shape. +runSemanticContract('S3Provider', () => { + const fetchMock = vi.fn(); + return { + makeProvider: () => + createS3Provider(config, fetchMock as unknown as S3FetchFn, { sleep: async () => {} }), + stageAbsent: () => + fetchMock.mockImplementationOnce(async (url: string) => + url.includes('list-type=2') ? xml(emptyList) : new Response(null, { status: 404 }), + ), + stageAuthFailure: () => fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 })), + }; +}); + +describe('S3Provider — transport', () => { + test('readText GETs the path-style object URL with a SigV4 authorization', async () => { + const h = makeS3(); + h.fetchMock.mockResolvedValueOnce(text('HELLO')); + + expect(await h.provider.readText('/Readest/library.json')).toBe('HELLO'); + expect(h.url(0)).toBe('https://acc.r2.cloudflarestorage.com/readest/Readest/library.json'); + expect(h.method(0)).toBe('GET'); + expect(h.headers(0)['authorization']).toContain('AWS4-HMAC-SHA256'); + }); + + test('percent-encodes unicode key segments', async () => { + const h = makeS3(); + h.fetchMock.mockResolvedValueOnce(text('X')); + + await h.provider.readText('/Readest/books/h1/白夜行.epub'); + expect(h.url(0)).toContain(`/readest/Readest/books/h1/${encodeURIComponent('白夜行.epub')}`); + }); + + test('head returns size and the ETag stripped of quotes', async () => { + const h = makeS3(); + h.fetchMock.mockResolvedValueOnce( + new Response(null, { + status: 200, + headers: { 'content-length': '123', etag: '"abc123"' }, + }), + ); + + expect(await h.provider.head('/Readest/library.json')).toEqual({ size: 123, etag: 'abc123' }); + expect(h.method(0)).toBe('HEAD'); + }); + + test('list maps CommonPrefixes to dirs and Contents to files, draining pages', async () => { + const h = makeS3(); + h.fetchMock + .mockResolvedValueOnce(xml(listPage({ prefixes: ['Readest/books/h1/'], next: 'tok-2' }))) + .mockResolvedValueOnce( + xml(listPage({ keys: [{ key: 'Readest/books/library.json', size: 42 }] })), + ); + + const entries = await h.provider.list('/Readest/books'); + + expect(h.url(0)).toContain('list-type=2'); + expect(h.url(0)).toContain(`prefix=${encodeURIComponent('Readest/books/')}`); + expect(h.url(0)).toContain('delimiter=%2F'); + expect(h.url(1)).toContain(`continuation-token=${encodeURIComponent('tok-2')}`); + expect(entries).toEqual([ + { name: 'h1', path: '/Readest/books/h1', isDirectory: true }, + { + name: 'library.json', + path: '/Readest/books/library.json', + isDirectory: false, + size: 42, + lastModified: '2026-01-01T00:00:00.000Z', + }, + ]); + }); + + test('writeText PUTs the body with its content type; ensureDir never fetches', async () => { + const h = makeS3(); + await h.provider.ensureDir(['/Readest', '/Readest/books']); + expect(h.fetchMock).not.toHaveBeenCalled(); + + h.fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })); + await h.provider.writeText('/Readest/library.json', '{"a":1}'); + expect(h.method(0)).toBe('PUT'); + expect(h.url(0)).toContain('/readest/Readest/library.json'); + expect(h.fetchMock.mock.calls[0]?.[1]?.body).toBeDefined(); + }); + + test('deleteDir lists the prefix and DELETEs every key', async () => { + const h = makeS3(); + h.fetchMock + .mockResolvedValueOnce( + xml( + listPage({ + keys: [{ key: 'Readest/books/h1/config.json' }, { key: 'Readest/books/h1/B.epub' }], + }), + ), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })); // concurrently gone — fine + + await h.provider.deleteDir('/Readest/books/h1'); + + expect(h.url(0)).toContain(`prefix=${encodeURIComponent('Readest/books/h1/')}`); + expect(h.url(0)).not.toContain('delimiter'); + expect(h.method(1)).toBe('DELETE'); + expect(h.method(2)).toBe('DELETE'); + expect(h.url(1)).toContain('config.json'); + }); + + test('retries a transient 503 with backoff and then succeeds', async () => { + const h = makeS3(); + h.fetchMock + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(text('OK')); + + expect(await h.provider.readText('/Readest/library.json')).toBe('OK'); + expect(h.sleep).toHaveBeenCalledTimes(1); + }); + + test('maps statuses to FileSyncError codes (403 auth, 404 write, 409 conflict)', async () => { + const h = makeS3(); + h.fetchMock.mockResolvedValue(new Response(null, { status: 403 })); + let err = await h.provider.list('/Readest/books').catch((e: unknown) => e); + expect((err as FileSyncError).code).toBe('AUTH_FAILED'); + + h.fetchMock.mockReset().mockResolvedValue(new Response(null, { status: 404 })); + err = await h.provider.writeText('/Readest/library.json', 'x').catch((e: unknown) => e); + expect((err as FileSyncError).code).toBe('NOT_FOUND'); + + h.fetchMock.mockReset().mockResolvedValue(new Response(null, { status: 409 })); + err = await h.provider.writeText('/Readest/library.json', 'x').catch((e: unknown) => e); + expect((err as FileSyncError).code).toBe('CONFLICT'); + }); + + test('maps a thrown fetch (offline) to a NETWORK FileSyncError after retries', async () => { + const h = makeS3(); + h.fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + const err = await h.provider.readText('/Readest/library.json').catch((e: unknown) => e); + expect(err).toBeInstanceOf(FileSyncError); + expect((err as FileSyncError).code).toBe('NETWORK'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts index 4189bfcf3..3648f0efd 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts @@ -44,6 +44,7 @@ const makeSettings = (overrides: Partial = {}): SystemSettings = kosync: { serverUrl: '', username: '', userkey: '', password: '' }, readwise: { accessToken: '' }, hardcover: { accessToken: '' }, + webdav: { serverUrl: '', username: '', password: '', rootPath: '/' }, ...overrides, }) as unknown as SystemSettings; @@ -317,6 +318,96 @@ describe('publishSettingsIfChanged', () => { expect(patch.kosync?.password).toBe('hunter2'); }); + test('omits WebDAV credentials but keeps serverUrl/rootPath when credentials sync is OFF (issue #4810)', async () => { + await setCredentials(undefined); // default OFF + isUnlocked = false; + await publishSettingsIfChanged( + makeSettings({ + webdav: { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Books', + } as SystemSettings['webdav'], + }), + ); + expect(publishMock).toHaveBeenCalledTimes(1); + const patch = publishMock.mock.calls[0]![1].patch as Partial; + // Plaintext connection metadata still ships. + expect(patch.webdav?.serverUrl).toBe('https://dav.example.com'); + expect(patch.webdav?.rootPath).toBe('/Books'); + // Credentials are gated off. + expect(patch.webdav?.username).toBeUndefined(); + expect(patch.webdav?.password).toBeUndefined(); + }); + + test('publishes WebDAV credentials when credentials sync is ON (issue #4810)', async () => { + await setCredentials(true); + isUnlocked = true; + await publishSettingsIfChanged( + makeSettings({ + webdav: { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Books', + } as SystemSettings['webdav'], + }), + ); + expect(publishMock).toHaveBeenCalledTimes(1); + const patch = publishMock.mock.calls[0]![1].patch as Partial; + expect(patch.webdav?.serverUrl).toBe('https://dav.example.com'); + expect(patch.webdav?.username).toBe('alice'); + expect(patch.webdav?.password).toBe('hunter2'); + expect(patch.webdav?.rootPath).toBe('/Books'); + }); + + test('omits S3 credentials but keeps endpoint/region/bucket when credentials sync is OFF', async () => { + await setCredentials(undefined); // default OFF + isUnlocked = false; + await publishSettingsIfChanged( + makeSettings({ + s3: { + endpoint: 'https://acc.r2.cloudflarestorage.com', + region: 'auto', + bucket: 'readest', + accessKeyId: 'AKIA', + secretAccessKey: 'shh', + } as SystemSettings['s3'], + }), + ); + expect(publishMock).toHaveBeenCalledTimes(1); + const patch = publishMock.mock.calls[0]![1].patch as Partial; + // Plaintext connection metadata still ships. + expect(patch.s3?.endpoint).toBe('https://acc.r2.cloudflarestorage.com'); + expect(patch.s3?.region).toBe('auto'); + expect(patch.s3?.bucket).toBe('readest'); + // Access keys are gated off. + expect(patch.s3?.accessKeyId).toBeUndefined(); + expect(patch.s3?.secretAccessKey).toBeUndefined(); + }); + + test('publishes S3 credentials when credentials sync is ON', async () => { + await setCredentials(true); + isUnlocked = true; + await publishSettingsIfChanged( + makeSettings({ + s3: { + endpoint: 'https://acc.r2.cloudflarestorage.com', + region: 'auto', + bucket: 'readest', + accessKeyId: 'AKIA', + secretAccessKey: 'shh', + } as SystemSettings['s3'], + }), + ); + expect(publishMock).toHaveBeenCalledTimes(1); + const patch = publishMock.mock.calls[0]![1].patch as Partial; + expect(patch.s3?.endpoint).toBe('https://acc.r2.cloudflarestorage.com'); + expect(patch.s3?.accessKeyId).toBe('AKIA'); + expect(patch.s3?.secretAccessKey).toBe('shh'); + }); + test('skipping all of the only-credential changes is a clean no-op (no empty publish)', async () => { await setCredentials(undefined); isUnlocked = false; @@ -528,6 +619,92 @@ describe('applyRemoteSettings', () => { expect(publishMock).not.toHaveBeenCalled(); }); + test('deep-merges webdav credentials without clobbering local per-device fields (issue #4810)', () => { + const env = makeEnvConfig(); + useSettingsStore.setState({ + ...useSettingsStore.getState(), + settings: makeSettings({ + webdav: { + enabled: true, + serverUrl: 'https://old.example.com', + username: 'old', + password: 'old-pass', + rootPath: '/Old', + deviceId: 'this-device', + lastSyncedAt: 999, + syncBooks: true, + } as unknown as SystemSettings['webdav'], + }), + }); + applyRemoteSettings(env, { + name: 'singleton', + patch: { + webdav: { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Books', + }, + } as unknown as Partial, + }); + const merged = useSettingsStore.getState().settings.webdav; + // Synced connection fields are applied. + expect(merged.serverUrl).toBe('https://dav.example.com'); + expect(merged.username).toBe('alice'); + expect(merged.password).toBe('hunter2'); + expect(merged.rootPath).toBe('/Books'); + // Per-device fields the remote patch omits must survive the merge. + expect(merged.enabled).toBe(true); + expect(merged.deviceId).toBe('this-device'); + expect(merged.lastSyncedAt).toBe(999); + expect(merged.syncBooks).toBe(true); + }); + + test('deep-merges S3 credentials without clobbering local per-device fields', () => { + const env = makeEnvConfig(); + useSettingsStore.setState({ + ...useSettingsStore.getState(), + settings: makeSettings({ + s3: { + enabled: true, + endpoint: 'https://old.r2.cloudflarestorage.com', + region: 'auto', + bucket: 'old-bucket', + accessKeyId: 'OLD', + secretAccessKey: 'old-secret', + deviceId: 'this-device', + lastSyncedAt: 999, + providerSelectedAt: 111, + syncBooks: true, + } as unknown as SystemSettings['s3'], + }), + }); + applyRemoteSettings(env, { + name: 'singleton', + patch: { + s3: { + endpoint: 'https://acc.r2.cloudflarestorage.com', + region: 'auto', + bucket: 'readest', + accessKeyId: 'AKIA', + secretAccessKey: 'shh', + }, + } as unknown as Partial, + }); + const merged = useSettingsStore.getState().settings.s3; + // Synced connection fields are applied. + expect(merged.endpoint).toBe('https://acc.r2.cloudflarestorage.com'); + expect(merged.bucket).toBe('readest'); + expect(merged.accessKeyId).toBe('AKIA'); + expect(merged.secretAccessKey).toBe('shh'); + // Per-device fields the remote patch omits must survive the merge. + expect(merged.enabled).toBe(true); + expect(merged.deviceId).toBe('this-device'); + expect(merged.lastSyncedAt).toBe(999); + expect(merged.providerSelectedAt).toBe(111); + expect(merged.syncBooks).toBe(true); + }); + test('empty patch is a no-op', () => { const env = makeEnvConfig(); const before = useSettingsStore.getState().settings; diff --git a/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts b/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts index b963c64d8..d4b8a0028 100644 --- a/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts @@ -49,6 +49,51 @@ describe('isSyncCategoryEnabled', () => { expect(isSyncCategoryEnabled('font')).toBe(true); }); + describe('cloud sync provider gate (exclusive routing, #4380)', () => { + test('book/progress/note are gated off while a third-party provider is selected', () => { + setSettings({ webdav: { enabled: true } } as Partial); + expect(isSyncCategoryEnabled('book')).toBe(false); + expect(isSyncCategoryEnabled('progress')).toBe(false); + expect(isSyncCategoryEnabled('note')).toBe(false); + }); + + test('legacy plural/singular aliases are gated too', () => { + setSettings({ googleDrive: { enabled: true } } as Partial); + expect(isSyncCategoryEnabled('books')).toBe(false); + expect(isSyncCategoryEnabled('configs')).toBe(false); + expect(isSyncCategoryEnabled('config')).toBe(false); + expect(isSyncCategoryEnabled('notes')).toBe(false); + }); + + test('account channels stay native while a third-party provider is selected', () => { + setSettings({ webdav: { enabled: true } } as Partial); + expect(isSyncCategoryEnabled('stats')).toBe(true); + expect(isSyncCategoryEnabled('settings')).toBe(true); + expect(isSyncCategoryEnabled('dictionary')).toBe(true); + expect(isSyncCategoryEnabled('font')).toBe(true); + expect(isSyncCategoryEnabled('opds_catalog')).toBe(true); + }); + + test('the gate overrides an explicitly-true user category toggle', () => { + setSettings({ + webdav: { enabled: true }, + syncCategories: { book: true, progress: true }, + } as Partial); + expect(isSyncCategoryEnabled('book')).toBe(false); + expect(isSyncCategoryEnabled('progress')).toBe(false); + }); + + test('no gating when readest is the provider', () => { + setSettings({ + webdav: { enabled: false }, + googleDrive: { enabled: false }, + } as Partial); + expect(isSyncCategoryEnabled('book')).toBe(true); + expect(isSyncCategoryEnabled('progress')).toBe(true); + expect(isSyncCategoryEnabled('note')).toBe(true); + }); + }); + test('settings is togglable on its own', () => { setSettings({ syncCategories: { settings: false, dictionary: false } as Partial< diff --git a/apps/readest-app/src/__tests__/services/transfer-manager-gating.test.ts b/apps/readest-app/src/__tests__/services/transfer-manager-gating.test.ts new file mode 100644 index 000000000..e3d49d2ab --- /dev/null +++ b/apps/readest-app/src/__tests__/services/transfer-manager-gating.test.ts @@ -0,0 +1,445 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useTransferStore, TransferItem } from '@/store/transferStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import type { SystemSettings } from '@/types/settings'; + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { + dispatch: vi.fn(), + dispatchSync: vi.fn(), + }, +})); + +import { transferManager } from '@/services/transferManager'; +import { eventDispatcher } from '@/utils/event'; +import type { Book } from '@/types/book'; + +function makeBook(overrides: Partial = {}): Book { + return { + hash: 'hash1', + format: 'EPUB', + title: 'Test Book', + author: 'Author', + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} + +function makeTransferItem(overrides: Partial = {}): TransferItem { + return { + id: 't1', + kind: 'book', + bookHash: 'hash1', + bookTitle: 'Test Book', + type: 'upload', + status: 'pending', + progress: 0, + totalBytes: 0, + transferredBytes: 0, + transferSpeed: 0, + retryCount: 0, + maxRetries: 3, + createdAt: Date.now(), + priority: 10, + isBackground: false, + ...overrides, + }; +} + +const resetTransferManager = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only introspection + const mgr = transferManager as unknown as Record; + mgr['isInitialized'] = false; + mgr['isProcessing'] = false; + mgr['appService'] = null; + mgr['getLibrary'] = null; + mgr['updateBook'] = null; + mgr['_'] = null; + (mgr['abortControllers'] as Map).clear(); + let resolveReady: () => void = () => {}; + mgr['readyPromise'] = new Promise((res) => { + resolveReady = res; + }); + mgr['readyResolve'] = resolveReady; +}; + +const resetTransferStore = () => { + useTransferStore.setState({ + transfers: {}, + isQueuePaused: false, + isTransferQueueOpen: false, + maxConcurrent: 2, + activeCount: 0, + }); +}; + +const settingsLoaded = (overrides: Partial = {}): void => { + useSettingsStore.setState({ + settings: { + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...overrides, + } as SystemSettings, + }); +}; + +const settingsNotLoaded = (): void => { + useSettingsStore.setState({ settings: {} as SystemSettings }); +}; + +const webdavSelected = (): void => + settingsLoaded({ webdav: { enabled: true } } as Partial); + +function makeAppService(overrides: Record = {}) { + return { + uploadBook: vi.fn().mockResolvedValue(undefined), + downloadBook: vi.fn().mockResolvedValue(undefined), + deleteBook: vi.fn().mockResolvedValue(undefined), + uploadReplicaFile: vi.fn().mockResolvedValue(undefined), + downloadReplicaFile: vi.fn().mockResolvedValue(undefined), + deleteReplicaBundle: vi.fn().mockResolvedValue(undefined), + isMacOSApp: false, + ...overrides, + } as Record; +} + +const translationFn = (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); + } + return key; +}; + +const initManager = async (appService = makeAppService(), library: Book[] = []) => { + await transferManager.initialize(appService as never, () => library, vi.fn(), translationFn); + return appService; +}; + +const flushAsync = async (ms = 5000) => { + await vi.advanceTimersByTimeAsync(ms); +}; + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + resetTransferStore(); + resetTransferManager(); + vi.clearAllMocks(); + localStorage.clear(); + settingsLoaded(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('provider gating of book uploads', () => { + test('queueUpload returns null and queues nothing when a third-party provider is selected', async () => { + webdavSelected(); + await initManager(); + + const id = transferManager.queueUpload(makeBook()); + expect(id).toBeNull(); + expect(Object.keys(useTransferStore.getState().transfers)).toHaveLength(0); + }); + + test('queueUpload works when Readest Cloud is the provider', async () => { + await initManager(); + + const id = transferManager.queueUpload(makeBook()); + expect(id).toBeTruthy(); + }); + + test('queueBatchUploads returns empty when gated', async () => { + webdavSelected(); + await initManager(); + + const ids = transferManager.queueBatchUploads([makeBook(), makeBook({ hash: 'hash2' })]); + expect(ids).toEqual([]); + }); + + test('downloads and replica transfers are never gated', async () => { + webdavSelected(); + await initManager(); + + const downloadId = transferManager.queueDownload(makeBook()); + expect(downloadId).toBeTruthy(); + + const replicaId = transferManager.queueReplicaUpload( + 'font', + 'font-1', + 'A Font', + [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }], + 'Data' as never, + ); + expect(replicaId).toBeTruthy(); + }); +}); + +describe('settings-loaded barrier', () => { + test('a pending book upload does not execute before settings hydration', async () => { + settingsNotLoaded(); + const persisted = { + transfers: { t1: makeTransferItem() }, + isQueuePaused: false, + }; + localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted)); + + const appService = makeAppService(); + await initManager(appService, [makeBook()]); + await flushAsync(); + + expect(appService['uploadBook']).not.toHaveBeenCalled(); + expect(useTransferStore.getState().transfers['t1']?.status).toBe('pending'); + }); + + test('the deferred upload executes once settings hydrate with readest selected', async () => { + settingsNotLoaded(); + localStorage.setItem( + 'readest_transfer_queue', + JSON.stringify({ transfers: { t1: makeTransferItem() }, isQueuePaused: false }), + ); + + const appService = makeAppService(); + await initManager(appService, [makeBook()]); + await flushAsync(); + expect(appService['uploadBook']).not.toHaveBeenCalled(); + + settingsLoaded(); + await flushAsync(); + + expect(appService['uploadBook']).toHaveBeenCalledTimes(1); + }); + + test('replica transfers are not stalled by the barrier', async () => { + settingsNotLoaded(); + const appService = makeAppService(); + await initManager(appService); + + transferManager.queueReplicaDownload( + 'font', + 'font-1', + 'A Font', + [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }], + 'Data' as never, + ); + await flushAsync(); + + expect(appService['downloadReplicaFile']).toHaveBeenCalled(); + }); +}); + +describe('policy cancellation on restore/reconcile', () => { + test('pre-switch pending book uploads are policy-cancelled on restore; downloads and replicas survive', async () => { + webdavSelected(); + const persisted = { + transfers: { + up1: makeTransferItem({ id: 'up1' }), + down1: makeTransferItem({ id: 'down1', type: 'download', bookHash: 'hash2' }), + rep1: makeTransferItem({ + id: 'rep1', + kind: 'replica', + type: 'upload', + bookHash: '', + replicaKind: 'font', + replicaId: 'font-1', + replicaFiles: [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }], + replicaBase: 'Data' as never, + }), + }, + isQueuePaused: true, + }; + localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted)); + + await initManager(); + await flushAsync(); + + const transfers = useTransferStore.getState().transfers; + expect(transfers['up1']?.status).toBe('cancelled'); + expect(transfers['up1']?.cancelReason).toBe('policy'); + expect(transfers['down1']?.status).toBe('pending'); + expect(transfers['rep1']?.status).toBe('pending'); + }); + + test('switching providers after init policy-cancels pending book uploads', async () => { + await initManager(); + useTransferStore.getState().pauseQueue(); + const id = transferManager.queueUpload(makeBook())!; + + webdavSelected(); + await flushAsync(); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer?.status).toBe('cancelled'); + expect(transfer?.cancelReason).toBe('policy'); + }); + + test('policy-cancelled rows from a previous session are pruned on restore', async () => { + const persisted = { + transfers: { + old1: makeTransferItem({ id: 'old1', status: 'cancelled', cancelReason: 'policy' }), + user1: makeTransferItem({ id: 'user1', status: 'cancelled' }), + }, + isQueuePaused: false, + }; + localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted)); + + await initManager(); + + const transfers = useTransferStore.getState().transfers; + expect(transfers['old1']).toBeUndefined(); + expect(transfers['user1']).toBeDefined(); + }); + + test('legacy persisted entries without kind or cancelReason restore cleanly', async () => { + const legacy = makeTransferItem({ id: 'l1', status: 'completed' }) as Partial; + delete legacy.kind; + localStorage.setItem( + 'readest_transfer_queue', + JSON.stringify({ transfers: { l1: legacy }, isQueuePaused: false }), + ); + + await initManager(); + + const restored = useTransferStore.getState().transfers['l1']; + expect(restored?.kind).toBe('book'); + expect(restored?.status).toBe('completed'); + }); +}); + +describe('cancelled bucket accounting', () => { + test('policy-cancelled rows are excluded from failed stats and getFailedTransfers; user-cancelled stay', () => { + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }), + u1: makeTransferItem({ id: 'u1', status: 'cancelled', cancelReason: 'user' }), + f1: makeTransferItem({ id: 'f1', status: 'failed' }), + }, + }); + + const store = useTransferStore.getState(); + const failedIds = store.getFailedTransfers().map((t) => t.id); + expect(failedIds).toContain('u1'); + expect(failedIds).toContain('f1'); + expect(failedIds).not.toContain('p1'); + expect(store.getQueueStats().failed).toBe(2); + }); + + test('retryAllFailed does not resurrect policy-cancelled rows', async () => { + webdavSelected(); + await initManager(); + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }), + }, + }); + + transferManager.retryAllFailed(); + await flushAsync(); + + expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled'); + }); + + test('retryTransfer no-ops on a policy-cancelled row', async () => { + webdavSelected(); + await initManager(); + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }), + }, + }); + + transferManager.retryTransfer('p1'); + await flushAsync(); + + expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled'); + }); + + test('user cancelTransfer records cancelReason user', async () => { + await initManager(); + useTransferStore.getState().pauseQueue(); + const id = transferManager.queueUpload(makeBook())!; + + transferManager.cancelTransfer(id); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer?.status).toBe('cancelled'); + expect(transfer?.cancelReason).toBe('user'); + }); + + test('gate-off reconcile settles: a force-pended policy row is re-cancelled without looping', async () => { + webdavSelected(); + await initManager(); + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'pending', cancelReason: 'policy' }), + }, + }); + + await (transferManager as unknown as { processQueue: () => Promise }).processQueue(); + await flushAsync(); + + const transfer = useTransferStore.getState().transfers['p1']; + expect(transfer?.status).toBe('cancelled'); + expect(useTransferStore.getState().getPendingTransfers()).toHaveLength(0); + }); +}); + +describe('quota failure handling', () => { + test('quota 403 fails immediately with zero retries', async () => { + const appService = makeAppService({ + uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')), + }); + const book = makeBook(); + await initManager(appService, [book]); + + transferManager.queueUpload(book); + await flushAsync(); + + const transfers = Object.values(useTransferStore.getState().transfers); + expect(transfers).toHaveLength(1); + expect(transfers[0]?.status).toBe('failed'); + expect(transfers[0]?.retryCount).toBe(0); + expect(appService['uploadBook']).toHaveBeenCalledTimes(1); + }); + + test('a batch of quota failures produces one summary toast, not one per book', async () => { + const appService = makeAppService({ + uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')), + }); + const books = [makeBook(), makeBook({ hash: 'hash2' }), makeBook({ hash: 'hash3' })]; + await initManager(appService, books); + + transferManager.queueBatchUploads(books); + await flushAsync(10000); + + const dispatched = vi.mocked(eventDispatcher.dispatch).mock.calls.filter( + ([event, payload]) => + event === 'toast' && + String((payload as { message?: string })?.message ?? '') + .toLowerCase() + .includes('quota'), + ); + expect(dispatched).toHaveLength(1); + expect(String((dispatched[0]![1] as { message: string }).message)).toContain('3'); + }); + + test('non-quota errors keep the existing retry behavior', async () => { + const appService = makeAppService({ + uploadBook: vi.fn().mockRejectedValue(new Error('network boom')), + }); + const book = makeBook(); + await initManager(appService, [book]); + + transferManager.queueUpload(book); + await flushAsync(60000); + + const transfer = Object.values(useTransferStore.getState().transfers)[0]; + expect(transfer?.status).toBe('failed'); + expect(transfer?.retryCount).toBe(3); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/transfer-manager.test.ts b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts index 140cfe5ba..eb25e4944 100644 --- a/apps/readest-app/src/__tests__/services/transfer-manager.test.ts +++ b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; import { useTransferStore, TransferItem } from '@/store/transferStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import type { SystemSettings } from '@/types/settings'; // ── Mocks ──────────────────────────────────────────────────────────── // The transferManager module is a singleton, so we need to mock its @@ -105,6 +107,16 @@ beforeEach(() => { resetTransferManager(); vi.clearAllMocks(); localStorage.clear(); + // Book uploads are gated on the selected cloud sync provider and + // deferred until settings hydrate; hydrate with Readest Cloud selected + // so the pre-gating behavior under test is preserved. + useSettingsStore.setState({ + settings: { + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + } as SystemSettings, + }); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -527,6 +539,90 @@ describe('TransferManager', () => { }); }); + // ── clearCompleted ─────────────────────────────────────────────── + describe('clearCompleted', () => { + test('removes completed transfers, keeps others, and persists the queue', async () => { + const book1 = makeBook({ hash: 'h1', title: 'B1' }); + const book2 = makeBook({ hash: 'h2', title: 'B2' }); + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [book1, book2], + vi.fn(), + translationFn, + ); + + transferManager.pauseQueue(); + const id1 = transferManager.queueUpload(book1)!; + const id2 = transferManager.queueDownload(book2)!; + useTransferStore.getState().setTransferStatus(id1, 'completed'); + + transferManager.clearCompleted(); + + expect(useTransferStore.getState().transfers[id1]).toBeUndefined(); + expect(useTransferStore.getState().transfers[id2]).toBeDefined(); + + const stored = JSON.parse(localStorage.getItem('readest_transfer_queue')!); + expect(stored.transfers[id1]).toBeUndefined(); + expect(stored.transfers[id2]).toBeDefined(); + }); + }); + + // ── clearFailed ────────────────────────────────────────────────── + describe('clearFailed', () => { + test('removes failed/cancelled transfers, keeps others, and persists the queue', async () => { + const book1 = makeBook({ hash: 'h1', title: 'B1' }); + const book2 = makeBook({ hash: 'h2', title: 'B2' }); + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [book1, book2], + vi.fn(), + translationFn, + ); + + transferManager.pauseQueue(); + const id1 = transferManager.queueUpload(book1)!; + const id2 = transferManager.queueDownload(book2)!; + useTransferStore.getState().setTransferStatus(id1, 'failed', 'boom'); + + transferManager.clearFailed(); + + expect(useTransferStore.getState().transfers[id1]).toBeUndefined(); + expect(useTransferStore.getState().transfers[id2]).toBeDefined(); + + const stored = JSON.parse(localStorage.getItem('readest_transfer_queue')!); + expect(stored.transfers[id1]).toBeUndefined(); + expect(stored.transfers[id2]).toBeDefined(); + }); + }); + + // ── clearAll ───────────────────────────────────────────────────── + describe('clearAll', () => { + test('removes every transfer and persists the empty queue', async () => { + const book1 = makeBook({ hash: 'h1', title: 'B1' }); + const book2 = makeBook({ hash: 'h2', title: 'B2' }); + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [book1, book2], + vi.fn(), + translationFn, + ); + + transferManager.pauseQueue(); + transferManager.queueUpload(book1); + transferManager.queueDownload(book2); + + transferManager.clearAll(); + + expect(Object.keys(useTransferStore.getState().transfers)).toHaveLength(0); + + const stored = JSON.parse(localStorage.getItem('readest_transfer_queue')!); + expect(Object.keys(stored.transfers)).toHaveLength(0); + }); + }); + // ── Queue processing (integration-style) ───────────────────────── describe('queue processing', () => { test('successful upload calls appService.uploadBook and updates book', async () => { diff --git a/apps/readest-app/src/__tests__/services/transformers/transformers.test.ts b/apps/readest-app/src/__tests__/services/transformers/transformers.test.ts index 3961506e6..4fa4567fa 100644 --- a/apps/readest-app/src/__tests__/services/transformers/transformers.test.ts +++ b/apps/readest-app/src/__tests__/services/transformers/transformers.test.ts @@ -20,6 +20,7 @@ vi.mock('@/utils/lang', () => ({ getLanguageInfo: vi.fn(() => ({ direction: 'ltr' })), isSameLang: vi.fn(() => true), isValidLang: vi.fn(() => true), + normalizedLangCode: (lang?: string | null) => (lang ? lang.split('-')[0]!.toLowerCase() : ''), })); vi.mock('@/store/settingsStore', () => ({ @@ -769,6 +770,140 @@ describe('simpleccTransformer', () => { }); }); +// ============================================================================= +// nbspTransformer +// ============================================================================= + +describe('nbspTransformer', () => { + let nbspTransformer: typeof import('@/services/transformers/nbsp').nbspTransformer; + + beforeEach(async () => { + ({ nbspTransformer } = await import('@/services/transformers/nbsp')); + }); + + test('has the correct name', () => { + expect(nbspTransformer.name).toBe('nbsp'); + }); + + test('returns content unchanged for unsupported languages', async () => { + const html = '

в доме

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'en' }), + ); + expect(result).toBe(html); + }); + + describe('for Russian books', () => { + const NBSP = '\u00A0'; + + test('glues a single-letter preposition to the next word', async () => { + const html = '

книга в доме

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toContain(`в${NBSP}доме`); + expect(result).not.toContain('в доме'); + }); + + test('glues a two-letter conjunction', async () => { + const html = '

тепло но ветрено

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toContain(`но${NBSP}ветрено`); + }); + + test('glues multi-letter function words from the list', async () => { + const html = '

только если хотя дом

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru-RU' }), + ); + expect(result).toContain(`только${NBSP}если${NBSP}хотя${NBSP}дом`); + }); + + test('handles capitalized words at the start of a sentence', async () => { + const html = '

Но что делать

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toContain(`Но${NBSP}что${NBSP}делать`); + }); + + test('glues consecutive short words', async () => { + const html = '

и в доме

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toContain(`и${NBSP}в${NBSP}доме`); + }); + + test('does not split a longer word that begins with a short word', async () => { + const html = '

в наказание

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toContain(`в${NBSP}наказание`); + // "на" inside "наказание" must stay intact (no NBSP injected mid-word) + expect(result).not.toContain(`на${NBSP}`); + }); + + test('leaves text without short function words unchanged', async () => { + const html = '

наш дом большой

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toBe(html); + }); + + test('does not modify attribute values, only text nodes', async () => { + const html = '

в доме

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toContain('title="это и то"'); + expect(result).toContain(`в${NBSP}доме`); + }); + + test('skips

в доме

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + // body text glued, style text left intact (regular space) + expect(result).toContain(''); + expect(result).toContain(`

в${NBSP}доме

`); + }); + + test('does not glue a preposition before a non-Cyrillic word', async () => { + const html = '

читаю в Google

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result).toContain('в Google'); + expect(result).not.toContain(`в${NBSP}Google`); + }); + + test('preserves total length so CFIs stay valid', async () => { + const html = '

Он пришёл в дом и сел у окна около 5 часов.

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result.length).toBe(html.length); + // length is unchanged, but NBSPs were actually inserted + expect(result).not.toBe(html); + }); + + test('keeps length stable with consecutive short words', async () => { + const html = '

и в доме

'; + const result = await nbspTransformer.transform( + makeCtx({ content: html, primaryLanguage: 'ru' }), + ); + expect(result.length).toBe(html.length); + expect(result).toContain(`и${NBSP}в${NBSP}доме`); + }); + }); +}); + // ============================================================================= // availableTransformers (index) // ============================================================================= @@ -785,6 +920,7 @@ describe('availableTransformers', () => { expect(names).toContain('language'); expect(names).toContain('simplecc'); expect(names).toContain('proofread'); + expect(names).toContain('nbsp'); }); test('each transformer has a name and transform function', async () => { diff --git a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx index 3cb99f85a..58bf53b1a 100644 --- a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx +++ b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx @@ -78,8 +78,14 @@ vi.mock('@/services/tts/WebSpeechClient', () => ({ }), })); vi.mock('@/services/tts/EdgeTTSClient', () => ({ + // useTTSControl also imports this named const from the same module; the + // mock factory replaces the whole module, so it must re-export it too. + DEFAULT_SENTENCE_GAP_SEC: 0.15, EdgeTTSClient: vi.fn().mockImplementation(function (this: Record) { - Object.assign(this, makeMockTTSClient('edge')); + // TTSController.setSentenceGap always forwards to the real ttsEdgeClient + // instance regardless of the active engine, so this mock needs the method + // even though the other two client mocks don't. + Object.assign(this, makeMockTTSClient('edge'), { setSentenceGap: () => {} }); }), })); vi.mock('@/services/tts/NativeTTSClient', () => ({ @@ -123,6 +129,7 @@ interface RelocateDetail { location: PageInfo; time: { section: number; total: number }; range: Range; + fraction: number; } type BookProgressPageItem = { label?: string; href?: string } | null; @@ -197,6 +204,7 @@ const seedStores = (view: FoliateView, viewSettings: ViewSettings) => { error: null, ribbonVisible: false, ttsEnabled: false, + autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, @@ -226,6 +234,7 @@ const wireRelocate = (view: FoliateView) => { pageInfo, detail.time, detail.range, + detail.fraction, ); }); }; diff --git a/apps/readest-app/src/__tests__/services/tts-controller-detach.test.ts b/apps/readest-app/src/__tests__/services/tts-controller-detach.test.ts new file mode 100644 index 000000000..3bb52c3fd --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-controller-detach.test.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TTSController } from '@/services/tts/TTSController'; +import { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient'; +import { FoliateView } from '@/types/view'; + +// Detach/attach suite: headless operation guards, swap-time re-seed, and +// attach-epoch cancellation. + +const makeMockClient = (name: string): TTSClient => ({ + name, + initialized: true, + init: vi.fn().mockResolvedValue(true), + shutdown: vi.fn().mockResolvedValue(undefined), + speak: vi.fn().mockImplementation(async function* (): AsyncIterable { + yield { code: 'boundary', message: 'chunk', mark: '0' }; + }), + pause: vi.fn().mockResolvedValue(true), + resume: vi.fn().mockResolvedValue(true), + stop: vi.fn().mockResolvedValue(undefined), + setPrimaryLang: vi.fn(), + setRate: vi.fn().mockResolvedValue(undefined), + setPitch: vi.fn().mockResolvedValue(undefined), + setVoice: vi.fn().mockResolvedValue(undefined), + getAllVoices: vi.fn().mockResolvedValue([]), + getVoices: vi.fn().mockResolvedValue([]), + getGranularities: vi.fn().mockReturnValue(['sentence']), + supportsWordBoundaries: vi.fn().mockReturnValue(false), + getVoiceId: vi.fn().mockReturnValue('detach-voice'), + getSpeakingLang: vi.fn().mockReturnValue('en'), +}); + +vi.mock('@/services/tts/WebSpeechClient', () => ({ + WebSpeechClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('web-speech')); + }), +})); +vi.mock('@/services/tts/EdgeTTSClient', () => ({ + EdgeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('edge-tts')); + }), +})); +vi.mock('@/services/tts/NativeTTSClient', () => ({ + NativeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('native')); + }), +})); +vi.mock('@/services/tts/TTSUtils', () => ({ + TTSUtils: { + getPreferredClient: vi.fn().mockReturnValue('edge-tts'), + setPreferredClient: vi.fn(), + setPreferredVoice: vi.fn(), + getPreferredVoice: vi.fn().mockReturnValue(null), + }, +})); +vi.mock('foliate-js/overlayer.js', () => ({ Overlayer: { highlight: 'highlightFn' } })); + +interface MockTtsInstance { + start: ReturnType; + resume: ReturnType; + next: ReturnType; + prev: ReturnType; + from: ReturnType; + setMark: ReturnType; + getLastRange: ReturnType; + doc: Document | null; +} +const mockTtsInstances: MockTtsInstance[] = []; +let ttsNextReturns: (string | undefined)[] = []; + +vi.mock('foliate-js/tts.js', () => ({ + TTS: vi.fn().mockImplementation(function (this: Record, doc: Document) { + const instance = { + start: vi.fn().mockReturnValue('hello'), + resume: vi.fn().mockReturnValue('hello'), + next: vi.fn().mockImplementation(() => ttsNextReturns.shift()), + prev: vi.fn().mockReturnValue(undefined), + from: vi.fn().mockReturnValue('from'), + setMark: vi.fn().mockImplementation(() => new Range()), + getLastRange: vi.fn().mockReturnValue(undefined), + doc, + }; + Object.assign(this, instance); + mockTtsInstances.push(instance); + }), + getSentences: vi.fn().mockImplementation(function* () {}), +})); +vi.mock('foliate-js/text-walker.js', () => ({ textWalker: vi.fn() })); +vi.mock('@/utils/ssml', () => ({ + filterSSMLWithLang: vi.fn((ssml: string) => ssml), + parseSSMLMarks: vi.fn(() => ({ + plainText: 'hello', + marks: [{ offset: 0, name: '0', text: 'hello', language: 'en' }], + })), +})); +vi.mock('@/utils/node', () => ({ createRejectFilter: vi.fn(() => () => 1) })); +vi.mock('@/utils/lang', () => ({ + isValidLang: vi.fn(() => true), + isCJKLang: vi.fn(() => false), +})); + +interface ViewOptions { + createDocument?: () => Promise; + rendered?: boolean; +} + +const makeView = (options: ViewOptions = {}): { view: FoliateView; overlayer: unknown } => { + const doc = document.implementation.createHTMLDocument('section'); + const overlayer = { add: vi.fn(), remove: vi.fn() }; + const view = { + book: { + sections: [ + { createDocument: options.createDocument ?? vi.fn().mockResolvedValue(doc) }, + { createDocument: vi.fn().mockResolvedValue(doc) }, + ], + }, + renderer: { + getContents: () => (options.rendered === false ? [] : [{ doc, index: 0, overlayer }]), + primaryIndex: 0, + }, + language: { isCJK: false }, + getCFI: vi.fn().mockReturnValue('epubcfi(/6/2!/4/2)'), + resolveCFI: vi.fn().mockImplementation(() => ({ index: 0, anchor: () => new Range() })), + tts: null, + } as unknown as FoliateView; + return { view, overlayer }; +}; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('TTSController detach/attach', () => { + let controller: TTSController; + let view: FoliateView; + let onSectionChange: ReturnType Promise>>; + + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockTtsInstances.length = 0; + ttsNextReturns = []; + onSectionChange = vi.fn<(sectionIndex: number) => Promise>().mockResolvedValue(undefined); + ({ view } = makeView()); + controller = new TTSController(null, view, false, undefined, onSectionChange); + await controller.init(); + await controller.initViewTTS(0); + }); + + test('starts attached; detachView flips the flag idempotently', () => { + expect(controller.isViewAttached).toBe(true); + controller.detachView(); + expect(controller.isViewAttached).toBe(false); + expect(() => controller.detachView()).not.toThrow(); + }); + + test('detached playback advances without touching layout or dead callbacks', async () => { + const preprocess = vi.fn(async (ssml: string) => ssml); + controller.preprocessCallback = preprocess; + const positions: string[] = []; + controller.addEventListener('tts-position', (e) => { + positions.push((e as CustomEvent).detail.cfi); + }); + + controller.detachView(); + ttsNextReturns = ['next']; + controller.state = 'playing'; + await controller.forward(); + await flush(); + + expect(mockTtsInstances[0]!.next).toHaveBeenCalled(); + // Dead-hook closures are severed at detach. + expect(preprocess).not.toHaveBeenCalled(); + expect(onSectionChange).not.toHaveBeenCalled(); + // Position events keep flowing for persistence/lock-screen. + expect(positions.length).toBeGreaterThan(0); + }); + + test('attachView swaps to a new view: re-seeds, rebinds, invalidates timeline', async () => { + controller.detachView(); + + // The old instance's cursor is the seed source. + const oldRange = new Range(); + mockTtsInstances[0]!.getLastRange.mockReturnValue(oldRange); + + const { view: newView } = makeView(); + const anchored = new Range(); + (newView.resolveCFI as ReturnType).mockReturnValue({ + index: 0, + anchor: () => anchored, + }); + const newPreprocess = vi.fn(async (ssml: string) => ssml); + const newSectionChange = vi.fn().mockResolvedValue(undefined); + + await controller.attachView(newView, { + bookKey: 'hash-new123', + preprocessCallback: newPreprocess, + onSectionChange: newSectionChange, + }); + + expect(controller.isViewAttached).toBe(true); + expect(controller.view).toBe(newView); + expect(mockTtsInstances).toHaveLength(2); + const newTts = mockTtsInstances[1]!; + // Seeded at the old cursor, anchored into the NEW doc, SSML discarded. + expect(newTts.from).toHaveBeenCalledWith(anchored); + expect(controller.ttsClient.speak).not.toHaveBeenCalled(); + // The mirror points at the new instance. + expect(newView.tts).toBeTruthy(); + expect(controller.preprocessCallback).toBe(newPreprocess); + expect(controller.onSectionChange).toBe(newSectionChange); + }); + + test('attach re-seeds from the cursor position at swap time, not prep time', async () => { + controller.detachView(); + + const earlyRange = new Range(); + const lateRange = new Range(); + mockTtsInstances[0]!.getLastRange.mockReturnValue(earlyRange); + + // Gate the new view's section doc so prep stays in flight. + let releaseDoc!: (doc: Document) => void; + const gated = new Promise((resolve) => { + releaseDoc = resolve; + }); + const { view: newView } = makeView({ createDocument: () => gated, rendered: false }); + const anchorSpy = vi.fn().mockReturnValue(new Range()); + (newView.resolveCFI as ReturnType).mockReturnValue({ + index: 0, + anchor: anchorSpy, + }); + const getCFISpy = newView.getCFI as ReturnType; + + const attaching = controller.attachView(newView, { bookKey: 'hash-race' }); + await flush(); + // The old cursor advances while prep is in flight (paragraph auto-advance). + mockTtsInstances[0]!.getLastRange.mockReturnValue(lateRange); + releaseDoc(document.implementation.createHTMLDocument('late')); + await attaching; + + // The CFI for the seed must be computed from the LATE range. + const seedCall = getCFISpy.mock.calls.find(([, range]) => range === lateRange); + expect(seedCall).toBeTruthy(); + expect(getCFISpy.mock.calls.some(([, range]) => range === earlyRange)).toBe(false); + }); + + test('detachView during attach prep cancels the attach', async () => { + controller.detachView(); + + let releaseDoc!: (doc: Document) => void; + const gated = new Promise((resolve) => { + releaseDoc = resolve; + }); + const { view: newView } = makeView({ createDocument: () => gated, rendered: false }); + + const attaching = controller.attachView(newView, { bookKey: 'hash-cancel' }); + await flush(); + controller.detachView(); // e.g. the new view closed while attach prepared + releaseDoc(document.implementation.createHTMLDocument('stale')); + await attaching; + + expect(controller.isViewAttached).toBe(false); + expect(controller.view).not.toBe(newView); + const staleInstance = mockTtsInstances[1]; + if (staleInstance) expect(staleInstance.from).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-controller-lifecycle.test.ts b/apps/readest-app/src/__tests__/services/tts-controller-lifecycle.test.ts new file mode 100644 index 000000000..305499c68 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-controller-lifecycle.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TTSController } from '@/services/tts/TTSController'; +import { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient'; +import { FoliateView } from '@/types/view'; + +// Focused lifecycle suite: controller-owned #tts, the state-change event +// channel, and terminal (tts-session-ended) semantics. Mocks mirror the +// controller-timeline suite's harness. + +const makeMockClient = (name: string): TTSClient => ({ + name, + initialized: true, + init: vi.fn().mockResolvedValue(true), + shutdown: vi.fn().mockResolvedValue(undefined), + speak: vi.fn().mockImplementation(async function* (): AsyncIterable { + yield { code: 'end', message: 'done' }; + }), + pause: vi.fn().mockResolvedValue(true), + resume: vi.fn().mockResolvedValue(true), + stop: vi.fn().mockResolvedValue(undefined), + setPrimaryLang: vi.fn(), + setRate: vi.fn().mockResolvedValue(undefined), + setPitch: vi.fn().mockResolvedValue(undefined), + setVoice: vi.fn().mockResolvedValue(undefined), + getAllVoices: vi.fn().mockResolvedValue([]), + getVoices: vi.fn().mockResolvedValue([]), + getGranularities: vi.fn().mockReturnValue(['sentence']), + supportsWordBoundaries: vi.fn().mockReturnValue(false), + getVoiceId: vi.fn().mockReturnValue('lifecycle-voice'), + getSpeakingLang: vi.fn().mockReturnValue('en'), +}); + +vi.mock('@/services/tts/WebSpeechClient', () => ({ + WebSpeechClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('web-speech')); + }), +})); +vi.mock('@/services/tts/EdgeTTSClient', () => ({ + EdgeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('edge-tts')); + }), +})); +vi.mock('@/services/tts/NativeTTSClient', () => ({ + NativeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('native')); + }), +})); +vi.mock('@/services/tts/TTSUtils', () => ({ + TTSUtils: { + getPreferredClient: vi.fn().mockReturnValue('edge-tts'), + setPreferredClient: vi.fn(), + setPreferredVoice: vi.fn(), + getPreferredVoice: vi.fn().mockReturnValue(null), + }, +})); +vi.mock('foliate-js/overlayer.js', () => ({ Overlayer: { highlight: 'highlightFn' } })); + +// Mutable per-test behavior for the mocked foliate TTS instance. +let ttsNextReturns: (string | undefined)[] = []; +interface MockTtsInstance { + start: ReturnType; + resume: ReturnType; + next: ReturnType; + prev: ReturnType; + from: ReturnType; + setMark: ReturnType; + getLastRange: ReturnType; + doc: Document | null; +} +const mockTtsInstances: MockTtsInstance[] = []; + +vi.mock('foliate-js/tts.js', () => ({ + TTS: vi.fn().mockImplementation(function (this: Record) { + const instance = { + start: vi.fn().mockReturnValue('hello'), + resume: vi.fn().mockReturnValue('hello'), + next: vi.fn().mockImplementation(() => ttsNextReturns.shift()), + prev: vi.fn().mockReturnValue(undefined), + from: vi.fn().mockReturnValue('from'), + setMark: vi.fn().mockReturnValue(undefined), + getLastRange: vi.fn().mockReturnValue(undefined), + doc: null, + }; + Object.assign(this, instance); + mockTtsInstances.push(instance); + }), + getSentences: vi.fn().mockImplementation(function* () {}), +})); +vi.mock('foliate-js/text-walker.js', () => ({ textWalker: vi.fn() })); +vi.mock('@/utils/ssml', () => ({ + filterSSMLWithLang: vi.fn((ssml: string) => ssml), + parseSSMLMarks: vi.fn(() => ({ + plainText: 'hello', + marks: [{ offset: 0, name: '0', text: 'hello', language: 'en' }], + })), +})); +vi.mock('@/utils/node', () => ({ createRejectFilter: vi.fn(() => () => 1) })); +vi.mock('@/utils/lang', () => ({ + isValidLang: vi.fn(() => true), + isCJKLang: vi.fn(() => false), +})); + +const makeView = (): FoliateView => { + const doc = document.implementation.createHTMLDocument('t'); + return { + book: { sections: [{ createDocument: vi.fn().mockResolvedValue(doc) }] }, + renderer: { + getContents: () => [{ doc, index: 0, overlayer: { add: vi.fn(), remove: vi.fn() } }], + primaryIndex: 0, + }, + language: { isCJK: false }, + getCFI: vi.fn().mockReturnValue('epubcfi(/6/2!/4/2)'), + resolveCFI: vi.fn().mockReturnValue({ anchor: () => new Range() }), + tts: null, + } as unknown as FoliateView; +}; + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('TTSController lifecycle', () => { + let controller: TTSController; + let view: FoliateView; + + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + ttsNextReturns = []; + mockTtsInstances.length = 0; + view = makeView(); + controller = new TTSController(null, view); + await controller.init(); + await controller.initViewTTS(0); + }); + + test('survives view.tts being nulled by view.close()', async () => { + ttsNextReturns = ['next']; + view.tts = null; // what foliate view.close() does + controller.state = 'playing'; + await controller.forward(); + expect(mockTtsInstances[0]!.next).toHaveBeenCalled(); + }); + + test('state changes dispatch tts-state-change once per actual change, on a microtask', async () => { + const events: string[] = []; + controller.addEventListener('tts-state-change', (e) => { + events.push((e as CustomEvent).detail.state); + }); + controller.state = 'playing'; + expect(events).toHaveLength(0); // deferred, not re-entrant + await flushMicrotasks(); + expect(events).toEqual(['playing']); + controller.state = 'playing'; // idempotent assignment + await flushMicrotasks(); + expect(events).toEqual(['playing']); + controller.state = 'paused'; + await flushMicrotasks(); + expect(events).toEqual(['playing', 'paused']); + }); + + test('a paragraph-advance cycle fires no tts-session-ended', async () => { + const ended = vi.fn(); + controller.addEventListener('tts-session-ended', ended); + // Hold the auto-advance chain after one paragraph: a speak that ends on + // 'boundary' does not trigger forward(), so the single advance under test + // is isolated from the mock queue running dry. + (controller.ttsClient.speak as ReturnType).mockImplementation( + async function* (): AsyncIterable { + yield { code: 'boundary', message: 'chunk', mark: '0' }; + }, + ); + ttsNextReturns = ['next']; + controller.state = 'playing'; + await controller.forward(); // stop() -> 'stopped' -> speak -> 'playing' + await flushMicrotasks(); + expect(mockTtsInstances[0]!.next).toHaveBeenCalled(); + expect(ended).not.toHaveBeenCalled(); + expect(controller.terminated).toBe(false); + }); + + test('end of book fires tts-session-ended exactly once with reason ended', async () => { + const ended = vi.fn(); + controller.addEventListener('tts-session-ended', ended); + ttsNextReturns = [undefined]; // no next paragraph and no next section + controller.state = 'playing'; + await controller.forward(); + await flushMicrotasks(); + expect(ended).toHaveBeenCalledTimes(1); + expect((ended.mock.calls[0]![0] as CustomEvent).detail.reason).toBe('ended'); + expect(controller.terminated).toBe(true); + }); + + test('a new speak resets terminated', async () => { + ttsNextReturns = [undefined]; + controller.state = 'playing'; + await controller.forward(); // terminates + expect(controller.terminated).toBe(true); + (controller.ttsClient.speak as ReturnType).mockImplementation( + async function* (): AsyncIterable { + yield { code: 'boundary', message: 'chunk', mark: '0' }; + }, + ); + await controller.speak('again'); + await flushMicrotasks(); + expect(controller.terminated).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-controller-timeline.test.ts b/apps/readest-app/src/__tests__/services/tts-controller-timeline.test.ts new file mode 100644 index 000000000..5867d67d1 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-controller-timeline.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TTSController } from '@/services/tts/TTSController'; +import { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient'; +import { recordMeasuredDuration } from '@/services/tts/ttsDuration'; +import { FoliateView } from '@/types/view'; + +// --- Heavy clients replaced with light fakes (same pattern as the main +// controller suite); foliate tts.js provides a REAL-shaped getSentences fake +// that yields ranges from a jsdom document. + +const makeMockClient = (name: string): TTSClient => ({ + name, + initialized: true, + init: vi.fn().mockResolvedValue(true), + shutdown: vi.fn().mockResolvedValue(undefined), + speak: vi.fn().mockImplementation(async function* (): AsyncIterable { + yield { code: 'end', message: 'done' }; + }), + pause: vi.fn().mockResolvedValue(true), + resume: vi.fn().mockResolvedValue(true), + stop: vi.fn().mockResolvedValue(undefined), + setPrimaryLang: vi.fn(), + setRate: vi.fn().mockResolvedValue(undefined), + setPitch: vi.fn().mockResolvedValue(undefined), + setVoice: vi.fn().mockResolvedValue(undefined), + getAllVoices: vi.fn().mockResolvedValue([]), + getVoices: vi.fn().mockResolvedValue([]), + getGranularities: vi.fn().mockReturnValue(['sentence']), + supportsWordBoundaries: vi.fn().mockReturnValue(true), + getVoiceId: vi.fn().mockReturnValue('timeline-ctrl-voice'), + getSpeakingLang: vi.fn().mockReturnValue('en'), + getChunkPosition: vi.fn().mockReturnValue(0.5), +}); + +vi.mock('@/services/tts/WebSpeechClient', () => ({ + WebSpeechClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('web-speech')); + }), +})); + +vi.mock('@/services/tts/EdgeTTSClient', () => ({ + EdgeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('edge-tts')); + }), +})); + +vi.mock('@/services/tts/NativeTTSClient', () => ({ + NativeTTSClient: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, makeMockClient('native')); + }), +})); + +vi.mock('@/services/tts/TTSUtils', () => ({ + TTSUtils: { + getPreferredClient: vi.fn().mockReturnValue('edge-tts'), + setPreferredClient: vi.fn(), + setPreferredVoice: vi.fn(), + getPreferredVoice: vi.fn().mockReturnValue(null), + }, +})); + +vi.mock('foliate-js/overlayer.js', () => ({ + Overlayer: { highlight: 'highlightFn' }, +})); + +// Sentences of the fake section; ends avoid foliate's short-word +// abbreviation merge so counts stay predictable. +const S0 = 'The opening sentence of the chapter reads aloud smoothly.'; +const S1 = 'A following sentence continues the passage without pause.'; +const S2 = 'The final sentence wraps the paragraph completely together.'; + +let sectionDoc: Document; +let sentenceRanges: Range[] = []; + +const buildSectionDoc = () => { + const parser = new DOMParser(); + sectionDoc = parser.parseFromString( + `

${S0} ${S1} ${S2}

`, + 'text/html', + ); + const textNode = sectionDoc.getElementById('p')!.firstChild!; + const full = textNode.textContent!; + sentenceRanges = [S0, S1, S2].map((s) => { + const start = full.indexOf(s); + const range = sectionDoc.createRange(); + range.setStart(textNode, start); + range.setEnd(textNode, start + s.length); + return range; + }); +}; + +vi.mock('foliate-js/tts.js', () => ({ + TTS: vi.fn().mockImplementation(function (this: Record) { + Object.assign(this, { + start: vi.fn().mockReturnValue('hello'), + resume: vi.fn().mockReturnValue('hello'), + // End-of-section after one paragraph so auto-advance terminates instead + // of looping the controller forever in tests. + next: vi.fn().mockReturnValue(undefined), + prev: vi.fn().mockReturnValue(undefined), + from: vi.fn().mockReturnValue('from'), + setMark: vi.fn().mockImplementation(() => sentenceRanges[0]!.cloneRange()), + getLastRange: vi.fn().mockImplementation(() => sentenceRanges[0]!.cloneRange()), + doc: null, + }); + }), + getSentences: vi.fn().mockImplementation(function* () { + for (let i = 0; i < sentenceRanges.length; i++) { + yield { blockIndex: 0, markName: String(i), range: sentenceRanges[i]! }; + } + }), +})); + +vi.mock('foliate-js/text-walker.js', () => ({ textWalker: vi.fn() })); + +vi.mock('@/utils/ssml', () => ({ + filterSSMLWithLang: vi.fn((ssml: string) => ssml), + parseSSMLMarks: vi.fn(() => ({ + plainText: 'hello', + marks: [{ offset: 0, name: '0', text: 'hello', language: 'en' }], + })), +})); + +vi.mock('@/utils/node', () => ({ createRejectFilter: vi.fn(() => () => 1) })); +vi.mock('@/utils/lang', () => ({ + isValidLang: vi.fn(() => true), + isCJKLang: vi.fn(() => false), +})); + +const makeView = () => { + const contents = { + doc: sectionDoc, + index: 0, + overlayer: { add: vi.fn(), remove: vi.fn() }, + }; + return { + book: { sections: [{ createDocument: vi.fn().mockResolvedValue(sectionDoc) }] }, + renderer: { getContents: () => [contents], primaryIndex: 0 }, + language: { isCJK: false }, + getCFI: vi.fn().mockReturnValue('epubcfi(/6/2!/4/2)'), + resolveCFI: vi.fn().mockReturnValue({ anchor: () => sentenceRanges[0] }), + tts: null, + } as unknown as FoliateView; +}; + +describe('TTSController section timeline', () => { + let controller: TTSController; + + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + buildSectionDoc(); + controller = new TTSController(null, makeView()); + await controller.init(); + await controller.initViewTTS(0); + }); + + test('ensureTimeline builds lazily for the edge client and caches per section', async () => { + const timeline = await controller.ensureTimeline(); + expect(timeline).not.toBeNull(); + expect(timeline!.length).toBe(3); + expect(await controller.ensureTimeline()).toBe(timeline); + }); + + test('getPlaybackInfo composes sentence position with the chunk clock', async () => { + recordMeasuredDuration('timeline-ctrl-voice', S0, 4); + recordMeasuredDuration('timeline-ctrl-voice', S1, 6); + await controller.ensureTimeline(); + // getLastRange resolves to sentence 0; client chunk position is 0.5s. + const info = controller.getPlaybackInfo(); + expect(info).not.toBeNull(); + expect(info!.position).toBeCloseTo(0.5, 5); + expect(info!.duration).toBeGreaterThan(10); + expect(info!.measuredFraction).toBeGreaterThan(0); + }); + + test('getPlaybackInfo is null before the timeline is built (reserved-slot state)', () => { + expect(controller.getPlaybackInfo()).toBeNull(); + }); + + test('getPlaybackInfo is null for non-edge clients', async () => { + await controller.setVoice('', 'en'); // empty voice id: falls through to web client + controller.ttsClient = controller.ttsWebClient; + expect(await controller.ensureTimeline()).toBeNull(); + expect(controller.getPlaybackInfo()).toBeNull(); + }); + + test('seekToTime snaps to the sentence and speaks from its range while playing', async () => { + recordMeasuredDuration('timeline-ctrl-voice', S0, 4); + recordMeasuredDuration('timeline-ctrl-voice', S1, 6); + await controller.ensureTimeline(); + controller.state = 'playing'; + await controller.seekToTime(5); // inside sentence 1 + const tts = controller.view.tts as unknown as { from: ReturnType }; + expect(tts.from).toHaveBeenCalledTimes(1); + const arg = tts.from.mock.calls[0]![0] as Range; + expect(arg.toString()).toBe(S1); + }); + + test('seekToTime while paused stays paused and still navigates', async () => { + await controller.ensureTimeline(); + controller.state = 'paused'; + await controller.seekToTime(0); + expect(controller.state).toBe('forward-paused'); + const tts = controller.view.tts as unknown as { from: ReturnType }; + expect(tts.from).toHaveBeenCalled(); + }); + + test('seekToTime past the end clamps to the last sentence', async () => { + await controller.ensureTimeline(); + controller.state = 'playing'; + await controller.seekToTime(9999); + const tts = controller.view.tts as unknown as { from: ReturnType }; + const arg = tts.from.mock.calls[0]![0] as Range; + expect(arg.toString()).toBe(S2); + }); + + test('setRate rescales the timeline', async () => { + recordMeasuredDuration('timeline-ctrl-voice', S0, 4); + recordMeasuredDuration('timeline-ctrl-voice', S1, 4); + recordMeasuredDuration('timeline-ctrl-voice', S2, 4); + const timeline = await controller.ensureTimeline(); + const before = timeline!.getDuration(); + await controller.setRate(2); + expect(timeline!.getDuration()).toBeCloseTo(before / 2, 5); + }); + + test('shutdown drops the timeline', async () => { + await controller.ensureTimeline(); + await controller.shutdown(); + expect(controller.getPlaybackInfo()).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-controller.test.ts b/apps/readest-app/src/__tests__/services/tts-controller.test.ts index 9079c2e73..83ba129af 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -16,7 +16,7 @@ vi.mock('@/services/tts/WebSpeechClient', () => ({ vi.mock('@/services/tts/EdgeTTSClient', () => ({ EdgeTTSClient: vi.fn().mockImplementation(function (this: Record) { - Object.assign(this, createMockTTSClient('edge')); + Object.assign(this, createMockTTSClient('edge'), { setSentenceGap: vi.fn() }); }), })); @@ -67,6 +67,7 @@ vi.mock('foliate-js/tts.js', () => ({ nextMark: vi.fn().mockReturnValue('nextMark'), prevMark: vi.fn().mockReturnValue('prevMark'), setMark: vi.fn().mockReturnValue(new Range()), + getLastRange: vi.fn().mockReturnValue(new Range()), doc: null, }); }), @@ -142,9 +143,10 @@ function createMockView(): FoliateView { // --- Helper: create mock AppService --- -function createMockAppService(isAndroid = false): AppService { +function createMockAppService(isAndroid = false, isIOS = false): AppService { return { isAndroidApp: isAndroid, + isIOSApp: isIOS, } as unknown as AppService; } @@ -215,7 +217,13 @@ describe('TTSController', () => { expect(c.ttsNativeClient).not.toBeNull(); }); - test('does not create native client when not Android', () => { + test('creates native client when isIOSApp', () => { + const iosService = createMockAppService(false, true); + const c = new TTSController(iosService, mockView); + expect(c.ttsNativeClient).not.toBeNull(); + }); + + test('does not create native client when neither Android nor iOS', () => { expect(controller.ttsNativeClient).toBeNull(); }); @@ -288,6 +296,25 @@ describe('TTSController', () => { }); }); + describe('supportsGapControl', () => { + test('returns true when ttsClient is the edge client', () => { + controller.ttsClient = controller.ttsEdgeClient; + expect(controller.supportsGapControl()).toBe(true); + }); + + test('returns false when ttsClient is not the edge client', () => { + controller.ttsClient = controller.ttsWebClient; + expect(controller.supportsGapControl()).toBe(false); + }); + }); + + describe('setSentenceGap', () => { + test('delegates to ttsEdgeClient.setSentenceGap with the given value', () => { + controller.setSentenceGap(0.5); + expect(controller.ttsEdgeClient.setSentenceGap).toHaveBeenCalledWith(0.5); + }); + }); + describe('setVoice', () => { test('switches to edge client when voice found in edge voices', async () => { controller.ttsEdgeVoices = [{ id: 'edge-voice-1', name: 'Edge Voice', lang: 'en-US' }]; @@ -737,6 +764,29 @@ describe('TTSController', () => { controller.prepareSpeakWords([]); expect(controller.getCurrentHighlightCfi()).toBeNull(); }); + + test('granularity "sentence" forces sentence highlighting (skips word-by-word)', async () => { + controller.setHighlightGranularity('sentence'); + await armWithSentence(makeSentenceRange()); + getOverlayer().add.mockClear(); + // Even when the client reports word boundaries and calls prepareSpeakWords, + // the user's "sentence" choice keeps highlighting at the sentence level: the + // sentence highlight was already drawn at mark dispatch (not suppressed), so + // prepareSpeakWords is a no-op and word mode never engages. + controller.prepareSpeakWords(['Hello', 'brave', 'world']); + expect(getOverlayer().add).not.toHaveBeenCalled(); + expect(controller.getCurrentHighlightCfi()).toBeNull(); + }); + + test('granularity "word" (default) still highlights word-by-word', async () => { + controller.setHighlightGranularity('word'); + await armWithSentence(makeSentenceRange()); + getOverlayer().add.mockClear(); + controller.prepareSpeakWords(['Hello', 'brave', 'world']); + // First word highlighted immediately and word mode engaged. + expect(getOverlayer().add).toHaveBeenCalledTimes(1); + expect(controller.getCurrentHighlightCfi()).not.toBeNull(); + }); }); describe('tts-position event', () => { @@ -1109,6 +1159,92 @@ describe('TTSController', () => { }); }); + // Regression: Android System TTS (and iOS) read offline can report a terminal + // 'error' for an utterance it cannot synthesize — typically a specific + // unsupported character, hit characteristically on the first utterance after + // a chapter boundary even with a local/offline voice (online the engine often + // network-falls-back, which is why it only breaks offline). #speak only + // auto-advances on 'end', so without handling, one such error dead-ends + // playback ("stops at the end of the chapter") with the controls wedged in + // 'playing'. Re-speaking the same text would just fail again, so the + // controller skips the bad chunk and advances, bounding consecutive failures + // so a wholly-unusable engine still stops gracefully. See #4613, #4408. + describe('native TTS offline error recovery (#4613, #4408)', () => { + // An Android controller whose ACTIVE client is the native client, so the + // native-scoped recovery in #speak() is exercised. + const makeAndroidNativeController = async () => { + const androidService = createMockAppService(true); + const c = new TTSController(androidService, mockView); + await c.init(); + c.ttsClient = c.ttsNativeClient!; + await c.initViewTTS(0); + return c; + }; + + // A native speak() mock that always reports a terminal 'error' for real + // (non-preload) utterances — i.e. a deterministically unspeakable chunk. + // Preload calls (used to warm caches) resolve immediately like the real + // client and never count as attempts. + const alwaysErrorSpeakMock = (state: { attempts: number }) => + async function* ( + _ssml: string, + _signal: AbortSignal, + preload?: boolean, + ): AsyncGenerator { + if (preload) { + yield { code: 'end' }; + return; + } + state.attempts += 1; + yield { code: 'error', message: 'TTS playback error:-8' }; + }; + + test('skips a chunk the engine cannot speak and advances instead of dead-ending', async () => { + const c = await makeAndroidNativeController(); + // Stub forward() so the skip is observable without recursing through the + // mock sections; the point is that an error triggers an advance at all + // (retrying the same unspeakable text would be futile). + const forwardSpy = vi.spyOn(c, 'forward').mockResolvedValue(); + + const state = { attempts: 0 }; + vi.mocked(c.ttsNativeClient!.speak).mockImplementation(alwaysErrorSpeakMock(state)); + + c.speak('bad-char'); + + await vi.waitFor( + () => { + expect(forwardSpy).toHaveBeenCalled(); // advanced past the bad chunk + }, + { timeout: 5000 }, + ); + // It advanced rather than freezing in a phantom 'playing' halt. + expect(c.state).toBe('playing'); + }); + + test('stops gracefully after a run of consecutive unspeakable chunks', async () => { + const c = await makeAndroidNativeController(); + // Real forward(): each error skips to the next (mock) chunk, which also + // errors, until the consecutive-error cap stops playback. + + const state = { attempts: 0 }; + vi.mocked(c.ttsNativeClient!.speak).mockImplementation(alwaysErrorSpeakMock(state)); + + c.speak('bad-char'); + + // It skips past each unspeakable chunk — attempts climb past 1 (not an + // immediate halt) — until the consecutive-error cap is reached. (We key + // off attempts, not state: the controller starts 'stopped' and forward() + // transiently re-enters 'stopped' between chunks.) + await vi.waitFor(() => expect(state.attempts).toBeGreaterThanOrEqual(5), { timeout: 8000 }); + + // Let the cap-stop settle, then confirm it terminated (bounded, not + // racing to the end of the book) and is no longer playing. + await new Promise((r) => setTimeout(r, 150)); + expect(c.state).not.toBe('playing'); + expect(state.attempts).toBeLessThanOrEqual(10); + }); + }); + describe('preloadSSML', () => { test('does nothing when ssml is undefined', async () => { await controller.preloadSSML(undefined, new AbortController().signal); @@ -1203,4 +1339,68 @@ describe('TTSController', () => { expect(handler).toHaveBeenCalledTimes(1); }); }); + + describe('highlight hygiene', () => { + test('section change clears highlights from every live view, not just the primary', async () => { + // Two sections rendered at once (spread / preloaded adjacent view); the + // view has already navigated ahead so the primary is the NEW section. + const mockDoc = { querySelector: vi.fn().mockReturnValue(null) } as unknown as Document; + const overlayers = [ + { remove: vi.fn(), add: vi.fn() }, + { remove: vi.fn(), add: vi.fn() }, + ]; + const twoSectionView = { + renderer: { + primaryIndex: 1, + getContents: vi.fn().mockReturnValue([ + { doc: mockDoc, index: 0, overlayer: overlayers[0] }, + { doc: mockDoc, index: 1, overlayer: overlayers[1] }, + ]), + }, + book: { + sections: [ + { createDocument: vi.fn().mockResolvedValue(mockDoc) }, + { createDocument: vi.fn().mockResolvedValue(mockDoc) }, + ], + }, + language: { isCJK: false }, + tts: null, + getCFI: vi.fn().mockReturnValue('cfi-string'), + resolveCFI: vi.fn().mockReturnValue({ anchor: vi.fn().mockReturnValue(new Range()) }), + } as unknown as FoliateView; + const c = new TTSController(mockAppService, twoSectionView, false); + // Every section entry (start, prev/next, auto-advance) funnels through + // #initTTSForSection; entering a section must scrub the TTS highlight + // from EVERY live view, or the outgoing section's last spoken word + // stays highlighted forever in the preloaded neighbor. + await c.initViewTTS(0); + expect(overlayers[0]!.remove).toHaveBeenCalledWith('tts-highlight'); + expect(overlayers[1]!.remove).toHaveBeenCalledWith('tts-highlight'); + }); + + test('reapplyCurrentHighlight never draws the sentence in word mode while playing', async () => { + await controller.initViewTTS(0); + controller.ttsClient.supportsWordBoundaries = vi + .fn() + .mockReturnValue(true) as unknown as () => boolean; + controller.setHighlightGranularity('word'); + controller.state = 'playing'; + const content = ( + mockView.renderer.getContents() as unknown as { + overlayer: { add: ReturnType }; + }[] + )[0]!; + content.overlayer.add.mockClear(); + + // Between a sentence's mark and its first word boundary a page relocate + // triggers a re-apply; the whole sentence must not flash in. + controller.reapplyCurrentHighlight(); + expect(content.overlayer.add).not.toHaveBeenCalled(); + + // Paused keeps the sentence re-draw (deliberate navigation UX). + controller.state = 'paused'; + controller.reapplyCurrentHighlight(); + expect(content.overlayer.add).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/readest-app/src/__tests__/services/tts-duration.test.ts b/apps/readest-app/src/__tests__/services/tts-duration.test.ts new file mode 100644 index 000000000..d1ed21f9c --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-duration.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + calibrateVoiceRate, + defaultCharsPerSecond, + estimateSentenceSeconds, + getMeasuredDuration, + recordMeasuredDuration, + recordProvisionalDuration, +} from '@/services/tts/ttsDuration'; + +const VOICE = 'en-US-AriaNeural'; + +afterEach(() => { + localStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe('measured duration store', () => { + test('records and retrieves a duration by voice and text', () => { + recordMeasuredDuration(VOICE, 'The quick brown fox jumps.', 2.4); + expect(getMeasuredDuration(VOICE, 'The quick brown fox jumps.')).toBe(2.4); + }); + + test('punctuation and case variants hit the same key', () => { + recordMeasuredDuration(VOICE, 'Hello—world! Again…', 1.7); + expect(getMeasuredDuration(VOICE, 'hello world again')).toBe(1.7); + expect(getMeasuredDuration(VOICE, 'Hello, world; AGAIN')).toBe(1.7); + }); + + test('different voices do not share durations', () => { + recordMeasuredDuration(VOICE, 'Shared sentence text here.', 2.0); + expect(getMeasuredDuration('fr-FR-DeniseNeural', 'Shared sentence text here.')).toBeUndefined(); + }); + + test('provisional never overwrites measured; measured overwrites provisional', () => { + recordProvisionalDuration(VOICE, 'A provisional sentence sample.', 3.0); + expect(getMeasuredDuration(VOICE, 'A provisional sentence sample.')).toBe(3.0); + recordMeasuredDuration(VOICE, 'A provisional sentence sample.', 2.5); + expect(getMeasuredDuration(VOICE, 'A provisional sentence sample.')).toBe(2.5); + recordProvisionalDuration(VOICE, 'A provisional sentence sample.', 9.9); + expect(getMeasuredDuration(VOICE, 'A provisional sentence sample.')).toBe(2.5); + }); +}); + +describe('defaultCharsPerSecond', () => { + test('CJK languages are far denser per character than Latin', () => { + expect(defaultCharsPerSecond('zh-CN')).toBeLessThan(defaultCharsPerSecond('en-US')); + expect(defaultCharsPerSecond('ja')).toBeLessThan(defaultCharsPerSecond('fr')); + expect(defaultCharsPerSecond('ko')).toBeLessThan(defaultCharsPerSecond('de-DE')); + }); +}); + +describe('estimateSentenceSeconds', () => { + test('uses the measured duration when available', () => { + recordMeasuredDuration(VOICE, 'A measured sentence for estimates.', 4.2); + expect(estimateSentenceSeconds('A measured sentence for estimates.', 'en', VOICE)).toBe(4.2); + }); + + test('falls back to the script default without calibration', () => { + const text = 'x'.repeat(150); + const est = estimateSentenceSeconds(text, 'en', 'uncalibrated-voice'); + expect(est).toBeCloseTo(150 / defaultCharsPerSecond('en'), 3); + }); + + test('prefers the calibrated per-voice rate over the default', () => { + const voice = 'calibration-voice'; + // Voice speaks 20 normalized chars per second. + calibrateVoiceRate(voice, 'a'.repeat(40), 2); + calibrateVoiceRate(voice, 'b'.repeat(60), 3); + const est = estimateSentenceSeconds('c'.repeat(100), 'en', voice); + expect(est).toBeGreaterThan(100 / 25); + expect(est).toBeLessThan(100 / 15); + }); + + test('calibration converges toward the observed rate', () => { + const voice = 'converging-voice'; + for (let i = 0; i < 20; i++) { + calibrateVoiceRate(voice, 'a'.repeat(50), 5); // 10 cps observed + } + const est = estimateSentenceSeconds('d'.repeat(100), 'en', voice); + expect(est).toBeGreaterThan(100 / 12); + expect(est).toBeLessThan(100 / 8); + }); + + test('short texts are skipped for calibration', () => { + const voice = 'short-skip-voice'; + calibrateVoiceRate(voice, 'hi', 60); // absurd sample must be ignored + const est = estimateSentenceSeconds('e'.repeat(150), 'en', voice); + expect(est).toBeCloseTo(150 / defaultCharsPerSecond('en'), 3); + }); + + test('empty text estimates zero', () => { + expect(estimateSentenceSeconds('', 'en', VOICE)).toBe(0); + }); +}); + +describe('calibration stability (cumulative history)', () => { + test('a single outlier barely moves a well-established calibration', () => { + const voice = 'stable-voice'; + for (let i = 0; i < 100; i++) { + calibrateVoiceRate(voice, 'a'.repeat(50), 5); // 10 cps, 500s of history + } + const before = estimateSentenceSeconds('z'.repeat(100), 'en', voice); + calibrateVoiceRate(voice, 'b'.repeat(90), 3); // 30 cps outlier sentence + const after = estimateSentenceSeconds('z'.repeat(100), 'en', voice); + // The whole point of cumulative smoothing: one weird sentence must not + // re-price the chapter. (The old EMA moved this by ~28%.) + expect(Math.abs(after - before) / before).toBeLessThan(0.02); + }); + + test('history is duration-weighted, not sample-weighted', () => { + const voice = 'weighted-voice'; + calibrateVoiceRate(voice, 'a'.repeat(40), 2); // 20 cps for 2s + calibrateVoiceRate(voice, 'b'.repeat(90), 9); // 10 cps for 9s + // Cumulative ratio: 130 chars / 11 s, NOT the sample mean of 20 and 10. + const est = estimateSentenceSeconds('c'.repeat(100), 'en', voice); + expect(est).toBeCloseTo(100 / (130 / 11), 1); + }); + + test('legacy {cps,n} storage format is migrated as a prior', async () => { + localStorage.setItem( + 'readest-tts-voice-cps', + JSON.stringify({ 'legacy-voice': { cps: 20, n: 5 } }), + ); + vi.resetModules(); + const fresh = await import('@/services/tts/ttsDuration'); + const est = fresh.estimateSentenceSeconds('d'.repeat(100), 'en', 'legacy-voice'); + expect(est).toBeCloseTo(5, 1); // 100 chars at the legacy 20 cps + }); +}); + +describe('persistence', () => { + test('per-voice calibration survives a module reload via localStorage', async () => { + calibrateVoiceRate('persisted-voice', 'a'.repeat(50), 5); + vi.resetModules(); + const fresh = await import('@/services/tts/ttsDuration'); + const est = fresh.estimateSentenceSeconds('f'.repeat(100), 'en', 'persisted-voice'); + expect(est).toBeGreaterThan(100 / 16); + }); + + test('does not throw when localStorage is unavailable', () => { + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + clear: () => {}, + }); + expect(() => calibrateVoiceRate('blocked-voice', 'a'.repeat(50), 5)).not.toThrow(); + expect(() => estimateSentenceSeconds('g'.repeat(100), 'en', 'blocked-voice')).not.toThrow(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-fake-audio.ts b/apps/readest-app/src/__tests__/services/tts-fake-audio.ts new file mode 100644 index 000000000..e8809f564 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-fake-audio.ts @@ -0,0 +1,138 @@ +// Fake Web Audio primitives for jsdom tests. The fake clock is manual: +// advanceTo(t) fires due onended callbacks in end-time order, awaiting +// microtasks between each so schedulers and waiters can react, matching how +// the real audio clock interleaves with the JS task queue. + +import type { + TTSAudioBuffer, + TTSAudioBufferSourceNode, + TTSAudioContext, +} from '@/services/tts/WebAudioPlayer'; + +export class FakeAudioBuffer implements TTSAudioBuffer { + constructor( + public samples: Float32Array, + public readonly sampleRate: number, + ) {} + get length() { + return this.samples.length; + } + get duration() { + return this.samples.length / this.sampleRate; + } + getChannelData(_channel: number): Float32Array { + return this.samples; + } + copyToChannel(source: Float32Array, _channel: number): void { + this.samples.set(source.subarray(0, this.samples.length)); + } +} + +export class FakeSourceNode implements TTSAudioBufferSourceNode { + buffer: TTSAudioBuffer | null = null; + onended: (() => void) | null = null; + startedAt: number | null = null; + stopped = false; + connected = false; + endedFired = false; + + constructor(private ctx: FakeAudioContext) {} + + connect(_destination: unknown): void { + this.connected = true; + } + disconnect(): void { + this.connected = false; + } + start(when = 0): void { + this.startedAt = Math.max(when, this.ctx.currentTime); + this.ctx.sources.push(this); + } + stop(_when?: number): void { + this.stopped = true; + this.onended?.(); + } + get endTime(): number { + return (this.startedAt ?? 0) + (this.buffer?.duration ?? 0); + } +} + +export class FakeAudioContext implements TTSAudioContext { + // Constructed instances, newest last — client tests stub the global + // AudioContext with this class and need a handle on the context the shared + // singleton created internally. + static instances: FakeAudioContext[] = []; + + currentTime = 0; + state = 'running'; + destination = {}; + onstatechange: (() => void) | null = null; + sources: FakeSourceNode[] = []; + resumeCalls = 0; + suspendCalls = 0; + closeCalls = 0; + sampleRate: number; + // Overridable resume behavior for interruption-refusal tests. + resumeImpl: (() => void) | null = null; + decodeImpl: (data: ArrayBuffer) => Promise = async (data) => + new FakeAudioBuffer(new Float32Array(data.byteLength), this.sampleRate); + + constructor(sampleRate = 24000) { + this.sampleRate = sampleRate; + FakeAudioContext.instances.push(this); + } + + async resume(): Promise { + this.resumeCalls++; + if (this.resumeImpl) { + this.resumeImpl(); + } else { + this.state = 'running'; + } + this.onstatechange?.(); + } + async suspend(): Promise { + this.suspendCalls++; + this.state = 'suspended'; + this.onstatechange?.(); + } + async close(): Promise { + this.closeCalls++; + this.state = 'closed'; + this.onstatechange?.(); + } + createBufferSource(): FakeSourceNode { + return new FakeSourceNode(this); + } + createBuffer(_channels: number, length: number, sampleRate: number): FakeAudioBuffer { + return new FakeAudioBuffer(new Float32Array(length), sampleRate); + } + decodeAudioData(data: ArrayBuffer): Promise { + return this.decodeImpl(data); + } + + setState(state: string): void { + this.state = state; + this.onstatechange?.(); + } + + async advanceTo(t: number): Promise { + for (;;) { + const due = this.sources + .filter((s) => s.startedAt !== null && !s.stopped && !s.endedFired && s.endTime <= t) + .sort((a, b) => a.endTime - b.endTime)[0]; + if (!due) break; + this.currentTime = Math.max(this.currentTime, due.endTime); + due.endedFired = true; + due.onended?.(); + await Promise.resolve(); + await Promise.resolve(); + } + this.currentTime = Math.max(this.currentTime, t); + await Promise.resolve(); + await Promise.resolve(); + } +} + +export const makeBuffer = (seconds: number, sampleRate = 24000): FakeAudioBuffer => + new FakeAudioBuffer(new Float32Array(Math.round(seconds * sampleRate)), sampleRate); diff --git a/apps/readest-app/src/__tests__/services/tts-media-bridge.test.ts b/apps/readest-app/src/__tests__/services/tts-media-bridge.test.ts new file mode 100644 index 000000000..fa3acdc5b --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-media-bridge.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('@/utils/image', () => ({ + fetchImageAsBase64: vi.fn().mockResolvedValue('data:image/png;base64,x'), +})); + +import { TTSMediaBridge } from '@/services/tts/ttsMediaBridge'; +import { fetchImageAsBase64 } from '@/utils/image'; +import { TauriMediaSession } from '@/libs/mediaSession'; +import type { TTSController } from '@/services/tts/TTSController'; + +// A controller stand-in: EventTarget + the surface the bridge consumes. +class FakeController extends EventTarget { + state = 'playing'; + terminated = false; + pause = vi.fn().mockResolvedValue(true); + start = vi.fn().mockResolvedValue(undefined); + forward = vi.fn().mockResolvedValue(undefined); + backward = vi.fn().mockResolvedValue(undefined); + seekToTime = vi.fn().mockResolvedValue(undefined); + ensureTimeline = vi.fn().mockResolvedValue(null); + getPlaybackInfo = vi.fn().mockReturnValue({ position: 12, duration: 60, measuredFraction: 1 }); + + emitMark(text: string, name: string) { + this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: { text, name } })); + } + emitState(state: string) { + this.state = state; + this.dispatchEvent(new CustomEvent('tts-state-change', { detail: { state } })); + } +} + +interface FakeWebMediaSession { + metadata: unknown; + playbackState: string; + handlers: Map void>; + setActionHandler: ReturnType; + setPositionState: ReturnType; +} + +const makeFakeMediaSession = (): FakeWebMediaSession => { + const handlers = new Map void>(); + return { + metadata: null, + playbackState: 'none', + handlers, + setActionHandler: vi.fn( + (action: string, cb: ((d: MediaSessionActionDetails) => void) | null) => { + if (cb) handlers.set(action, cb); + else handlers.delete(action); + }, + ), + setPositionState: vi.fn(), + }; +}; + +const meta = (overrides = {}) => ({ + bookKey: 'hash-abc', + title: 'Alice', + author: 'Carroll', + coverImageUrl: null, + metadataMode: 'sentence' as const, + ...overrides, +}); + +// jsdom lacks MediaMetadata; the bridge constructs it for the web path. +class FakeMediaMetadata { + title: string; + artist: string; + album: string; + constructor(init: { title: string; artist: string; album: string }) { + this.title = init.title; + this.artist = init.artist; + this.album = init.album; + } +} +vi.stubGlobal('MediaMetadata', FakeMediaMetadata); + +describe('TTSMediaBridge', () => { + let controller: FakeController; + let fake: FakeWebMediaSession; + let bridge: TTSMediaBridge; + + beforeEach(() => { + controller = new FakeController(); + fake = makeFakeMediaSession(); + bridge = new TTSMediaBridge(() => fake as unknown as MediaSession); + }); + + const bind = () => bridge.bind(controller as unknown as TTSController, meta()); + + test('bind registers transport handlers that drive the controller', async () => { + await bind(); + expect(fake.handlers.has('play')).toBe(true); + fake.handlers.get('pause')!({} as MediaSessionActionDetails); + expect(controller.pause).toHaveBeenCalled(); + controller.state = 'paused'; + fake.handlers.get('play')!({} as MediaSessionActionDetails); + expect(controller.start).toHaveBeenCalled(); + fake.handlers.get('seekto')!({ seekTime: 42 } as MediaSessionActionDetails); + expect(controller.seekToTime).toHaveBeenCalledWith(42); + fake.handlers.get('nexttrack')!({} as MediaSessionActionDetails); + expect(controller.forward).toHaveBeenCalled(); + }); + + test('speak-mark events update metadata and clamped position state headless', async () => { + await bind(); + controller.getPlaybackInfo.mockReturnValue({ position: 90, duration: 60, measuredFraction: 1 }); + controller.emitMark('Hello there, reader.', '0'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.metadata).toBeTruthy(); + expect((fake.metadata as FakeMediaMetadata).artist).toContain('Alice'); + expect(fake.setPositionState).toHaveBeenCalledWith({ + duration: 60, + position: 60, // clamped, never skipped + playbackRate: 1, + }); + }); + + test('state changes surface playing/paused but not transit stopped', async () => { + await bind(); + controller.emitState('paused'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('paused'); + controller.emitState('stopped'); // transit: paragraph advance + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('paused'); // unchanged + controller.emitState('playing'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('playing'); + }); + + test('rebinding the same controller refreshes meta without duplicate listeners', async () => { + await bind(); + await bridge.bind(controller as unknown as TTSController, meta({ bookKey: 'hash-abc-2' })); + controller.emitMark('Once more.', '1'); + await new Promise((r) => setTimeout(r, 0)); + // One metadata update per mark, not two. + expect(fake.setPositionState).toHaveBeenCalledTimes(1); + }); + + test('unbind clears handlers and stops reacting to controller events', async () => { + await bind(); + bridge.unbind(); + expect(fake.handlers.size).toBe(0); + fake.setPositionState.mockClear(); + controller.emitMark('After unbind.', '2'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.setPositionState).not.toHaveBeenCalled(); + expect(bridge.isBound).toBe(false); + }); + + // Android Auto (and the lock screen) drive nexttrack/previoustrack. The + // native onSkipToNext/Previous fire an event into the WebView, where + // forward()/backward() run stop() then advance a paragraph — a ~1s round + // trip. During that window the controller churns (stop -> transient + // paused), which surfaced to the car as a pause flicker / progress-bar + // reset with no track change, i.e. "the forward button does not work". + // The bridge must give instant, coherent feedback: assert playing at once + // and swallow the transient churn until the next segment's mark lands. + test('skip asserts playing immediately (no dead zone before the round trip)', async () => { + await bind(); + expect(fake.playbackState).toBe('none'); + fake.handlers.get('nexttrack')!({} as MediaSessionActionDetails); + expect(controller.forward).toHaveBeenCalled(); + expect(fake.playbackState).toBe('playing'); + }); + + test('skip suppresses the transient stop/pause churn until the next mark', async () => { + await bind(); + controller.emitState('playing'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('playing'); + + // press "forward" in the car + fake.handlers.get('previoustrack')!({} as MediaSessionActionDetails); + expect(controller.backward).toHaveBeenCalled(); + + // backward() internally stops then re-speaks; the transient paused must + // not flicker to the car while the skip is in flight. + controller.emitState('paused'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('playing'); // held, not flickered + + // the new segment starts speaking -> the guard clears, metadata updates + controller.state = 'playing'; + controller.emitMark('The new sentence.', '0'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.metadata).toBeTruthy(); + + // once the skip has landed, real state changes surface again + controller.emitState('paused'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('paused'); + }); + + test('a stray mark mid-skip (aborted segment) keeps the hold, no flicker', async () => { + await bind(); + controller.emitState('playing'); + await new Promise((r) => setTimeout(r, 0)); + + fake.handlers.get('previoustrack')!({} as MediaSessionActionDetails); + expect(fake.playbackState).toBe('playing'); + + // stop() aborts the old segment and emits a stray mark while NOT playing; + // it must not clear the hold or push a paused/position update. + controller.state = 'stopped'; + controller.emitMark('aborted tail', '3'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('playing'); // held + expect(fake.setPositionState).not.toHaveBeenCalled(); // position suppressed + + // the real new segment plays -> hold ends, updates resume + controller.state = 'playing'; + controller.emitMark('the previous sentence', '0'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.metadata).toBeTruthy(); + expect(fake.setPositionState).toHaveBeenCalled(); + }); + + test('a terminal stop during a skip (end of book) still surfaces', async () => { + await bind(); + controller.emitState('playing'); + await new Promise((r) => setTimeout(r, 0)); + fake.handlers.get('nexttrack')!({} as MediaSessionActionDetails); + // forward() ran off the end of the book -> terminate. + controller.terminated = true; + controller.emitState('stopped'); + await new Promise((r) => setTimeout(r, 0)); + expect(fake.playbackState).toBe('paused'); // terminal stop is not swallowed + }); + + test('section label falls back to the last known value when the source dies', async () => { + let label: string | undefined = 'Chapter 7'; + await bridge.bind(controller as unknown as TTSController, { + ...meta({ metadataMode: 'chapter' as const }), + getSectionLabel: () => label, + }); + controller.emitMark('First.', '0'); + await new Promise((r) => setTimeout(r, 0)); + const first = fake.metadata as FakeMediaMetadata; + label = undefined; // hook unmounted + controller.emitMark('Second.', '1'); + await new Promise((r) => setTimeout(r, 0)); + // Metadata still reflects the last known chapter, no crash, no blanking. + expect(first).toBeTruthy(); + expect(bridge.isBound).toBe(true); + }); +}); + +describe('TTSMediaBridge bind teardown race (READEST-1A)', () => { + test('does not crash when unbound while the cover loads', async () => { + // A real TauriMediaSession instance so bind() takes the Tauri branch; its + // native methods are stubbed so nothing hits `invoke`. + const tauriSession = new TauriMediaSession(); + tauriSession.setActive = vi.fn().mockResolvedValue(undefined); + tauriSession.updateMetadata = vi.fn().mockResolvedValue(undefined); + tauriSession.setActionHandler = vi.fn(); + + const bridge = new TTSMediaBridge(() => tauriSession); + const controller = new FakeController(); + + // Tear the session down mid-flight, exactly like a stop during startup: + // #mediaSession becomes null before the awaited setActive/updateMetadata. + vi.mocked(fetchImageAsBase64).mockImplementationOnce(async () => { + bridge.unbind(); + return 'data:image/png;base64,x'; + }); + + await expect( + bridge.bind(controller as unknown as TTSController, meta({ coverImageUrl: 'cover.png' })), + ).resolves.toBeUndefined(); + // The bind aborted after teardown; no handlers were wired on a dead session. + expect(bridge.isBound).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-pcm.test.ts b/apps/readest-app/src/__tests__/services/tts-pcm.test.ts new file mode 100644 index 000000000..26dd27d9f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-pcm.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'vitest'; + +import { applyEdgeFade, findSpeechBounds } from '@/services/tts/pcm'; + +const SR = 24000; + +const makeSignal = ( + leadingSilenceSec: number, + speechSec: number, + trailingSilenceSec: number, + noiseFloor = 0, +) => { + const total = Math.round((leadingSilenceSec + speechSec + trailingSilenceSec) * SR); + const samples = new Float32Array(total); + const speechStart = Math.round(leadingSilenceSec * SR); + const speechEnd = speechStart + Math.round(speechSec * SR); + for (let i = 0; i < total; i++) { + if (i >= speechStart && i < speechEnd) { + samples[i] = 0.3 * Math.sin((2 * Math.PI * 440 * i) / SR); + } else if (noiseFloor > 0) { + // Deterministic pseudo-noise below the detection threshold, emulating + // MP3 decoder dither/ringing in "silent" passages. + samples[i] = noiseFloor * Math.sin((2 * Math.PI * 1731 * i) / SR + i * 0.7); + } + } + return samples; +}; + +describe('findSpeechBounds', () => { + test('trims leading and trailing silence with head/tail pads', () => { + const samples = makeSignal(0.5, 1.0, 0.8); + const { startSec, endSec } = findSpeechBounds(samples, SR); + expect(startSec).toBeGreaterThan(0.47 - 1e-6); + expect(startSec).toBeLessThan(0.53); + expect(endSec).toBeGreaterThan(1.44); + expect(endSec).toBeLessThan(1.56 + 1e-6); + expect(endSec).toBeGreaterThan(startSec); + }); + + test('ignores a realistic decoder noise floor in silent passages', () => { + const samples = makeSignal(0.5, 1.0, 0.8, 0.0008); + const { startSec, endSec } = findSpeechBounds(samples, SR); + expect(startSec).toBeGreaterThan(0.4); + expect(startSec).toBeLessThan(0.53); + expect(endSec).toBeGreaterThan(1.44); + expect(endSec).toBeLessThan(1.6); + }); + + test('all-silence input returns the full range', () => { + const samples = new Float32Array(SR); // 1s of zeros + const { startSec, endSec } = findSpeechBounds(samples, SR); + expect(startSec).toBe(0); + expect(endSec).toBeCloseTo(1, 5); + }); + + test('empty input returns zero bounds', () => { + const { startSec, endSec } = findSpeechBounds(new Float32Array(0), SR); + expect(startSec).toBe(0); + expect(endSec).toBe(0); + }); + + test('speech reaching the buffer edges clamps to the buffer', () => { + const samples = makeSignal(0, 0.5, 0); + const { startSec, endSec } = findSpeechBounds(samples, SR); + expect(startSec).toBe(0); + expect(endSec).toBeCloseTo(0.5, 2); + }); +}); + +describe('applyEdgeFade', () => { + test('ramps the first and last samples to zero, leaving the interior intact', () => { + const sr = 48000; + const fadeSec = 0.003; + const n = Math.floor(fadeSec * sr); + const samples = new Float32Array(sr).fill(1); // 1s of DC, edges are the clicks + applyEdgeFade(samples, sr, fadeSec); + + // Both edges land exactly on zero, so there is no step from/to silence. + expect(samples[0]).toBe(0); + expect(samples[samples.length - 1]).toBe(0); + // Everything past the fade window is untouched. + expect(samples[n]).toBe(1); + expect(samples[samples.length - 1 - n]).toBe(1); + expect(samples[samples.length >> 1]).toBe(1); + // The fade-in is a monotonic ramp. + for (let i = 1; i < n; i++) { + expect(samples[i]!).toBeGreaterThan(samples[i - 1]!); + } + }); + + test('clamps the ramp so the two ends never overlap on a short buffer', () => { + const samples = new Float32Array([1, 1, 1, 1]); // fade window would exceed length + applyEdgeFade(samples, 48000, 0.003); + expect(samples[0]).toBe(0); + expect(samples[3]).toBe(0); + // No sample double-scaled or amplified. + for (const v of samples) expect(Math.abs(v)).toBeLessThanOrEqual(1); + }); + + test('is a no-op on buffers too small to fade', () => { + expect(() => applyEdgeFade(new Float32Array(0), 48000)).not.toThrow(); + const one = new Float32Array([0.5]); + applyEdgeFade(one, 48000); + expect(one[0]).toBe(0.5); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-section-timeline.test.ts b/apps/readest-app/src/__tests__/services/tts-section-timeline.test.ts new file mode 100644 index 000000000..20bc509de --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-section-timeline.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'vitest'; +import { textWalker } from 'foliate-js/text-walker.js'; +import { getSentences } from 'foliate-js/tts.js'; + +import { SectionTimeline, type TimelineSentence } from '@/services/tts/SectionTimeline'; +import { recordMeasuredDuration } from '@/services/tts/ttsDuration'; + +// Note: foliate's segmenter merges sentences ending in short (<=3 letter) +// words as suspected abbreviations, so every fixture sentence ends long. +const SENTENCE_A0 = 'The quick brown fox jumps over the lazy hound.'; +const SENTENCE_A1 = 'A second sentence follows the first one closely.'; +const SENTENCE_B0 = 'Another paragraph starts a new block of text.'; +const SENTENCE_B1 = 'It also carries a couple of sentences inside.'; + +const makeDoc = (): Document => { + const parser = new DOMParser(); + return parser.parseFromString( + `` + + `

${SENTENCE_A0} ${SENTENCE_A1}

` + + `

${SENTENCE_B0} ${SENTENCE_B1}

` + + ``, + 'text/html', + ); +}; + +const enumerate = (doc: Document): TimelineSentence[] => { + const sentences: TimelineSentence[] = []; + for (const { blockIndex, markName, range } of getSentences(doc, textWalker, null, 'sentence')) { + sentences.push({ blockIndex, markName, range, text: range.toString() }); + } + return sentences; +}; + +describe('SectionTimeline', () => { + test('enumerates sentences via foliate getSentences with block-scoped marks', () => { + const sentences = enumerate(makeDoc()); + expect(sentences).toHaveLength(4); + expect(sentences[0]!.blockIndex).toBe(0); + expect(sentences[2]!.blockIndex).toBe(1); + // Mark names restart per block, matching foliate's per-block mark naming. + expect(sentences[0]!.markName).toBe(sentences[2]!.markName); + expect(sentences[0]!.text.trim()).toBe(SENTENCE_A0); + expect(sentences[3]!.text.trim()).toBe(SENTENCE_B1); + }); + + test('mixes measured and estimated durations after refresh', () => { + const voice = 'timeline-voice-mixed'; + const sentences = enumerate(makeDoc()); + const timeline = new SectionTimeline(sentences, 'en', voice); + const estimatedTotal = timeline.getDuration(); + expect(estimatedTotal).toBeGreaterThan(0); + + recordMeasuredDuration(voice, SENTENCE_A0, 10); + timeline.refresh(); + const mixedTotal = timeline.getDuration(); + // Sentence A0's estimate (~3s at 15cps) was replaced by the 10s measured value. + expect(mixedTotal).toBeGreaterThan(estimatedTotal + 5); + expect(timeline.getMeasuredFraction()).toBeGreaterThan(0); + expect(timeline.getMeasuredFraction()).toBeLessThan(1); + }); + + test('positionAt sums prior durations plus the within-sentence offset', () => { + const voice = 'timeline-voice-position'; + const sentences = enumerate(makeDoc()); + recordMeasuredDuration(voice, SENTENCE_A0, 4); + recordMeasuredDuration(voice, SENTENCE_A1, 6); + const timeline = new SectionTimeline(sentences, 'en', voice); + expect(timeline.positionAt(0, 1.5)).toBeCloseTo(1.5, 5); + expect(timeline.positionAt(1, 2)).toBeCloseTo(4 + 2, 5); + // Clamps out-of-range indexes. + expect(timeline.positionAt(-1, 0)).toBe(0); + expect(timeline.positionAt(99, 0)).toBeCloseTo(timeline.getDuration(), 5); + }); + + test('rate rescales duration and position without touching stored data', () => { + const voice = 'timeline-voice-rate'; + const sentences = enumerate(makeDoc()); + recordMeasuredDuration(voice, SENTENCE_A0, 4); + const timeline = new SectionTimeline(sentences, 'en', voice); + const at1 = timeline.getDuration(); + timeline.setRate(2); + expect(timeline.getDuration()).toBeCloseTo(at1 / 2, 5); + expect(timeline.positionAt(1, 0)).toBeCloseTo(4 / 2, 5); + timeline.setRate(0.5); + expect(timeline.getDuration()).toBeCloseTo(at1 * 2, 5); + }); + + test('sentenceAtTime maps seconds to the sentence, clamping past the end', () => { + const voice = 'timeline-voice-seek'; + const sentences = enumerate(makeDoc()); + for (const [text, dur] of [ + [SENTENCE_A0, 4], + [SENTENCE_A1, 6], + [SENTENCE_B0, 5], + [SENTENCE_B1, 5], + ] as const) { + recordMeasuredDuration(voice, text, dur); + } + const timeline = new SectionTimeline(sentences, 'en', voice); + expect(timeline.sentenceAtTime(0)?.index).toBe(0); + expect(timeline.sentenceAtTime(4.5)?.index).toBe(1); + expect(timeline.sentenceAtTime(11)?.index).toBe(2); + // Past the (possibly over-estimated) end: clamp to the last sentence so a + // lock-screen scrub past the real end is not a dead gesture. + expect(timeline.sentenceAtTime(999)?.index).toBe(3); + expect(timeline.sentenceAtTime(-1)?.index).toBe(0); + // Rate applies: at 2x, 5.5 real seconds is 11 media seconds = sentence 2. + timeline.setRate(2); + expect(timeline.sentenceAtTime(5.5)?.index).toBe(2); + }); + + test('sentenceAtTime returns null for an empty timeline', () => { + const timeline = new SectionTimeline([], 'en', 'timeline-voice-empty'); + expect(timeline.sentenceAtTime(0)).toBeNull(); + expect(timeline.getDuration()).toBe(0); + }); + + test('indexOfRange finds exact ranges, sub-ranges, and rejects foreign docs', () => { + const doc = makeDoc(); + const sentences = enumerate(doc); + const timeline = new SectionTimeline(sentences, 'en', 'timeline-voice-range'); + expect(timeline.indexOfRange(sentences[2]!.range)).toBe(2); + + // A collapsed range inside sentence 3 still resolves to sentence 3. + const sub = sentences[3]!.range.cloneRange(); + sub.collapse(false); + expect(timeline.indexOfRange(sub)).toBe(3); + + const foreign = makeDoc().createRange(); + expect(timeline.indexOfRange(foreign)).toBe(-1); + }); + + test('setVoice re-estimates unmeasured entries under the new voice', () => { + const voiceA = 'timeline-voice-a'; + const voiceB = 'timeline-voice-b'; + const sentences = enumerate(makeDoc()); + recordMeasuredDuration(voiceA, SENTENCE_A0, 20); + const timeline = new SectionTimeline(sentences, 'en', voiceA); + const withMeasured = timeline.getDuration(); + timeline.setVoice(voiceB); + // Voice B has no measured data: everything estimates again. + expect(timeline.getDuration()).toBeLessThan(withMeasured); + expect(timeline.getMeasuredFraction()).toBe(0); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-session-manager.test.ts b/apps/readest-app/src/__tests__/services/tts-session-manager.test.ts new file mode 100644 index 000000000..ff1feb66f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-session-manager.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const bridgeBind = vi.fn().mockResolvedValue(undefined); +const bridgeUnbind = vi.fn(); +const releaseKeepAlive = vi.fn(); + +vi.mock('@/services/tts/ttsMediaBridge', () => ({ + ttsMediaBridge: { + bind: (...args: unknown[]) => bridgeBind(...args), + unbind: () => bridgeUnbind(), + }, + releaseUnblockAudio: () => releaseKeepAlive(), + unblockAudio: vi.fn(), +})); + +const setConfig = vi.fn(); +const saveConfig = vi.fn().mockResolvedValue(undefined); +const getConfig = vi.fn().mockReturnValue({ viewSettings: { ttsLocation: '' } }); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: { + getState: () => ({ setConfig, saveConfig, getConfig }), + }, +})); +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: { getState: () => ({ settings: { fake: true } }) }, +})); +vi.mock('@/services/environment', () => ({ default: { env: 'test' } })); +vi.mock('@/utils/bridge', () => ({ + invokeUseBackgroundAudio: vi.fn().mockResolvedValue(undefined), +})); + +import { TTSSessionManager, getBookHashFromKey } from '@/services/tts/TTSSessionManager'; +import type { TTSController } from '@/services/tts/TTSController'; +import { eventDispatcher } from '@/utils/event'; + +class FakeController extends EventTarget { + state = 'playing'; + terminated = false; + isViewAttached = true; + shutdown = vi.fn().mockResolvedValue(undefined); + detachView = vi.fn().mockImplementation(() => { + this.isViewAttached = false; + }); + + emitState(state: string) { + this.state = state; + this.dispatchEvent(new CustomEvent('tts-state-change', { detail: { state } })); + } + emitEnded(reason: string) { + this.terminated = true; + this.dispatchEvent(new CustomEvent('tts-session-ended', { detail: { reason } })); + } + emitMark(cfi: string) { + this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: { cfi } })); + } +} + +const meta = (bookKey: string) => ({ + bookKey, + title: 'Alice', + author: 'Carroll', + coverImageUrl: null, + metadataMode: 'sentence' as const, +}); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('getBookHashFromKey', () => { + test('extracts the hash prefix from an ephemeral bookKey', () => { + expect(getBookHashFromKey('c9f7c5aa-1a2b3c')).toBe('c9f7c5aa'); + }); +}); + +describe('TTSSessionManager', () => { + let manager: TTSSessionManager; + let controller: FakeController; + let playbackStates: Array<{ bookKey: string; state: string }>; + let sessionEvents: Array<{ reason: string }>; + const playbackListener = (e: CustomEvent) => { + playbackStates.push(e.detail as { bookKey: string; state: string }); + }; + + const claim = (bookKey = 'hashA-r1', ctrl = controller) => + manager.claim(bookKey, ctrl as unknown as TTSController, meta(bookKey)); + + beforeEach(() => { + vi.clearAllMocks(); + manager = new TTSSessionManager(); + controller = new FakeController(); + playbackStates = []; + sessionEvents = []; + eventDispatcher.on('tts-playback-state', playbackListener as never); + manager.addEventListener('session-changed', (e) => { + sessionEvents.push({ reason: (e as CustomEvent).detail.reason }); + }); + }); + + afterEach(() => { + eventDispatcher.off('tts-playback-state', playbackListener as never); + manager.setSleepTimer(0); + }); + + test('claim registers a hash-keyed session and binds the bridge', () => { + claim(); + expect(manager.getSessionByHash('hashA')?.bookKey).toBe('hashA-r1'); + expect(manager.getActiveSession()?.bookHash).toBe('hashA'); + expect(bridgeBind).toHaveBeenCalled(); + expect(sessionEvents.at(-1)?.reason).toBe('claimed'); + }); + + test('claim for the same hash replaces the controller without stopping the slot', async () => { + claim(); + const second = new FakeController(); + claim('hashA-r2', second); + await flush(); + // Old controller unsubscribed and shut down by the manager; no bar-level stop. + expect(controller.shutdown).toHaveBeenCalled(); + expect(manager.getActiveSession()?.controller).toBe(second as unknown as TTSController); + // Old controller events no longer relay. + playbackStates.length = 0; + controller.emitState('playing'); + await flush(); + expect(playbackStates).toHaveLength(0); + }); + + test('claim for a DIFFERENT hash stops the prior session first', async () => { + claim(); + const other = new FakeController(); + claim('hashB-r1', other); + await flush(); + expect(controller.shutdown).toHaveBeenCalled(); + expect(bridgeUnbind).toHaveBeenCalled(); + expect(manager.getActiveSession()?.bookHash).toBe('hashB'); + expect(manager.getSessionByHash('hashA')).toBeNull(); + }); + + test('detach keeps the session retrievable and detaches the view', () => { + claim(); + manager.detach('hashA'); + expect(controller.detachView).toHaveBeenCalled(); + expect(manager.getSessionByHash('hashA')).not.toBeNull(); + expect(sessionEvents.at(-1)?.reason).toBe('detached'); + }); + + test('state relay: playing/paused re-emit, transit stopped is swallowed', async () => { + claim(); + controller.emitState('paused'); + controller.emitState('stopped'); // paragraph advance transit + controller.emitState('playing'); + await flush(); + const states = playbackStates.map((s) => s.state); + expect(states).toEqual(['paused', 'playing']); + }); + + test('tts-session-ended (not state) stops the session with its reason', async () => { + claim(); + manager.detach('hashA'); + controller.emitEnded('ended'); + await flush(); + expect(manager.getActiveSession()).toBeNull(); + expect(bridgeUnbind).toHaveBeenCalled(); + expect(releaseKeepAlive).toHaveBeenCalled(); + expect(playbackStates.at(-1)?.state).toBe('stopped'); + expect(sessionEvents.at(-1)?.reason).toBe('stopped'); + }); + + test('sleep timer fires stopActive and is cleared by it', async () => { + vi.useFakeTimers(); + claim(); + manager.setSleepTimer(60); + expect(manager.getSleepTimer()?.timeoutSec).toBe(60); + vi.advanceTimersByTime(61_000); + vi.useRealTimers(); + await flush(); + expect(manager.getActiveSession()).toBeNull(); + expect(manager.getSleepTimer()).toBeNull(); + }); + + test('headless persistence writes through setConfig and flushes to disk on stop', async () => { + vi.useFakeTimers(); + claim(); + manager.detach('hashA'); + controller.emitMark('epubcfi(/6/8!/4/2)'); + expect(setConfig).toHaveBeenCalledWith( + 'hashA-r1', + expect.objectContaining({ + viewSettings: expect.objectContaining({ ttsLocation: 'epubcfi(/6/8!/4/2)' }), + }), + ); + vi.useRealTimers(); + await manager.stopActive('user'); + expect(saveConfig).toHaveBeenCalled(); + }); + + test('persistence is a no-op while the view is attached (the hook owns it)', () => { + claim(); + controller.emitMark('epubcfi(/6/8!/4/4)'); + expect(setConfig).not.toHaveBeenCalled(); + }); + + test('release clears the slot without shutting the controller down', () => { + claim(); + manager.release('hashA'); + expect(controller.shutdown).not.toHaveBeenCalled(); + expect(manager.getActiveSession()).toBeNull(); + expect(sessionEvents.at(-1)?.reason).toBe('released'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-time-stretch.test.ts b/apps/readest-app/src/__tests__/services/tts-time-stretch.test.ts new file mode 100644 index 000000000..0f28b1641 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-time-stretch.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'vitest'; + +import { timeStretch } from '@/services/tts/timeStretch'; + +const SR = 24000; + +const makeSpeechLike = (seconds: number) => { + const n = Math.round(seconds * SR); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) { + const t = i / SR; + // Multi-sine with slow amplitude modulation, roughly speech-shaped. + const env = 0.5 + 0.5 * Math.sin(2 * Math.PI * 3 * t); + out[i] = + env * + (0.3 * Math.sin(2 * Math.PI * 220 * t) + + 0.2 * Math.sin(2 * Math.PI * 470 * t) + + 0.1 * Math.sin(2 * Math.PI * 910 * t)); + } + return out; +}; + +const makeSine = (seconds: number, freq: number) => { + const n = Math.round(seconds * SR); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) { + out[i] = 0.5 * Math.sin((2 * Math.PI * freq * i) / SR); + } + return out; +}; + +const zeroCrossingsPerSec = (samples: Float32Array) => { + let crossings = 0; + for (let i = 1; i < samples.length; i++) { + if (samples[i - 1]! <= 0 !== samples[i]! <= 0) crossings++; + } + return crossings / (samples.length / SR); +}; + +describe('timeStretch', () => { + test('tempo 1.5 shortens output to ~1/1.5 of input length', () => { + const input = makeSpeechLike(3); + const output = timeStretch(input, SR, 1.5); + const ratio = output.length / input.length; + expect(ratio).toBeGreaterThan((1 / 1.5) * 0.95); + expect(ratio).toBeLessThan((1 / 1.5) * 1.05); + }); + + test('tempo 0.75 lengthens output to ~1/0.75 of input length', () => { + const input = makeSpeechLike(3); + const output = timeStretch(input, SR, 0.75); + const ratio = output.length / input.length; + expect(ratio).toBeGreaterThan((1 / 0.75) * 0.95); + expect(ratio).toBeLessThan((1 / 0.75) * 1.05); + }); + + test('preserves pitch: zero-crossing rate unchanged at tempo 1.5', () => { + const input = makeSine(1, 440); + const output = timeStretch(input, SR, 1.5); + const inputZps = zeroCrossingsPerSec(input); // ~880 + const outputZps = zeroCrossingsPerSec(output); + // A resampler would land near 1320; WSOLA must stay near the input rate. + expect(outputZps).toBeGreaterThan(inputZps * 0.97); + expect(outputZps).toBeLessThan(inputZps * 1.03); + }); + + test('tempo 1 returns an equal-length copy, not the same reference', () => { + const input = makeSpeechLike(1); + const output = timeStretch(input, SR, 1); + expect(output).not.toBe(input); + expect(output.length).toBe(input.length); + expect(output[1234]).toBe(input[1234]); + }); + + test('all-zero input produces finite output (zero-energy correlation guard)', () => { + const input = new Float32Array(SR); // 1s of digital silence + const output = timeStretch(input, SR, 1.5); + expect(output.length).toBeGreaterThan(0); + expect(output.every((s) => Number.isFinite(s))).toBe(true); + }); + + test('slow tempo 0.2 produces ~5x length with finite samples', () => { + const input = makeSpeechLike(1); + const output = timeStretch(input, SR, 0.2); + const ratio = output.length / input.length; + expect(ratio).toBeGreaterThan(5 * 0.95); + expect(ratio).toBeLessThan(5 * 1.05); + expect(output.every((s) => Number.isFinite(s))).toBe(true); + }); + + test('very short input is returned as a copy (below two frames)', () => { + const input = makeSine(0.05, 440); // 50ms < 2 x 40ms frames + const output = timeStretch(input, SR, 2); + expect(output.length).toBe(input.length); + }); + + test('does not mutate its input (input may be a subarray view)', () => { + const input = makeSpeechLike(1); + const snapshot = input.slice(); + timeStretch(input, SR, 1.5); + expect(input).toEqual(snapshot); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts-web-audio-player.test.ts b/apps/readest-app/src/__tests__/services/tts-web-audio-player.test.ts new file mode 100644 index 000000000..a278feebb --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts-web-audio-player.test.ts @@ -0,0 +1,312 @@ +import { afterEach, describe, expect, test } from 'vitest'; + +import { WebAudioPlayer, type WebAudioPlayerEvent } from '@/services/tts/WebAudioPlayer'; +import { FakeAudioContext, makeBuffer } from './tts-fake-audio'; + +const SAFETY = 0.03; + +const setVisibility = (value: 'visible' | 'hidden') => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => value, + }); +}; + +afterEach(() => { + setVisibility('visible'); +}); + +const setup = () => { + const ctx = new FakeAudioContext(); + const player = new WebAudioPlayer(() => ctx); + const events: WebAudioPlayerEvent[] = []; + return { ctx, player, events, onEvent: (e: WebAudioPlayerEvent) => events.push(e) }; +}; + +describe('WebAudioPlayer scheduling', () => { + test('chunks are scheduled contiguously with the requested gap', async () => { + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0.5 }); + player.scheduleChunk(gen, makeBuffer(3), { trimStartSec: 0, mediaScale: 1, gapSec: 0.5 }); + expect(ctx.sources[0]!.startedAt).toBeCloseTo(SAFETY, 5); + expect(ctx.sources[1]!.startedAt).toBeCloseTo(SAFETY + 2 + 0.5, 5); + }); + + test('chunk-start fires at schedule for index 0 and on prior onended after', async () => { + const { ctx, player, events, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0.2 }); + expect(events).toEqual([{ type: 'chunk-start', chunkIndex: 0 }]); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0.2 }); + expect(events).toHaveLength(1); + await ctx.advanceTo(SAFETY + 2); + expect(events).toEqual([ + { type: 'chunk-start', chunkIndex: 0 }, + { type: 'chunk-start', chunkIndex: 1 }, + ]); + }); + + test('stale-generation scheduleChunk is a no-op', async () => { + const { ctx, player, events, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.startSession(() => {}); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + expect(ctx.sources).toHaveLength(0); + expect(events).toHaveLength(0); + }); +}); + +describe('WebAudioPlayer backpressure', () => { + test('third chunk waits until the first finishes while visible', async () => { + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + let resolved: boolean | null = null; + const wait = player.waitUntilReady(gen).then((r) => { + resolved = r; + return r; + }); + await Promise.resolve(); + expect(resolved).toBeNull(); + await ctx.advanceTo(SAFETY + 2); + expect(await wait).toBe(true); + }); + + test('hidden visibility deepens the pending-chunk budget to 5', async () => { + setVisibility('hidden'); + const { player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + for (let i = 0; i < 4; i++) { + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + } + expect(await player.waitUntilReady(gen)).toBe(true); + }); + + test('seconds cap blocks scheduling far ahead even under the chunk budget', async () => { + setVisibility('hidden'); + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(30), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + player.scheduleChunk(gen, makeBuffer(31), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + let resolved: boolean | null = null; + const wait = player.waitUntilReady(gen).then((r) => { + resolved = r; + return r; + }); + await Promise.resolve(); + expect(resolved).toBeNull(); // 61s ahead > 60s cap, though only 2 < 5 chunks + await ctx.advanceTo(SAFETY + 30); + expect(await wait).toBe(true); + }); + + test('waitUntilReady returns false for a stale generation', async () => { + const { player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.startSession(() => {}); + expect(await player.waitUntilReady(gen)).toBe(false); + }); +}); + +describe('WebAudioPlayer session end', () => { + test('session-end fires after endSession + last onended', async () => { + const { ctx, player, events, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + player.endSession(gen); + expect(events.filter((e) => e.type === 'session-end')).toHaveLength(0); + await ctx.advanceTo(SAFETY + 1); + expect(events.filter((e) => e.type === 'session-end')).toHaveLength(1); + }); + + test('endSession with zero scheduled chunks fires session-end synchronously', async () => { + const { player, events, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.endSession(gen); + expect(events).toEqual([{ type: 'session-end' }]); + }); + + test('endSession after the last onended already fired still ends the session', async () => { + const { ctx, player, events, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + await ctx.advanceTo(SAFETY + 1); + expect(events.filter((e) => e.type === 'session-end')).toHaveLength(0); + player.endSession(gen); + expect(events.filter((e) => e.type === 'session-end')).toHaveLength(1); + }); + + test('session-end is emitted exactly once', async () => { + const { ctx, player, events, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + player.endSession(gen); + player.endSession(gen); + await ctx.advanceTo(SAFETY + 1); + await ctx.advanceTo(SAFETY + 2); + expect(events.filter((e) => e.type === 'session-end')).toHaveLength(1); + }); +}); + +describe('WebAudioPlayer abort', () => { + test('abort stops pending sources, resolves waiters false, silences events', async () => { + const { ctx, player, events, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + for (let i = 0; i < 3; i++) { + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + } + const wait = player.waitUntilReady(gen); + player.abortSession(); + expect(await wait).toBe(false); + expect(ctx.sources.every((s) => s.stopped)).toBe(true); + const countBefore = events.length; + await ctx.advanceTo(100); + expect(events).toHaveLength(countBefore); + }); + + test('double abortSession is idempotent', async () => { + const { player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + player.abortSession(); + expect(() => player.abortSession()).not.toThrow(); + }); + + test('abort storm: 10 rapid startSession calls leave one live session, no stale events', async () => { + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gens: number[] = []; + const staleEvents: WebAudioPlayerEvent[] = []; + for (let i = 0; i < 10; i++) { + const gen = player.startSession(i === 9 ? onEvent : (e) => staleEvents.push(e)); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + gens.push(gen); + } + for (const gen of gens.slice(0, 9)) { + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + expect(await player.waitUntilReady(gen)).toBe(false); + } + // Stale sessions emitted only their own synchronous chunk-start 0 before + // being aborted; nothing new after the storm. + const staleCount = staleEvents.length; + await ctx.advanceTo(50); + expect(staleEvents).toHaveLength(staleCount); + expect(await player.waitUntilReady(gens[9]!)).toBe(true); + }); +}); + +describe('WebAudioPlayer pause/resume and interruption', () => { + test('pauseContext suspends; resumeContext resumes', async () => { + const { ctx, player } = setup(); + await player.ensureContext(); + player.startSession(() => {}); + await player.pauseContext(); + expect(ctx.state).toBe('suspended'); + await player.resumeContext(); + expect(ctx.state).toBe('running'); + }); + + test('auto-resumes on unexpected suspension while a session is live', async () => { + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + const before = ctx.resumeCalls; + ctx.setState('interrupted'); + await Promise.resolve(); + expect(ctx.resumeCalls).toBeGreaterThan(before); + }); + + test('user pause suppresses auto-resume', async () => { + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 }); + await player.pauseContext(); + const before = ctx.resumeCalls; + ctx.setState('interrupted'); + await Promise.resolve(); + expect(ctx.resumeCalls).toBe(before); + }); + + test('resumeContext throws when the context refuses to run again', async () => { + const { ctx, player } = setup(); + await player.ensureContext(); + await player.pauseContext(); + ctx.resumeImpl = () => { + ctx.state = 'interrupted'; + }; + await expect(player.resumeContext()).rejects.toThrow(); + }); +}); + +describe('WebAudioPlayer playback position', () => { + test('maps playback time through trim offset and media scale', async () => { + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + // 2s output chunk covering original media [0.3, 3.3) stretched 1.5x. + player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0.3, mediaScale: 1.5, gapSec: 0.2 }); + await ctx.advanceTo(SAFETY + 1); + const pos = player.getPlaybackPosition(gen); + expect(pos?.chunkIndex).toBe(0); + expect(pos?.mediaTimeSec).toBeCloseTo(0.3 + 1 * 1.5, 3); + }); + + test('clamps before the first chunk and in the inter-chunk gap', async () => { + const { ctx, player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0.2, mediaScale: 1, gapSec: 1 }); + player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0.1, mediaScale: 1, gapSec: 1 }); + // Before the first chunk starts. + expect(player.getPlaybackPosition(gen)?.mediaTimeSec).toBeCloseTo(0.2, 5); + // Inside the gap after chunk 0 (ends at SAFETY+1; next starts SAFETY+2). + ctx.currentTime = SAFETY + 1.5; + const pos = player.getPlaybackPosition(gen); + expect(pos?.chunkIndex).toBe(0); + expect(pos?.mediaTimeSec).toBeCloseTo(0.2 + 1, 5); + }); + + test('returns null for a stale generation or empty session', async () => { + const { player, onEvent } = setup(); + await player.ensureContext(); + const gen = player.startSession(onEvent); + expect(player.getPlaybackPosition(gen)).toBeNull(); + player.startSession(() => {}); + expect(player.getPlaybackPosition(gen)).toBeNull(); + }); +}); + +describe('WebAudioPlayer buffers', () => { + test('createMonoBuffer preserves samples and sample rate', async () => { + const { player } = setup(); + const samples = new Float32Array([0.1, -0.2, 0.3, -0.4]); + const buffer = await player.createMonoBuffer(samples, 48000); + expect(buffer.sampleRate).toBe(48000); + expect(buffer.length).toBe(4); + expect(Array.from(buffer.getChannelData(0))).toEqual(Array.from(samples)); + }); + + test('decode resolves through the context decoder at its sample rate', async () => { + const ctx = new FakeAudioContext(48000); + const player = new WebAudioPlayer(() => ctx); + const buffer = await player.decode(new ArrayBuffer(96000)); + expect(buffer.sampleRate).toBe(48000); + expect(buffer.duration).toBeCloseTo(2, 5); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/tts/wordPronouncer.test.ts b/apps/readest-app/src/__tests__/services/tts/wordPronouncer.test.ts new file mode 100644 index 000000000..5ccb19ea9 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/tts/wordPronouncer.test.ts @@ -0,0 +1,184 @@ +/** + * wordPronouncer — pronounces a single dictionary word. + * + * The pronouncer is deliberately independent of the reader's TTSController: it + * synthesizes via EdgeSpeechTTS directly (no throwaway init synth), plays on a + * dedicated Web Audio context, and drops to the platform speech client + * (Web Speech on desktop/web, native on the mobile app) when Edge is + * unavailable. These tests pin that Edge-first / fallback-on-failure contract + * and the language -> Edge voice selection. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { TTSUtils } from '@/services/tts/TTSUtils'; + +const h = vi.hoisted(() => { + const createAudioData = vi.fn(); + let sessionOnEvent: ((e: { type: string; message?: string }) => void) | null = null; + const player = { + ensureContext: vi.fn().mockResolvedValue({}), + decode: vi.fn().mockResolvedValue({ duration: 0.5 }), + startSession: vi.fn((onEvent: (e: { type: string; message?: string }) => void) => { + sessionOnEvent = onEvent; + return 1; + }), + scheduleChunk: vi.fn(), + endSession: vi.fn(), + abortSession: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + fireSessionEnd: () => sessionOnEvent?.({ type: 'session-end' }), + fireContextError: () => sessionOnEvent?.({ type: 'context-error', message: 'boom' }), + }; + const makeClient = (speak: unknown) => ({ + init: vi.fn().mockResolvedValue(true), + setPrimaryLang: vi.fn(), + speak, + shutdown: vi.fn().mockResolvedValue(undefined), + }); + // eslint-disable-next-line require-yield + const webSpeak = vi.fn(async function* (_ssml: string, _signal: AbortSignal) {}); + // eslint-disable-next-line require-yield + const nativeSpeak = vi.fn(async function* (_ssml: string, _signal: AbortSignal) {}); + return { + createAudioData, + player, + webClient: makeClient(webSpeak), + nativeClient: makeClient(nativeSpeak), + webSpeak, + nativeSpeak, + }; +}); + +vi.mock('@/libs/edgeTTS', () => { + class EdgeSpeechTTS { + static voices = [ + { name: 'Aria', id: 'en-US-AriaNeural', lang: 'en-US' }, + { name: 'Ryan', id: 'en-GB-RyanNeural', lang: 'en-GB' }, + { name: 'Denise', id: 'fr-FR-DeniseNeural', lang: 'fr-FR' }, + ]; + createAudioData = h.createAudioData; + } + return { EdgeSpeechTTS }; +}); + +vi.mock('@/services/tts/WebAudioPlayer', () => ({ + WebAudioPlayer: class { + constructor() { + Object.assign(this, h.player); + } + }, +})); + +vi.mock('@/services/tts/WebSpeechClient', () => ({ + WebSpeechClient: class { + constructor() { + Object.assign(this, h.webClient); + } + }, +})); + +vi.mock('@/services/tts/NativeTTSClient', () => ({ + NativeTTSClient: class { + constructor() { + Object.assign(this, h.nativeClient); + } + }, +})); + +import { pronounceWord, pickEdgeVoiceId, cancelWordPronounce } from '@/services/tts/wordPronouncer'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +beforeEach(() => { + vi.clearAllMocks(); + (globalThis as unknown as { AudioContext: unknown }).AudioContext = vi.fn(); + h.createAudioData.mockReset(); +}); + +describe('pickEdgeVoiceId', () => { + it('returns the first Edge voice whose locale matches the language', () => { + expect(pickEdgeVoiceId('en')).toBe('en-US-AriaNeural'); + expect(pickEdgeVoiceId('fr')).toBe('fr-FR-DeniseNeural'); + }); + + it('falls back to the default English voice for an unknown language', () => { + expect(pickEdgeVoiceId('xx')).toBe('en-US-AriaNeural'); + }); + + it("respects the user's preferred Edge voice for the language when valid", () => { + const spy = vi.spyOn(TTSUtils, 'getPreferredVoice').mockReturnValue('en-GB-RyanNeural'); + expect(pickEdgeVoiceId('en')).toBe('en-GB-RyanNeural'); + spy.mockRestore(); + }); +}); + +describe('pronounceWord — Edge path', () => { + it('synthesizes with Edge and schedules playback without touching the fallback', async () => { + h.createAudioData.mockResolvedValue({ data: new ArrayBuffer(8), boundaries: [] }); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', {}, onStatus); + + expect(h.createAudioData).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello', lang: 'en', voice: 'en-US-AriaNeural' }), + ); + expect(h.player.scheduleChunk).toHaveBeenCalledTimes(1); + expect(h.webSpeak).not.toHaveBeenCalled(); + expect(h.nativeSpeak).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenLastCalledWith('playing'); + + h.player.fireSessionEnd(); + expect(onStatus).toHaveBeenLastCalledWith('ended'); + }); + + it('reports an error when the audio context surfaces one mid-playback', async () => { + h.createAudioData.mockResolvedValue({ data: new ArrayBuffer(8), boundaries: [] }); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', {}, onStatus); + h.player.fireContextError(); + + expect(onStatus).toHaveBeenLastCalledWith('error'); + }); +}); + +describe('pronounceWord — fallback path', () => { + it('drops to Web Speech on desktop/web when Edge fails', async () => { + h.createAudioData.mockRejectedValue(new Error('wss blocked')); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', {}, onStatus); + await flush(); + + expect(h.webSpeak).toHaveBeenCalledTimes(1); + const ssml = h.webSpeak.mock.calls[0]![0]; + expect(ssml).toContain('hello'); + expect(h.nativeSpeak).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenLastCalledWith('ended'); + }); + + it('uses the native client on the mobile app when Edge fails', async () => { + h.createAudioData.mockRejectedValue(new Error('wss blocked')); + const onStatus = vi.fn(); + + await pronounceWord('hello', 'en', { appService: { isMobile: true } as never }, onStatus); + await flush(); + + expect(h.nativeSpeak).toHaveBeenCalledTimes(1); + expect(h.webSpeak).not.toHaveBeenCalled(); + }); +}); + +describe('pronounceWord — guards', () => { + it('does nothing for a blank word', async () => { + const onStatus = vi.fn(); + await pronounceWord(' ', 'en', {}, onStatus); + expect(h.createAudioData).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenLastCalledWith('ended'); + }); + + it('aborts the active session on cancel', async () => { + cancelWordPronounce(); + expect(h.player.abortSession).toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts b/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts index 4e33388c4..28859f092 100644 --- a/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts +++ b/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest'; -import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings'; -import type { WebDAVSettings, WebDAVSyncLogEntry } from '@/types/settings'; +import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings'; +import type { WebDAVSettings } from '@/types/settings'; describe('buildWebDAVConnectSettings', () => { test('applies form fields onto a blank previous state', () => { @@ -10,8 +10,10 @@ describe('buildWebDAVConnectSettings', () => { password: 'hunter2', rootPath: '/Readest', }); + // The builder is activation-agnostic: `enabled` (and the activation + // side effects like the syncBooks auto-flip) belong to + // withActiveCloudProvider, which the connect flow applies on top. expect(result).toEqual({ - enabled: true, serverUrl: 'https://dav.example.com', username: 'alice', password: 'hunter2', @@ -21,26 +23,8 @@ describe('buildWebDAVConnectSettings', () => { test('preserves prior bookkeeping fields across reconnect', () => { // Simulates the disconnect → reconnect flow: the user previously - // synced (deviceId minted, syncBooks toggled on, history populated), - // disabled WebDAV, and is now reconnecting with the same credentials. - const log: WebDAVSyncLogEntry[] = [ - { - id: 'log-1', - startedAt: 1_700_000_000_000, - finishedAt: 1_700_000_001_500, - status: 'success', - trigger: 'manual', - totalBooks: 3, - booksDownloaded: 0, - filesUploaded: 1, - filesAlreadyInSync: 2, - configsUploaded: 3, - configsDownloaded: 0, - coversUploaded: 0, - failures: 0, - summary: 'Sync complete', - }, - ]; + // synced (deviceId minted, syncBooks toggled on), disabled WebDAV, + // and is now reconnecting with the same credentials. const previous: WebDAVSettings = { enabled: false, serverUrl: 'https://dav.example.com', @@ -53,7 +37,6 @@ describe('buildWebDAVConnectSettings', () => { strategy: 'send', deviceId: 'device-uuid-9f3c', lastSyncedAt: 1_700_000_001_500, - syncLog: log, }; const next = buildWebDAVConnectSettings(previous, { @@ -63,7 +46,10 @@ describe('buildWebDAVConnectSettings', () => { rootPath: '/Readest', }); - expect(next.enabled).toBe(true); + // Still disabled: the connect flow activates via withActiveCloudProvider + // so the disabled -> enabled transition (and its syncBooks auto-flip) + // happens exactly once, in one place. + expect(next.enabled).toBe(false); // Stable per-device id MUST survive — losing it makes the next sync // look like a brand-new device and breaks cross-device clobber // detection in `RemoteBookConfig.writerDeviceId`. @@ -73,7 +59,6 @@ describe('buildWebDAVConnectSettings', () => { expect(next.syncProgress).toBe(true); expect(next.syncNotes).toBe(true); expect(next.lastSyncedAt).toBe(1_700_000_001_500); - expect(next.syncLog).toEqual(log); }); test('updates the credentials when the user reconnects to a different account', () => { diff --git a/apps/readest-app/src/__tests__/services/webdav-delete.test.ts b/apps/readest-app/src/__tests__/services/webdav-delete.test.ts index a94d4a9ef..cbb5923a9 100644 --- a/apps/readest-app/src/__tests__/services/webdav-delete.test.ts +++ b/apps/readest-app/src/__tests__/services/webdav-delete.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { - deleteDirectory, - WebDAVRequestError, - type WebDAVConfig, -} from '@/services/webdav/WebDAVClient'; -import { deleteRemoteBookDir } from '@/services/webdav/WebDAVSync'; +import { deleteDirectory, type WebDAVConfig } from '@/services/sync/providers/webdav/client'; +import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider'; +import { deleteRemoteBookDir } from '@/services/sync/file/engine'; +import { FileSyncError } from '@/services/sync/file/provider'; import type { WebDAVSettings } from '@/types/settings'; /** @@ -127,15 +125,16 @@ describe('deleteDirectory', () => { describe('deleteRemoteBookDir', () => { const HASH = 'abc123'; + const provider = createWebDAVProvider(settings); test('successful DELETE returns ok=true', async () => { fetchMock.mockResolvedValueOnce(buildResponse(204)); - await expect(deleteRemoteBookDir(settings, HASH)).resolves.toEqual({ ok: true }); + await expect(deleteRemoteBookDir(provider, HASH)).resolves.toEqual({ ok: true }); }); test('non-auth failure (500) returns ok=false with reason populated', async () => { fetchMock.mockResolvedValueOnce(buildResponse(500)); - const result = await deleteRemoteBookDir(settings, HASH); + const result = await deleteRemoteBookDir(provider, HASH); expect(result.ok).toBe(false); // The reason is the underlying error message; we don't pin its // exact wording (could be tweaked) but it must be a non-empty @@ -146,24 +145,23 @@ describe('deleteRemoteBookDir', () => { test('AUTH_FAILED is rethrown so callers can short-circuit batches', async () => { fetchMock.mockResolvedValueOnce(buildResponse(401)); - await expect(deleteRemoteBookDir(settings, HASH)).rejects.toBeInstanceOf(WebDAVRequestError); + await expect(deleteRemoteBookDir(provider, HASH)).rejects.toBeInstanceOf(FileSyncError); // 403 walks the same code path; assert the AUTH_FAILED tag // independently so a regression that lets one status leak // through but not the other still trips a test. fetchMock.mockResolvedValueOnce(buildResponse(403)); - await expect(deleteRemoteBookDir(settings, HASH)).rejects.toMatchObject({ + await expect(deleteRemoteBookDir(provider, HASH)).rejects.toMatchObject({ code: 'AUTH_FAILED', }); }); test('targets the correct per-hash directory under /Readest/books', async () => { - // The remote layout is documented in WebDAVPaths.ts; this test - // pins the contract so neither side can drift. A custom - // rootPath is used to make sure buildBookDirPath honours it - // (a literal '/' would mask a "join always uses leading-slash - // base" regression). + // The remote layout is documented in sync/file/layout.ts; this test + // pins the contract so neither side can drift. A custom rootPath is + // used to make sure buildBookDirPath honours it (a literal '/' would + // mask a "join always uses leading-slash base" regression). fetchMock.mockResolvedValueOnce(buildResponse(204)); - await deleteRemoteBookDir({ ...settings, rootPath: '/MyDav' }, HASH); + await deleteRemoteBookDir(createWebDAVProvider({ ...settings, rootPath: '/MyDav' }), HASH); const [url] = fetchMock.mock.calls[0]!; expect(url).toBe('https://dav.example.com/MyDav/Readest/books/abc123'); }); diff --git a/apps/readest-app/src/__tests__/services/webdav-encode-path.test.ts b/apps/readest-app/src/__tests__/services/webdav-encode-path.test.ts index e9a90bf53..a0d64a742 100644 --- a/apps/readest-app/src/__tests__/services/webdav-encode-path.test.ts +++ b/apps/readest-app/src/__tests__/services/webdav-encode-path.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import { buildRequestUrl } from '@/services/webdav/WebDAVClient'; +import { buildRequestUrl } from '@/services/sync/providers/webdav/client'; describe('buildRequestUrl (encodePath)', () => { test('escapes spaces and unicode in each segment', () => { diff --git a/apps/readest-app/src/__tests__/services/webdav-list-directory.test.ts b/apps/readest-app/src/__tests__/services/webdav-list-directory.test.ts new file mode 100644 index 000000000..a87693dc3 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/webdav-list-directory.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { listDirectory, type WebDAVConfig } from '@/services/sync/providers/webdav/client'; + +/** + * Tests for the PROPFIND listing parser, focused on the metadata fields + * that drive the browser's sort/search. `getcontentlength` / + * `getlastmodified` were already parsed; `creationdate` was added so the + * browser can offer "sort by date created". Servers that omit the + * property must still produce a valid entry with `created === undefined`. + */ + +const ORIGINAL_FETCH = globalThis.fetch; + +const config: WebDAVConfig = { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'secret', +}; + +const multistatus = (inner: string): string => + ` + + + /books/ + + + HTTP/1.1 200 OK + + + ${inner} +`; + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + vi.restoreAllMocks(); +}); + +describe('listDirectory metadata parsing', () => { + test('requests creationdate in the PROPFIND body', async () => { + fetchMock.mockResolvedValueOnce(new Response(multistatus(''), { status: 207 })); + await listDirectory(config, '/books'); + const [, init] = fetchMock.mock.calls[0]!; + expect(init?.method).toBe('PROPFIND'); + expect(String(init?.body)).toContain('creationdate'); + }); + + test('parses creationdate, getlastmodified and getcontentlength for a file', async () => { + const fileResponse = ` + + /books/novel.epub + + + + 1234 + Mon, 15 Jan 2024 10:30:00 GMT + 2023-12-01T08:00:00Z + + HTTP/1.1 200 OK + + `; + fetchMock.mockResolvedValueOnce(new Response(multistatus(fileResponse), { status: 207 })); + + const entries = await listDirectory(config, '/books'); + expect(entries).toHaveLength(1); + const entry = entries[0]!; + expect(entry.name).toBe('novel.epub'); + expect(entry.isDirectory).toBe(false); + expect(entry.size).toBe(1234); + expect(entry.lastModified).toBe('Mon, 15 Jan 2024 10:30:00 GMT'); + expect(entry.created).toBe('2023-12-01T08:00:00Z'); + }); + + test('leaves created undefined when the server omits creationdate', async () => { + const dirResponse = ` + + /books/sub/ + + + + Mon, 15 Jan 2024 10:30:00 GMT + + HTTP/1.1 200 OK + + `; + fetchMock.mockResolvedValueOnce(new Response(multistatus(dirResponse), { status: 207 })); + + const entries = await listDirectory(config, '/books'); + expect(entries).toHaveLength(1); + const entry = entries[0]!; + expect(entry.name).toBe('sub'); + expect(entry.isDirectory).toBe(true); + expect(entry.created).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/webdav-request-timeouts.test.ts b/apps/readest-app/src/__tests__/services/webdav-request-timeouts.test.ts new file mode 100644 index 000000000..45a77b335 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/webdav-request-timeouts.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + getFileBinary, + headFile, + listDirectory, + type WebDAVConfig, +} from '@/services/sync/providers/webdav/client'; + +/** + * Per-request timeouts against an unreachable/dead server. Metadata + * round-trips (PROPFIND / HEAD / MKCOL / DELETE) answer with headers only, so + * they fail fast (~5 s) instead of pinning the sync UI on a dead LAN host; + * GET / PUT carry book-sized bodies over possibly slow links, so they keep a + * generous ceiling. + */ + +const ORIGINAL_FETCH = globalThis.fetch; + +const config: WebDAVConfig = { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'secret', +}; + +/** A fetch that never resolves — only rejects when its signal aborts. */ +const hangingFetch = () => + vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')), + ); + }), + ); + +beforeEach(() => { + vi.useFakeTimers(); + globalThis.fetch = hangingFetch() as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('WebDAV request timeouts', () => { + test('HEAD fails fast with a timeout after 5 s', async () => { + const p = headFile(config, '/Readest/library.json'); + const assertion = expect(p).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(5_000); + await assertion; + }); + + test('PROPFIND listing fails fast with a timeout after 5 s', async () => { + const p = listDirectory(config, '/'); + const assertion = expect(p).rejects.toThrow(/timed out/i); + await vi.advanceTimersByTimeAsync(5_000); + await assertion; + }); + + test('GET keeps the long transfer ceiling (still pending at 5 s)', async () => { + let settled = false; + const p = getFileBinary(config, '/Readest/books/h1/big.epub'); + p.catch(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(5_000); + expect(settled).toBe(false); // large transfers must not be cut at 5 s + + await vi.advanceTimersByTimeAsync(295_000); + expect(settled).toBe(true); // but a dead link still ends eventually + }); +}); diff --git a/apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts b/apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts index fdf7bc697..c9e455958 100644 --- a/apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts +++ b/apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts @@ -6,14 +6,17 @@ describe('cleanGloss', () => { expect(cleanGloss('interj. 呃哼')).toBe('呃哼'); }); - it('strips short leading POS tags', () => { + it('strips short leading POS tags (per sense)', () => { expect(cleanGloss('a. 神秘的')).toBe('神秘的'); - expect(cleanGloss('vt. 做;vi. 看')).toBe('做'); + expect(cleanGloss('vt. 做;vi. 看')).toBe('做;看'); // two senses, POS stripped from each }); - it('keeps only the first sense (comma- or semicolon-separated)', () => { - expect(cleanGloss('内心的, 向内的, 本来的;向内的')).toBe('内心的'); - expect(cleanGloss('to run, to operate, to manage')).toBe('to run'); + it('keeps the first synonym of each of the first two senses', () => { + // ";" separates senses; "," / "、" separate near-synonyms within a sense. + expect(cleanGloss('内心的, 向内的, 本来的;向内的')).toBe('内心的;向内的'); + expect(cleanGloss('阻止, 监禁, 拘留;隔离, 拘留, 滞留, 停')).toBe('阻止;隔离'); + expect(cleanGloss('to run, to operate, to manage')).toBe('to run'); // single sense → first synonym + expect(cleanGloss('天的, 天国的;天的, 天空的')).toBe('天的'); // dedupe shared first synonym }); it('leaves a single short sense unchanged, including a trailing 的', () => { @@ -24,6 +27,27 @@ describe('cleanGloss', () => { it('returns empty string for empty input', () => { expect(cleanGloss('')).toBe(''); }); + + it('preserves the build-formatted gloss for monolingual (en-en) packs', () => { + // en-en glosses keep their full shape (≤2 senses joined by "; "); monolingual + // mode must NOT split on ";" or drop a synonym — only normalize whitespace. + expect(cleanGloss('to begin; to start', true)).toBe('to begin; to start'); + // non-monolingual reprocesses into ≤2 senses joined by ";" (fullwidth) + expect(cleanGloss('to begin; to start')).toBe('to begin;to start'); + }); + + it('applies the display length cap (the packs store the full hint)', () => { + // monolingual: caps with an ellipsis instead of splitting/reprocessing. + expect(cleanGloss('having a hidden meaning; mysterious', true)).toBe( + 'having a hidden meaning…', + ); + // a hint already within the cap is unchanged. + expect(cleanGloss('having a hidden meaning…', true)).toBe('having a hidden meaning…'); + // cross-lingual: after sense/synonym reduction the (single, long) result is also capped. + const long = '啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊'; // 26 chars, no separators + expect(cleanGloss(long).length).toBe(24); + expect(cleanGloss(long).endsWith('…')).toBe(true); + }); }); describe('baseFormCandidates', () => { @@ -35,6 +59,13 @@ describe('baseFormCandidates', () => { expect(baseFormCandidates('inwards')).toContain('inward'); }); + it('over-generates base forms for -able / -ible and negative-prefix derivations', () => { + expect(baseFormCandidates('comfortable')).toContain('comfort'); + expect(baseFormCandidates('sensible')).toContain('sense'); + expect(baseFormCandidates('unhappy')).toContain('happy'); + expect(baseFormCandidates('insufferable')).toContain('suffer'); // strip -able then in- + }); + it('returns no candidates for a non-derived word', () => { expect(baseFormCandidates('cryptic')).toEqual([]); }); diff --git a/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts b/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts index 05e7c5f49..8dd781564 100644 --- a/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts +++ b/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts @@ -83,6 +83,23 @@ describe('planGlosses derivational reduction (English source)', () => { const occ = planGlosses('ahem', derv, { sourceLang: 'en', rankCutoff: cutoff }); expect(occ[0]?.gloss).toBe('呃哼'); }); + + it('suppresses a derivation whose definition mentions the base word (thickly ⇐ thick)', () => { + // en-en: glosses are definitions, so the base/derived glosses rarely share a word — + // but "thickly"'s definition literally contains "thick", a transparent derivation. + const enen: GlossSource = { + lookup(word) { + const table: Record = { + thickly: { rank: 15000, gloss: 'in a thick manner' }, + thick: { rank: 1000, gloss: 'of great width' }, + }; + return table[word.toLowerCase()] ?? null; + }, + }; + expect( + planGlosses('thickly', enen, { sourceLang: 'en', rankCutoff: cutoff, monolingual: true }), + ).toEqual([]); + }); }); describe('planGlosses against a zh-en fixture', () => { diff --git a/apps/readest-app/src/__tests__/statistics/statisticsDb.test.ts b/apps/readest-app/src/__tests__/statistics/statisticsDb.test.ts index 1259b9f9c..4596b76ad 100644 --- a/apps/readest-app/src/__tests__/statistics/statisticsDb.test.ts +++ b/apps/readest-app/src/__tests__/statistics/statisticsDb.test.ts @@ -32,6 +32,22 @@ describe('statistics migration', () => { expect(names).toContain('readest_stat_sync_state'); }); + it('is idempotent when the page_stat view already exists (READEST-13)', async () => { + // A DB imported from KOReader (or left by a partially-applied migration) + // already has a page_stat view but no migration record. turso ignores + // IF NOT EXISTS on CREATE VIEW, so a non-idempotent migration throws + // "View page_stat already exists" here. + const imported = await NodeDatabaseService.open(':memory:'); + await imported.execute('CREATE VIEW page_stat AS SELECT 1 AS x'); + + await expect(migrate(imported, getMigrations('statistics'))).resolves.toBeUndefined(); + + const views = await imported.select<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type = 'view'`, + ); + expect(views.map((v) => v.name)).toContain('page_stat'); + }); + it('seeds the numbers helper table 1..1000', async () => { const rows = await db.select<{ c: number }>(`SELECT COUNT(*) AS c FROM numbers`); expect(rows[0]!.c).toBe(1000); @@ -103,6 +119,22 @@ describe('StatisticsDb', () => { expect(book!.total_read_time).toBe(8); }); + it('serializes concurrent applyRemoteEvents without nesting transactions (READEST-N)', async () => { + // Two pulls racing on the shared connection (split-view trackers) must not + // open a BEGIN inside a BEGIN ("cannot start a transaction within a transaction"). + const a = stats.applyRemoteEvents( + [{ bookMd5: 'ra', title: 'RA', authors: '' }], + [{ bookMd5: 'ra', page: 1, startTime: 400, duration: 3, totalPages: 10 }], + ); + const b = stats.applyRemoteEvents( + [{ bookMd5: 'rb', title: 'RB', authors: '' }], + [{ bookMd5: 'rb', page: 1, startTime: 401, duration: 4, totalPages: 10 }], + ); + await expect(Promise.all([a, b])).resolves.toBeDefined(); + expect((await stats.getBookByMd5('ra'))!.total_read_time).toBe(3); + expect((await stats.getBookByMd5('rb'))!.total_read_time).toBe(4); + }); + it('reads and writes sync cursors', async () => { expect(await stats.getCursor('push')).toBe(0); await stats.setCursor('push', 1234); diff --git a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts index 38975846d..86a767b1d 100644 --- a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts +++ b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts @@ -695,3 +695,74 @@ describe('customDictionaryStore — loadCustomDictionaries reconciliation', () = expect('imp-tombstoned' in after.providerEnabled).toBe(false); }); }); + +describe('customDictionaryStore — fontScale (dictionary popup font size, #4443)', () => { + beforeEach(() => { + vi.clearAllMocks(); + useCustomDictionaryStore.setState({ + dictionaries: [], + settings: { + providerOrder: ['local-x'], + providerEnabled: { 'local-x': true }, + webSearches: [], + }, + }); + }); + + it('setFontScale updates the in-memory setting', () => { + const { setFontScale } = useCustomDictionaryStore.getState(); + setFontScale(1.3); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.3); + }); + + it('applyRemoteDictionarySettings overlays a remote fontScale patch', () => { + const { applyRemoteDictionarySettings } = useCustomDictionaryStore.getState(); + applyRemoteDictionarySettings({ fontScale: 1.5 }); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.5); + }); + + it('loadCustomDictionaries defaults fontScale to 1 when the persisted settings omit it', async () => { + type SettingsState = ReturnType; + useSettingsStore.setState({ + settings: { + customDictionaries: [], + dictionarySettings: { + providerOrder: ['builtin:wikipedia'], + providerEnabled: { 'builtin:wikipedia': true }, + webSearches: [], + }, + } as unknown as SettingsState['settings'], + } as unknown as SettingsState); + + const fakeAppService = { exists: vi.fn().mockResolvedValue(false) }; + const fakeEnv = { + getAppService: () => Promise.resolve(fakeAppService), + } as unknown as EnvConfigType; + + await useCustomDictionaryStore.getState().loadCustomDictionaries(fakeEnv); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1); + }); + + it('loadCustomDictionaries preserves a persisted fontScale', async () => { + type SettingsState = ReturnType; + useSettingsStore.setState({ + settings: { + customDictionaries: [], + dictionarySettings: { + providerOrder: ['builtin:wikipedia'], + providerEnabled: { 'builtin:wikipedia': true }, + webSearches: [], + fontScale: 1.15, + }, + } as unknown as SettingsState['settings'], + } as unknown as SettingsState); + + const fakeAppService = { exists: vi.fn().mockResolvedValue(false) }; + const fakeEnv = { + getAppService: () => Promise.resolve(fakeAppService), + } as unknown as EnvConfigType; + + await useCustomDictionaryStore.getState().loadCustomDictionaries(fakeEnv); + expect(useCustomDictionaryStore.getState().settings.fontScale).toBe(1.15); + }); +}); diff --git a/apps/readest-app/src/__tests__/store/feedStore.test.ts b/apps/readest-app/src/__tests__/store/feedStore.test.ts new file mode 100644 index 000000000..0607994cd --- /dev/null +++ b/apps/readest-app/src/__tests__/store/feedStore.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useFeedStore } from '@/store/feedStore'; +import type { ParsedFeed } from '@/types/rss'; + +const parsed = (title: string, itemIds: string[]): ParsedFeed => ({ + title, + siteUrl: 'https://s.example.com', + items: itemIds.map((id) => ({ + id, + title: `T-${id}`, + link: `https://s.example.com/${id}`, + read: false, + })), +}); + +beforeEach(() => { + useFeedStore.setState({ feeds: [] }); +}); + +describe('feedStore', () => { + it('adds a subscription from a fetched feed', async () => { + const feed = await useFeedStore + .getState() + .addFeed('https://s.example.com/feed', async () => parsed('Blog', ['a', 'b'])); + expect(feed.title).toBe('Blog'); + expect(useFeedStore.getState().feeds).toHaveLength(1); + expect(useFeedStore.getState().unreadCount(feed.id)).toBe(2); + }); + + it('rejects a duplicate subscription', async () => { + const f = async () => parsed('Blog', ['a']); + await useFeedStore.getState().addFeed('https://s.example.com/feed', f); + await expect(useFeedStore.getState().addFeed('https://s.example.com/feed', f)).rejects.toThrow( + /already subscribed/i, + ); + }); + + it('merges new items on refresh and preserves read flags', async () => { + const feed = await useFeedStore + .getState() + .addFeed('https://s.example.com/feed', async () => parsed('Blog', ['a', 'b'])); + useFeedStore.getState().markItemRead(feed.id, 'a'); + expect(useFeedStore.getState().unreadCount(feed.id)).toBe(1); + + await useFeedStore.getState().refreshFeed(feed.id, async () => parsed('Blog', ['c', 'a', 'b'])); + const items = useFeedStore.getState().feeds[0]!.items; + expect(items.map((i) => i.id).sort()).toEqual(['a', 'b', 'c']); + expect(items.find((i) => i.id === 'a')!.read).toBe(true); // preserved + expect(items.find((i) => i.id === 'c')!.read).toBe(false); // new -> unread + expect(useFeedStore.getState().unreadCount(feed.id)).toBe(2); + }); + + it('records an error message when refresh fails', async () => { + const feed = await useFeedStore + .getState() + .addFeed('https://s.example.com/feed', async () => parsed('Blog', ['a'])); + await useFeedStore.getState().refreshFeed(feed.id, async () => { + throw new Error('boom'); + }); + expect(useFeedStore.getState().feeds[0]!.errorMessage).toBe('boom'); + }); +}); diff --git a/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts b/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts new file mode 100644 index 000000000..52b2ebdf3 --- /dev/null +++ b/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { useFileSyncStore } from '@/store/fileSyncStore'; + +const reset = () => + useFileSyncStore.setState({ byKind: {}, activeKind: null, lastErrorByKind: {} }); + +describe('fileSyncStore', () => { + beforeEach(reset); + + test('beginSync acquires the mutex and marks the backend syncing', () => { + const { beginSync } = useFileSyncStore.getState(); + expect(beginSync('webdav', 'Syncing 0 / 3')).toBe(true); + const s = useFileSyncStore.getState(); + expect(s.activeKind).toBe('webdav'); + expect(s.byKind.webdav?.isSyncing).toBe(true); + expect(s.byKind.webdav?.progressLabel).toBe('Syncing 0 / 3'); + }); + + test('a second backend cannot begin while another holds the lock', () => { + const { beginSync } = useFileSyncStore.getState(); + expect(beginSync('webdav', 'a')).toBe(true); + // Drive must not start a library sync while WebDAV is mid-run. + expect(beginSync('gdrive', 'b')).toBe(false); + expect(useFileSyncStore.getState().byKind.gdrive).toBeUndefined(); + expect(useFileSyncStore.getState().activeKind).toBe('webdav'); + }); + + test('endSync releases the lock and resets that backend to idle', () => { + const { beginSync, endSync } = useFileSyncStore.getState(); + beginSync('webdav', 'a'); + endSync('webdav'); + const s = useFileSyncStore.getState(); + expect(s.activeKind).toBeNull(); + expect(s.byKind.webdav?.isSyncing).toBe(false); + // Lock is free again for any backend. + expect(s.beginSync('gdrive', 'c')).toBe(true); + expect(useFileSyncStore.getState().activeKind).toBe('gdrive'); + }); + + test('updateProgress sets the label + detail for the active backend', () => { + const { beginSync, updateProgress } = useFileSyncStore.getState(); + beginSync('webdav', 'start'); + updateProgress('webdav', 'Uploading 2 / 3', 'Project Hail Mary'); + const p = useFileSyncStore.getState().byKind.webdav; + expect(p?.progressLabel).toBe('Uploading 2 / 3'); + expect(p?.progressDetail).toBe('Project Hail Mary'); + }); + + test('lastError is recorded per backend and survives endSync until cleared', () => { + const { beginSync, setLastError, endSync } = useFileSyncStore.getState(); + beginSync('webdav', 'a'); + setLastError('webdav', 'AUTH_FAILED: 401'); + endSync('webdav'); + // The health surface reads this after the run finished. + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBe('AUTH_FAILED: 401'); + expect(useFileSyncStore.getState().lastErrorByKind.gdrive).toBeUndefined(); + // A later successful run clears it. + setLastError('webdav', null); + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/store/notebook-store.test.ts b/apps/readest-app/src/__tests__/store/notebook-store.test.ts index 1f88a9698..80b52f48b 100644 --- a/apps/readest-app/src/__tests__/store/notebook-store.test.ts +++ b/apps/readest-app/src/__tests__/store/notebook-store.test.ts @@ -10,6 +10,7 @@ beforeEach(() => { isNotebookPinned: false, notebookActiveTab: 'notes', notebookNewAnnotation: null, + notebookNewHighlightId: null, notebookEditAnnotation: null, notebookAnnotationDrafts: {}, }); @@ -137,6 +138,20 @@ describe('notebookStore', () => { }); }); + // ── New highlight placeholder id ─────────────────────────────── + describe('setNotebookNewHighlightId', () => { + test('tracks the placeholder highlight id', () => { + useNotebookStore.getState().setNotebookNewHighlightId('hl-1'); + expect(useNotebookStore.getState().notebookNewHighlightId).toBe('hl-1'); + }); + + test('clears the placeholder highlight id when set to null', () => { + useNotebookStore.getState().setNotebookNewHighlightId('hl-1'); + useNotebookStore.getState().setNotebookNewHighlightId(null); + expect(useNotebookStore.getState().notebookNewHighlightId).toBeNull(); + }); + }); + // ── Edit annotation ──────────────────────────────────────────── describe('setNotebookEditAnnotation', () => { test('sets a note for editing', () => { @@ -216,6 +231,7 @@ describe('notebookStore', () => { expect(state.isNotebookPinned).toBe(false); expect(state.notebookActiveTab).toBe('notes'); expect(state.notebookNewAnnotation).toBeNull(); + expect(state.notebookNewHighlightId).toBeNull(); expect(state.notebookEditAnnotation).toBeNull(); expect(state.notebookAnnotationDrafts).toEqual({}); }); diff --git a/apps/readest-app/src/__tests__/store/proofread-store.test.ts b/apps/readest-app/src/__tests__/store/proofread-store.test.ts index a0dbaf902..a9aa2a2ad 100644 --- a/apps/readest-app/src/__tests__/store/proofread-store.test.ts +++ b/apps/readest-app/src/__tests__/store/proofread-store.test.ts @@ -196,7 +196,10 @@ describe('proofreadStore', () => { replacement: 'the', }); - expect(rule.id).toBe('uid-1'); + // Book rules now carry a stable content-derived id (not a random uid) + // so the same rule made on two devices dedupes on sync. + expect(rule.id).toBeTruthy(); + expect(rule.id).not.toBe('uid-1'); expect(rule.scope).toBe('book'); expect(rule.pattern).toBe('teh'); expect(rule.replacement).toBe('the'); @@ -246,6 +249,25 @@ describe('proofreadStore', () => { expect(mockSaveConfig).toHaveBeenCalledTimes(1); }); + + test('derives a stable content id (same pattern/scope/isRegex on two devices)', async () => { + mockViewSettingsMap['bookA'] = emptyViewSettings(); + mockViewSettingsMap['bookB'] = emptyViewSettings(); + + const a = await useProofreadStore.getState().addRule(envConfig, 'bookA', { + scope: 'book', + pattern: 'teh', + replacement: 'the', + }); + // Different replacement, same pattern — identity ignores replacement. + const b = await useProofreadStore.getState().addRule(envConfig, 'bookB', { + scope: 'book', + pattern: 'teh', + replacement: 'THE', + }); + + expect(a.id).toBe(b.id); + }); }); // ----------------------------------------------------------------------- @@ -282,6 +304,20 @@ describe('proofreadStore', () => { const rules = mockViewSettingsMap['book1']!.proofreadRules!; expect(rules.length).toBe(2); }); + + test('uses a per-instance unique id (not a content hash)', async () => { + mockViewSettingsMap['book1'] = emptyViewSettings(); + + const rule = await useProofreadStore.getState().addRule(envConfig, 'book1', { + scope: 'selection', + pattern: 'dup', + replacement: 'new', + }); + + // Selection rules keep the random uniqueId so two selections of the same + // text are never collapsed. + expect(rule.id).toBe('uid-1'); + }); }); // ----------------------------------------------------------------------- @@ -331,17 +367,22 @@ describe('proofreadStore', () => { // removeRule – book scope // ----------------------------------------------------------------------- describe('removeRule (book scope)', () => { - test('removes rule by id', async () => { + test('tombstones the rule (sets deletedAt) instead of hard-removing it', async () => { const rule = makeRule({ id: 'r1' }); mockViewSettingsMap['book1'] = emptyViewSettings({ proofreadRules: [rule] }); await useProofreadStore.getState().removeRule(envConfig, 'book1', 'r1', 'book'); + // The row is retained with a deletedAt tombstone so the deletion can sync + // across devices (the per-id config merge would otherwise resurrect it). const rules = mockViewSettingsMap['book1']!.proofreadRules!; - expect(rules.length).toBe(0); + expect(rules.length).toBe(1); + expect(typeof rules[0]!.deletedAt).toBe('number'); + // Hidden from consumers. + expect(useProofreadStore.getState().getBookRules('book1')).toEqual([]); }); - test('does not remove unmatched rule', async () => { + test('does not tombstone an unmatched rule', async () => { const rule = makeRule({ id: 'r1' }); mockViewSettingsMap['book1'] = emptyViewSettings({ proofreadRules: [rule] }); @@ -349,6 +390,78 @@ describe('proofreadStore', () => { const rules = mockViewSettingsMap['book1']!.proofreadRules!; expect(rules.length).toBe(1); + expect(rules[0]!.deletedAt ?? null).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // CRDT sync fields (updatedAt / deletedAt) + // ----------------------------------------------------------------------- + describe('CRDT sync fields', () => { + test('addRule stamps updatedAt', async () => { + mockViewSettingsMap['book1'] = emptyViewSettings(); + const rule = await useProofreadStore.getState().addRule(envConfig, 'book1', { + scope: 'book', + pattern: 'teh', + replacement: 'the', + }); + expect(typeof rule.updatedAt).toBe('number'); + expect(rule.updatedAt!).toBeGreaterThan(0); + }); + + test('updateRule bumps updatedAt', async () => { + const rule = makeRule({ id: 'r1', updatedAt: 1 }); + mockViewSettingsMap['book1'] = emptyViewSettings({ proofreadRules: [rule] }); + + await useProofreadStore.getState().updateRule(envConfig, 'book1', 'r1', { + replacement: 'baz', + }); + + const updated = mockViewSettingsMap['book1']!.proofreadRules!.find((r) => r.id === 'r1'); + expect(updated!.updatedAt!).toBeGreaterThan(1); + }); + + test('getBookRules hides tombstoned rules', () => { + mockViewSettingsMap['book1'] = emptyViewSettings({ + proofreadRules: [makeRule({ id: 'a' }), makeRule({ id: 'b', deletedAt: 123 })], + }); + expect( + useProofreadStore + .getState() + .getBookRules('book1') + .map((r) => r.id), + ).toEqual(['a']); + }); + + test('getMergedRules hides tombstoned rules from both stores', () => { + mockGlobalViewSettingsHolder.current = emptyViewSettings({ + proofreadRules: [makeRule({ id: 'g', scope: 'library', deletedAt: 9 })], + }); + mockViewSettingsMap['book1'] = emptyViewSettings({ + proofreadRules: [makeRule({ id: 'b' })], + }); + expect( + useProofreadStore + .getState() + .getMergedRules('book1') + .map((r) => r.id), + ).toEqual(['b']); + }); + + test('re-adding a tombstoned book pattern creates a fresh live rule', async () => { + mockViewSettingsMap['book1'] = emptyViewSettings({ + proofreadRules: [makeRule({ id: 'old', pattern: 'teh', deletedAt: 999 })], + }); + + await useProofreadStore.getState().addRule(envConfig, 'book1', { + scope: 'book', + pattern: 'teh', + replacement: 'the', + }); + + const live = mockViewSettingsMap['book1']!.proofreadRules!.filter((r) => !r.deletedAt); + expect(live.length).toBe(1); + expect(live[0]!.pattern).toBe('teh'); }); }); @@ -503,4 +616,87 @@ describe('proofreadStore', () => { ).rejects.toThrow('Rule not found: no-exist'); }); }); + + // ----------------------------------------------------------------------- + // reorderRules + // ----------------------------------------------------------------------- + describe('reorderRules', () => { + test('reassigns order fields for book-scope rules by position; saves config only', async () => { + mockViewSettingsMap['book1'] = emptyViewSettings({ + proofreadRules: [ + makeRule({ id: 'a', scope: 'book', order: 1000 }), + makeRule({ id: 'b', scope: 'book', order: 1000 }), + makeRule({ id: 'c', scope: 'book', order: 1000 }), + ], + }); + + await useProofreadStore.getState().reorderRules(envConfig, 'book1', ['c', 'a', 'b']); + + const saved = mockViewSettingsMap['book1']!.proofreadRules!; + const orderById = Object.fromEntries(saved.map((r) => [r.id, r.order])); + expect(orderById).toEqual({ c: 0, a: 1, b: 2 }); + expect(mockSaveConfig).toHaveBeenCalledTimes(1); + expect(mockSaveSettings).not.toHaveBeenCalled(); + }); + + test('reassigns order for library-scope rules; saves settings only', async () => { + mockViewSettingsMap['book1'] = emptyViewSettings({ proofreadRules: [] }); + mockGlobalViewSettingsHolder.current = emptyViewSettings({ + proofreadRules: [ + makeRule({ id: 'g1', scope: 'library', order: 1000 }), + makeRule({ id: 'g2', scope: 'library', order: 1000 }), + ], + }); + + await useProofreadStore.getState().reorderRules(envConfig, 'book1', ['g2', 'g1']); + + const savedSettings = mockSaveSettings.mock.calls[0]![1] as { + globalViewSettings: ViewSettings; + }; + const saved = savedSettings.globalViewSettings.proofreadRules!; + const orderById = Object.fromEntries(saved.map((r) => [r.id, r.order])); + expect(orderById).toEqual({ g2: 0, g1: 1 }); + expect(mockSaveSettings).toHaveBeenCalledTimes(1); + expect(mockSaveConfig).not.toHaveBeenCalled(); + }); + + test('a merged book+library reorder updates both stores', async () => { + mockViewSettingsMap['book1'] = emptyViewSettings({ + proofreadRules: [makeRule({ id: 'bk', scope: 'book', order: 1000 })], + }); + mockGlobalViewSettingsHolder.current = emptyViewSettings({ + proofreadRules: [makeRule({ id: 'lib', scope: 'library', order: 1000 })], + }); + + // Drag the library rule above the book rule. + await useProofreadStore.getState().reorderRules(envConfig, 'book1', ['lib', 'bk']); + + const savedBook = mockViewSettingsMap['book1']!.proofreadRules!.find((r) => r.id === 'bk'); + const savedSettings = mockSaveSettings.mock.calls[0]![1] as { + globalViewSettings: ViewSettings; + }; + const savedLib = savedSettings.globalViewSettings.proofreadRules!.find((r) => r.id === 'lib'); + expect(savedLib!.order).toBe(0); + expect(savedBook!.order).toBe(1); + expect(mockSaveConfig).toHaveBeenCalledTimes(1); + expect(mockSaveSettings).toHaveBeenCalledTimes(1); + }); + + test('leaves rules outside the reordered set untouched', async () => { + mockViewSettingsMap['book1'] = emptyViewSettings({ + proofreadRules: [ + makeRule({ id: 'sel', scope: 'selection', order: 7 }), + makeRule({ id: 'a', scope: 'book', order: 1000 }), + makeRule({ id: 'b', scope: 'book', order: 1000 }), + ], + }); + + await useProofreadStore.getState().reorderRules(envConfig, 'book1', ['b', 'a']); + + const saved = mockViewSettingsMap['book1']!.proofreadRules!; + expect(saved.find((r) => r.id === 'sel')!.order).toBe(7); + expect(saved.find((r) => r.id === 'b')!.order).toBe(0); + expect(saved.find((r) => r.id === 'a')!.order).toBe(1); + }); + }); }); diff --git a/apps/readest-app/src/__tests__/store/reader-store.test.ts b/apps/readest-app/src/__tests__/store/reader-store.test.ts index e66ecdbb0..880600a59 100644 --- a/apps/readest-app/src/__tests__/store/reader-store.test.ts +++ b/apps/readest-app/src/__tests__/store/reader-store.test.ts @@ -60,6 +60,13 @@ vi.mock('@/services/opds/pseStream', () => ({ openPseStreamBook: vi.fn(), parsePseStreamFileName: vi.fn(), })); +vi.mock('@/services/rss/feedBookUrl', () => ({ + isFeedBookUrl: () => false, + parseFeedBookUrl: vi.fn(), +})); +vi.mock('@/services/rss/feedReader', () => ({ + openFeedBookDoc: vi.fn(), +})); import { useReaderStore } from '@/store/readerStore'; import { useBookDataStore } from '@/store/bookDataStore'; @@ -81,6 +88,7 @@ function seedViewState(key: string, overrides: Record = {}) { error: null, ribbonVisible: false, ttsEnabled: false, + autoScrollEnabled: false, syncing: false, gridInsets: null, previewMode: false, diff --git a/apps/readest-app/src/__tests__/store/settings-store.test.ts b/apps/readest-app/src/__tests__/store/settings-store.test.ts index 16a5bec37..4fcbb97c4 100644 --- a/apps/readest-app/src/__tests__/store/settings-store.test.ts +++ b/apps/readest-app/src/__tests__/store/settings-store.test.ts @@ -10,13 +10,20 @@ vi.mock('@/utils/time', () => ({ initDayjs: vi.fn(), })); +vi.mock('@/utils/settingsSync', () => ({ + broadcastGlobalSettings: vi.fn(), +})); + import i18n from '@/i18n/i18n'; import { initDayjs } from '@/utils/time'; +import { broadcastGlobalSettings } from '@/utils/settingsSync'; import { useSettingsStore } from '@/store/settingsStore'; +import type { EnvConfigType } from '@/services/environment'; import type { SystemSettings } from '@/types/settings'; const mockChangeLanguage = vi.mocked(i18n.changeLanguage); const mockInitDayjs = vi.mocked(initDayjs); +const mockBroadcastGlobalSettings = vi.mocked(broadcastGlobalSettings); function makeSettings(overrides: Partial = {}): SystemSettings { return { @@ -31,7 +38,6 @@ function makeSettings(overrides: Partial = {}): SystemSettings { screenBrightness: 1, autoScreenBrightness: true, alwaysShowStatusBar: false, - alwaysInForeground: false, openLastBooks: false, lastOpenBooks: [], autoImportBooksOnOpen: false, @@ -91,6 +97,21 @@ describe('settingsStore', () => { }); }); + describe('saveSettings', () => { + test('persists settings and broadcasts globals to other windows', async () => { + const settings = makeSettings({ version: 7 }); + const saveSettings = vi.fn().mockResolvedValue(undefined); + const envConfig = { + getAppService: vi.fn().mockResolvedValue({ saveSettings }), + } as unknown as EnvConfigType; + + await useSettingsStore.getState().saveSettings(envConfig, settings); + + expect(saveSettings).toHaveBeenCalledWith(settings); + expect(mockBroadcastGlobalSettings).toHaveBeenCalledWith(settings); + }); + }); + describe('setSettingsDialogBookKey', () => { test('sets the dialog book key', () => { useSettingsStore.getState().setSettingsDialogBookKey('book-key-123'); diff --git a/apps/readest-app/src/__tests__/store/transfer-store.test.ts b/apps/readest-app/src/__tests__/store/transfer-store.test.ts index 77c225cde..db45a0f70 100644 --- a/apps/readest-app/src/__tests__/store/transfer-store.test.ts +++ b/apps/readest-app/src/__tests__/store/transfer-store.test.ts @@ -76,6 +76,27 @@ describe('transferStore', () => { useTransferStore.getState().updateTransferProgress('nope', 10, 10, 100, 5); expect(useTransferStore.getState().transfers).toEqual(before); }); + + test('re-applying identical values keeps the same state reference (READEST-2)', () => { + const id = useTransferStore.getState().addTransfer('h', 'B', 'upload'); + useTransferStore.getState().updateTransferProgress(id, 50, 500, 1000, 100); + const before = useTransferStore.getState().transfers; + // A repeated write with unchanged values must not allocate a new state, + // otherwise subscribers re-render and can drive an update loop. + useTransferStore.getState().updateTransferProgress(id, 50, 500, 1000, 100); + expect(useTransferStore.getState().transfers).toBe(before); + }); + + test('a speed-only change is a no-op (transferSpeed is time-derived) (READEST-2)', () => { + const id = useTransferStore.getState().addTransfer('h', 'B', 'upload'); + useTransferStore.getState().updateTransferProgress(id, 50, 500, 1000, 100); + const before = useTransferStore.getState().transfers; + // transferSpeed is recomputed from wall-clock time on every emission, so it + // almost always differs; a speed-only delta (same progress + bytes) must + // not allocate a new state, or the no-op guard never fires. + useTransferStore.getState().updateTransferProgress(id, 50, 500, 1000, 173); + expect(useTransferStore.getState().transfers).toBe(before); + }); }); // ── setTransferStatus ──────────────────────────────────────────── diff --git a/apps/readest-app/src/__tests__/styles/zIndexScale.test.ts b/apps/readest-app/src/__tests__/styles/zIndexScale.test.ts index b9cc1d58f..a34c13abc 100644 --- a/apps/readest-app/src/__tests__/styles/zIndexScale.test.ts +++ b/apps/readest-app/src/__tests__/styles/zIndexScale.test.ts @@ -42,6 +42,7 @@ const RSVP_CONTROLS = firstZ( read('src/app/reader/components/rsvp/RSVPStartDialog.tsx'), /z-\[(\d+)\]/, ); +const TOAST = firstZ(read('src/components/Toast.tsx'), /toast z-\[(\d+)\]/); const APP_LOCK = firstZ(read('src/components/AppLockScreen.tsx'), /z-\[(\d+)\]/); describe('overlay z-index scale', () => { @@ -62,12 +63,21 @@ describe('overlay z-index scale', () => { expect(RSVP_OVERLAY).toBeGreaterThan(PAGE_FRAME); }); - it('keeps the security lock screen on top of every modal', () => { + it('raises toasts above every modal so they show over open dialogs', () => { + // Regression: a sync-complete toast dispatched from the open Settings + // dialog was buried because the toast sat at z-50, below Settings (110) + // and ModalPortal (120). + expect(TOAST).toBeGreaterThan(MODAL); + expect(TOAST).toBeGreaterThan(SETTINGS); + }); + + it('keeps the security lock screen on top of every modal and toast', () => { expect(APP_LOCK).toBeGreaterThan(MODAL); + expect(APP_LOCK).toBeGreaterThan(TOAST); }); it('uses a compact scale with no four-digit z-index', () => { - for (const value of [RSVP_OVERLAY, RSVP_CONTROLS, SETTINGS, MODAL, APP_LOCK]) { + for (const value of [RSVP_OVERLAY, RSVP_CONTROLS, SETTINGS, MODAL, TOAST, APP_LOCK]) { expect(value).toBeLessThan(1000); } }); diff --git a/apps/readest-app/src/__tests__/types/wrapped-foliate-view.test.ts b/apps/readest-app/src/__tests__/types/wrapped-foliate-view.test.ts new file mode 100644 index 000000000..47936569c --- /dev/null +++ b/apps/readest-app/src/__tests__/types/wrapped-foliate-view.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { wrappedFoliateView, type FoliateView } from '@/types/view'; + +function makeFakeView(goToImpl: (href: string) => Promise) { + const el = document.createElement('div') as unknown as FoliateView; + el.addAnnotation = vi.fn() as unknown as FoliateView['addAnnotation']; + el.goTo = goToImpl as unknown as FoliateView['goTo']; + return el; +} + +describe('wrappedFoliateView goTo events', () => { + it('dispatches navigate-start before and navigate-end after goTo resolves', async () => { + const order: string[] = []; + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const view = makeFakeView(() => gate); + view.addEventListener('navigate-start', () => order.push('start')); + view.addEventListener('navigate-end', () => order.push('end')); + const wrapped = wrappedFoliateView(view); + const nav = wrapped.goTo('5'); + expect(order).toEqual(['start']); // start fired synchronously, end pending + release(); + await nav; + expect(order).toEqual(['start', 'end']); + }); + + it('dispatches navigate-end even when goTo rejects', async () => { + const order: string[] = []; + const view = makeFakeView(() => Promise.reject(new Error('nav failed'))); + view.addEventListener('navigate-start', () => order.push('start')); + view.addEventListener('navigate-end', () => order.push('end')); + const wrapped = wrappedFoliateView(view); + await expect(wrapped.goTo('5')).rejects.toThrow('nav failed'); + expect(order).toEqual(['start', 'end']); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/annotation-index.test.ts b/apps/readest-app/src/__tests__/utils/annotation-index.test.ts index b51f70127..7d357a0e7 100644 --- a/apps/readest-app/src/__tests__/utils/annotation-index.test.ts +++ b/apps/readest-app/src/__tests__/utils/annotation-index.test.ts @@ -54,6 +54,22 @@ describe('buildAnnotationIndex / selectLocationAnnotations', () => { expect(globals).toEqual([]); }); + it('skips an annotation deleted in place after the index was built (stale index)', () => { + // #4773: the re-apply effect holds a memoized index. A quick delete stamps + // `deletedAt` on the SAME object that is still sitting in the bucket (the + // memo has not recomputed yet). If the read trusts the build-time filter, + // the just-deleted highlight gets re-drawn and its overlay is orphaned — + // visible on the page until the book is reopened. The read must re-check. + const highlight = note({ style: 'highlight', color: 'yellow', note: 'hi' }); + const index = buildAnnotationIndex([highlight]); + // The user deletes the highlight: `deletedAt` is stamped in place on the + // booknote object that the stale index still references. + highlight.deletedAt = 123; + const { annotations, notes } = selectLocationAnnotations(index, location); + expect(annotations).toEqual([]); + expect(notes).toEqual([]); + }); + it('classifies a styled annotation into annotations only', () => { const highlight = note({ style: 'highlight', color: 'yellow' }); const index = buildAnnotationIndex([highlight]); diff --git a/apps/readest-app/src/__tests__/utils/annotator-util.test.ts b/apps/readest-app/src/__tests__/utils/annotator-util.test.ts index 594896b9e..24e0cb7c0 100644 --- a/apps/readest-app/src/__tests__/utils/annotator-util.test.ts +++ b/apps/readest-app/src/__tests__/utils/annotator-util.test.ts @@ -4,6 +4,7 @@ import { getExternalDragHandle, getHighlightColorLabel, removeBookNoteOverlays, + removeEmptyAnnotationPlaceholder, toParentViewportPoint, } from '@/app/reader/utils/annotatorUtil'; import { Point } from '@/utils/sel'; @@ -227,6 +228,78 @@ describe('removeBookNoteOverlays', () => { }); }); +describe('removeEmptyAnnotationPlaceholder', () => { + const baseNote = (overrides: Partial = {}): BookNote => ({ + id: 'ph-1', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2)', + style: 'highlight', + color: 'yellow', + text: 'selected text', + note: '', + createdAt: 1, + updatedAt: 1, + ...overrides, + }); + + it('tombstones the empty placeholder by id and returns it', () => { + const placeholder = baseNote(); + const booknotes = [placeholder]; + + const removed = removeEmptyAnnotationPlaceholder(booknotes, 'ph-1', 1234); + + expect(removed).toBe(placeholder); + expect(booknotes[0]!.deletedAt).toBe(1234); + }); + + it('returns null and leaves booknotes untouched when the record carries note text', () => { + const saved = baseNote({ note: 'a real note' }); + const booknotes = [saved]; + + const removed = removeEmptyAnnotationPlaceholder(booknotes, 'ph-1', 1234); + + expect(removed).toBeNull(); + expect(booknotes[0]!.deletedAt).toBeUndefined(); + }); + + it('treats whitespace-only note text as empty and tombstones it', () => { + const placeholder = baseNote({ note: ' \n ' }); + const booknotes = [placeholder]; + + const removed = removeEmptyAnnotationPlaceholder(booknotes, 'ph-1', 1234); + + expect(removed).toBe(placeholder); + expect(booknotes[0]!.deletedAt).toBe(1234); + }); + + it('returns null when no record matches the id', () => { + const booknotes = [baseNote({ id: 'other' })]; + + const removed = removeEmptyAnnotationPlaceholder(booknotes, 'ph-1', 1234); + + expect(removed).toBeNull(); + expect(booknotes[0]!.deletedAt).toBeUndefined(); + }); + + it('returns null when the matching record is already soft-deleted', () => { + const booknotes = [baseNote({ deletedAt: 5 })]; + + const removed = removeEmptyAnnotationPlaceholder(booknotes, 'ph-1', 1234); + + expect(removed).toBeNull(); + }); + + it('ignores a non-annotation record with the same id', () => { + const bookmark = baseNote({ type: 'bookmark', style: undefined }); + const booknotes = [bookmark]; + + const removed = removeEmptyAnnotationPlaceholder(booknotes, 'ph-1', 1234); + + expect(removed).toBeNull(); + expect(booknotes[0]!.deletedAt).toBeUndefined(); + }); +}); + describe('buildTTSSentenceHighlight', () => { const params = { cfi: 'epubcfi(/6/4!/4/10,/1:0,/1:42)', diff --git a/apps/readest-app/src/__tests__/utils/book-calibre-columns.test.ts b/apps/readest-app/src/__tests__/utils/book-calibre-columns.test.ts new file mode 100644 index 000000000..ea6d2398f --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/book-calibre-columns.test.ts @@ -0,0 +1,62 @@ +// Display formatting for Calibre custom column values (readest#4811). +import { describe, expect, it } from 'vitest'; + +import { CalibreCustomColumn } from '@/libs/document'; +import { formatCalibreColumnValue } from '@/utils/book'; + +const column = (partial: Partial): CalibreCustomColumn => ({ + label: 'col', + name: 'Col', + datatype: 'text', + value: '', + ...partial, +}); + +describe('formatCalibreColumnValue', () => { + it('joins multi-value columns with a comma', () => { + expect(formatCalibreColumnValue(column({ value: ['TOD', 'Grandma'] }))).toBe('TOD, Grandma'); + }); + + it('passes plain text through', () => { + expect(formatCalibreColumnValue(column({ value: 'TOD' }))).toBe('TOD'); + }); + + it('renders ratings (0-10 half stars) as stars', () => { + expect(formatCalibreColumnValue(column({ datatype: 'rating', value: 8 }))).toBe('★★★★'); + expect(formatCalibreColumnValue(column({ datatype: 'rating', value: 7 }))).toBe('★★★½'); + }); + + it('renders series with its index like calibre does', () => { + expect( + formatCalibreColumnValue(column({ datatype: 'series', value: 'Cool Saga', extra: 2 })), + ).toBe('Cool Saga [2]'); + expect(formatCalibreColumnValue(column({ datatype: 'series', value: 'Cool Saga' }))).toBe( + 'Cool Saga', + ); + }); + + it('renders yes/no columns as check marks', () => { + expect(formatCalibreColumnValue(column({ datatype: 'bool', value: true }))).toBe('✓'); + expect(formatCalibreColumnValue(column({ datatype: 'bool', value: false }))).toBe('✗'); + }); + + it('renders datetime columns as a locale date', () => { + const formatted = formatCalibreColumnValue( + column({ datatype: 'datetime', value: '2024-03-01T10:00:00+00:00' }), + ); + expect(formatted).toContain('2024'); + expect(formatted).toContain('March'); + }); + + it('strips markup from comments columns', () => { + expect( + formatCalibreColumnValue( + column({ datatype: 'comments', value: '

Great read

' }), + ), + ).toBe('Great read'); + }); + + it('stringifies numbers', () => { + expect(formatCalibreColumnValue(column({ datatype: 'float', value: 2.5 }))).toBe('2.5'); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/captured-turn.browser.test.ts b/apps/readest-app/src/__tests__/utils/captured-turn.browser.test.ts new file mode 100644 index 000000000..01763c325 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/captured-turn.browser.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CapturedPageTurn, CapturedTurnHost } from '@/app/reader/utils/capturedTurn'; + +// Choreography tests for the captured page-turn controller (readest#555): +// capture the page → overlay the captured bitmap → instantly navigate the +// live view underneath → animate (or scrub) the turn → dispose. Pixel-level +// curl geometry is covered by page-curl.browser.test.ts; these tests assert +// the orchestration contract against a fake host. + +const W = 320; +const H = 240; + +const makePngBuffer = async (): Promise => { + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'rgb(200, 60, 60)'; + ctx.fillRect(0, 0, W, H); + const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')); + return blob.arrayBuffer(); +}; + +describe('CapturedPageTurn (browser)', () => { + let host: HTMLDivElement; + let capture: ReturnType>; + let navigate: ReturnType>; + let controller: CapturedPageTurn; + + const contentRect = () => new DOMRect(10, 20, W, H); + + beforeEach(async () => { + host = document.createElement('div'); + Object.assign(host.style, { + position: 'absolute', + left: '0', + top: '0', + width: '400px', + height: '300px', + }); + document.body.appendChild(host); + const png = await makePngBuffer(); + capture = vi.fn().mockResolvedValue(png); + navigate = vi.fn().mockResolvedValue(undefined); + const hostApi: CapturedTurnHost = { + getHostElement: () => host, + getContentRect: contentRect, + capture, + navigate, + }; + controller = new CapturedPageTurn(hostApi, { duration: 40 }); + }); + + afterEach(() => { + controller.dispose(); + host.remove(); + }); + + it('captures the content rect, navigates once, and disposes after a turn', async () => { + const ok = await controller.turn(true, false); + expect(ok).toBe(true); + expect(capture).toHaveBeenCalledWith({ x: 10, y: 20, width: W, height: H }); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(true); + // Overlay fully cleaned up. + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('mounts the overlay canvas over the content box while animating', async () => { + // Slow animation so the overlay is reliably observable mid-turn. + const slow = new CapturedPageTurn( + { getHostElement: () => host, getContentRect: contentRect, capture, navigate }, + { duration: 5000 }, + ); + const turned = slow.turn(true, false); + // Wait until the async capture+navigate steps have mounted the overlay. + await vi.waitFor(() => { + expect(host.querySelector('canvas')).not.toBeNull(); + }); + const overlay = host.querySelector('canvas')!.parentElement!; + expect(overlay.style.left).toBe('10px'); + expect(overlay.style.top).toBe('20px'); + slow.dispose(); + await turned; + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('slides the captured page toward the spine on a forward LTR turn', async () => { + const slow = new CapturedPageTurn( + { getHostElement: () => host, getContentRect: contentRect, capture, navigate }, + { duration: 5000 }, + ); + const turned = slow.turn(true, false, 'slide'); + await vi.waitFor(() => { + expect(host.querySelector('canvas')).not.toBeNull(); + }); + const canvas = host.querySelector('canvas')!; + // The overlay clips the exiting page to the content box like the VT slide. + expect(canvas.parentElement!.style.overflow).toBe('hidden'); + await vi.waitFor(() => { + const shift = new DOMMatrixReadOnly(getComputedStyle(canvas).transform).e; + expect(shift).toBeLessThan(0); + }); + slow.dispose(); + await turned; + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('slides backward turns out over the outer edge (mirrored)', async () => { + const slow = new CapturedPageTurn( + { getHostElement: () => host, getContentRect: contentRect, capture, navigate }, + { duration: 5000 }, + ); + const turned = slow.turn(false, false, 'slide'); + await vi.waitFor(() => { + expect(host.querySelector('canvas')).not.toBeNull(); + }); + const canvas = host.querySelector('canvas')!; + await vi.waitFor(() => { + const shift = new DOMMatrixReadOnly(getComputedStyle(canvas).transform).e; + expect(shift).toBeGreaterThan(0); + }); + slow.dispose(); + await turned; + }); + + it('propagates capture failures without navigating or leaving an overlay', async () => { + capture.mockRejectedValueOnce(new Error('no capture')); + await expect(controller.turn(true, false)).rejects.toThrow('no capture'); + expect(navigate).not.toHaveBeenCalled(); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('interrupts an in-flight turn when a new one starts', async () => { + const first = controller.turn(true, false); + await vi.waitFor(() => expect(navigate).toHaveBeenCalledTimes(1)); + const second = controller.turn(true, false); + await Promise.all([first, second]); + expect(navigate).toHaveBeenCalledTimes(2); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('scrubs a drag and navigates back when cancelled', async () => { + const began = await controller.beginDrag(true, false); + expect(began).toBe(true); + expect(navigate).toHaveBeenNthCalledWith(1, true); + expect(host.querySelector('canvas')).not.toBeNull(); + + controller.moveDrag(0.3, 0.5); + await controller.endDrag(false); + // Cancel: back to flat, then instantly turn back under the overlay. + expect(navigate).toHaveBeenNthCalledWith(2, false); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('scrubs a slide drag and cleans up on commit', async () => { + const began = await controller.beginDrag(true, false, 'slide'); + expect(began).toBe(true); + const canvas = host.querySelector('canvas')!; + controller.moveDrag(0.5, 0.5); + expect(new DOMMatrixReadOnly(getComputedStyle(canvas).transform).e).toBeCloseTo(-W / 2, 0); + await controller.endDrag(true); + expect(navigate).toHaveBeenCalledTimes(1); + expect(host.querySelector('canvas')).toBeNull(); + }); + + it('commits a drag without a second navigation', async () => { + await controller.beginDrag(true, false); + controller.moveDrag(0.7, 0.5); + await controller.endDrag(true); + expect(navigate).toHaveBeenCalledTimes(1); + expect(host.querySelector('canvas')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts b/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts new file mode 100644 index 000000000..c19884235 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { parseBookDeepLink } from '@/utils/deeplink'; + +describe('parseBookDeepLink', () => { + it('parses the custom-scheme book-open form', () => { + expect(parseBookDeepLink('readest://book/abc123')).toEqual({ bookHash: 'abc123' }); + }); + it('parses the web form', () => { + expect(parseBookDeepLink('https://web.readest.com/o/book/abc123')).toEqual({ + bookHash: 'abc123', + }); + }); + it('surfaces the Android Auto autoplay flag', () => { + expect(parseBookDeepLink('readest://book/abc123?autoplay=tts')).toEqual({ + bookHash: 'abc123', + autoplay: true, + }); + }); + it('omits autoplay when absent or not tts', () => { + expect(parseBookDeepLink('readest://book/abc123?autoplay=foo')).toEqual({ bookHash: 'abc123' }); + expect(parseBookDeepLink('readest://book/abc123')).toEqual({ bookHash: 'abc123' }); + }); + it('does NOT match the annotation form', () => { + expect(parseBookDeepLink('readest://book/abc123/annotation/n1')).toBeNull(); + }); + it('ignores unrelated urls', () => { + expect(parseBookDeepLink('readest://share/tok')).toBeNull(); + expect(parseBookDeepLink('not a url')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/event.test.ts b/apps/readest-app/src/__tests__/utils/event.test.ts index a85163e6b..873f437f9 100644 --- a/apps/readest-app/src/__tests__/utils/event.test.ts +++ b/apps/readest-app/src/__tests__/utils/event.test.ts @@ -68,6 +68,31 @@ describe('EventDispatcher', () => { eventDispatcher.off('no-detail', fn); }); + it('does not call listeners added during an in-progress dispatch (snapshot semantics)', async () => { + // Regression: a listener that (re)subscribes another listener mid-dispatch + // must not have that new listener fire for the *current* event. The live + // Set used to be iterated directly, so a handler added during an awaited + // listener got invoked in the same dispatch — causing paragraph mode to + // toggle twice per keypress (#4717). + const added = vi.fn(); + const adder = vi.fn(async () => { + await Promise.resolve(); + eventDispatcher.on('reentrant', added); + }); + eventDispatcher.on('reentrant', adder); + + await eventDispatcher.dispatch('reentrant'); + expect(adder).toHaveBeenCalledOnce(); + expect(added).not.toHaveBeenCalled(); + + // The newly-added listener takes effect on the next dispatch. + await eventDispatcher.dispatch('reentrant'); + expect(added).toHaveBeenCalledOnce(); + + eventDispatcher.off('reentrant', adder); + eventDispatcher.off('reentrant', added); + }); + it('awaits async listeners sequentially', async () => { const order: string[] = []; const slowFn = vi.fn(async () => { diff --git a/apps/readest-app/src/__tests__/utils/fixed-layout-styles.test.ts b/apps/readest-app/src/__tests__/utils/fixed-layout-styles.test.ts new file mode 100644 index 000000000..e15592b8f --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/fixed-layout-styles.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@/utils/misc', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + getOSPlatform: vi.fn(() => 'macos' as const), + }; +}); + +import { applyFixedlayoutStyles, ThemeCode } from '@/utils/style'; +import { ViewSettings } from '@/types/book'; +import { + DEFAULT_BOOK_FONT, + DEFAULT_BOOK_LAYOUT, + DEFAULT_BOOK_LANGUAGE, + DEFAULT_BOOK_STYLE, + DEFAULT_VIEW_CONFIG, + DEFAULT_TTS_CONFIG, + DEFAULT_TRANSLATOR_CONFIG, + DEFAULT_ANNOTATOR_CONFIG, + DEFAULT_SCREEN_CONFIG, +} from '@/services/constants'; + +function makeViewSettings(overrides: Partial = {}): ViewSettings { + return { + ...DEFAULT_BOOK_FONT, + ...DEFAULT_BOOK_LAYOUT, + ...DEFAULT_BOOK_LANGUAGE, + ...DEFAULT_BOOK_STYLE, + ...DEFAULT_VIEW_CONFIG, + ...DEFAULT_TTS_CONFIG, + ...DEFAULT_TRANSLATOR_CONFIG, + ...DEFAULT_ANNOTATOR_CONFIG, + ...DEFAULT_SCREEN_CONFIG, + ...overrides, + } as ViewSettings; +} + +function makeThemeCode(overrides: Partial = {}): ThemeCode { + return { + bg: '#ffffff', + fg: '#000000', + primary: '#3366cc', + isDarkMode: false, + palette: { + 'base-100': '#ffffff', + 'base-200': '#f0f0f0', + 'base-300': '#e0e0e0', + 'base-content': '#000000', + neutral: '#808080', + 'neutral-content': '#ffffff', + primary: '#3366cc', + secondary: '#6699cc', + accent: '#33cc99', + }, + ...overrides, + }; +} + +/** Run applyFixedlayoutStyles on a fresh document and return the injected CSS. */ +function fixedLayoutCss(vs: ViewSettings, theme: ThemeCode): string { + const doc = document.implementation.createHTMLDocument('test'); + applyFixedlayoutStyles(doc, vs, theme); + return doc.getElementById('fixed-layout-styles')?.textContent ?? ''; +} + +describe('applyFixedlayoutStyles contrast filter', () => { + it('does not apply a contrast filter at the default 100%', () => { + const css = fixedLayoutCss(makeViewSettings({ contrast: 100 }), makeThemeCode()); + expect(css).not.toContain('contrast('); + }); + + it('applies a contrast filter when contrast is increased above 100%', () => { + const css = fixedLayoutCss(makeViewSettings({ contrast: 150 }), makeThemeCode()); + expect(css).toContain('filter: contrast(150%)'); + }); + + it('applies a contrast filter when contrast is decreased below 100%', () => { + const css = fixedLayoutCss(makeViewSettings({ contrast: 75 }), makeThemeCode()); + expect(css).toContain('filter: contrast(75%)'); + }); + + it('applies contrast in light mode too (independent of invertImgColorInDark)', () => { + const css = fixedLayoutCss( + makeViewSettings({ contrast: 150, invertImgColorInDark: true }), + makeThemeCode({ isDarkMode: false }), + ); + expect(css).toContain('contrast(150%)'); + expect(css).not.toContain('invert(100%)'); + }); + + it('combines invert and contrast into a single filter declaration in dark mode', () => { + const css = fixedLayoutCss( + makeViewSettings({ contrast: 150, invertImgColorInDark: true }), + makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' }), + ); + expect(css).toContain('filter: invert(100%) contrast(150%)'); + }); + + it('treats an undefined contrast as 100% (no filter, backward compatible)', () => { + const css = fixedLayoutCss( + makeViewSettings({ contrast: undefined as unknown as number }), + makeThemeCode(), + ); + expect(css).not.toContain('contrast('); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts b/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts index ce069ddc1..fbecd4452 100644 --- a/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts +++ b/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts @@ -73,6 +73,7 @@ describe('resolvePageTurn', () => { pageNext: { source: 'native', id: 'MediaNext', label: 'Media Next' }, sectionPrev: { source: 'dom', id: 'PageUp', label: 'Page Up' }, sectionNext: { source: 'dom', id: 'PageDown', label: 'Page Down' }, + refresh: { source: 'native', id: 'MediaPlayPause', label: 'Media Play/Pause' }, }, }; @@ -92,8 +93,12 @@ describe('resolvePageTurn', () => { expect(resolvePageTurn(settings, { source: 'dom', id: 'PageDown' })).toBe('sectionNext'); }); + test('returns "refresh" for the refresh binding', () => { + expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPlayPause' })).toBe('refresh'); + }); + test('returns null for an unbound key', () => { - expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPlayPause' })).toBeNull(); + expect(resolvePageTurn(settings, { source: 'native', id: 'MediaFastForward' })).toBeNull(); }); test('returns null when the feature is disabled', () => { diff --git a/apps/readest-app/src/__tests__/utils/library-utils.test.ts b/apps/readest-app/src/__tests__/utils/library-utils.test.ts index 587feba98..e16b3e261 100644 --- a/apps/readest-app/src/__tests__/utils/library-utils.test.ts +++ b/apps/readest-app/src/__tests__/utils/library-utils.test.ts @@ -544,6 +544,18 @@ describe('getGroupSortValue', () => { expect(getGroupSortValue(group, LibrarySortByType.Series)).toBe('My Series'); }); + it('should return max read ratio for progress sort', () => { + const group = createMockGroup({ + books: [ + createMockBook({ progress: [10, 100] }), + createMockBook({ progress: [80, 100] }), + createMockBook({}), + ], + }); + + expect(getGroupSortValue(group, LibrarySortByType.Progress)).toBe(0.8); + }); + it('should handle empty groups gracefully', () => { const group = createMockGroup({ books: [] }); diff --git a/apps/readest-app/src/__tests__/utils/md.test.ts b/apps/readest-app/src/__tests__/utils/md.test.ts new file mode 100644 index 000000000..dfe0c156d --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/md.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeMarkdownBook } from '@/utils/md'; +import type { BookDoc } from '@/libs/document'; +import { CFI } from '@/libs/document'; +import { getIndexFromCfi } from '@/utils/cfi'; + +// makeMarkdownBook returns a foliate book with a few methods (resolveHref, +// isExternal, destroy) and a per-section load() that the BookDoc type does not +// declare. Cast to this richer shape in tests to exercise them. +type MdBook = BookDoc & { + toc: NonNullable; + sections: Array string }>; + resolveHref: ( + href: string, + ) => { index: number; anchor: (doc: Document) => Element | null } | null; + isExternal: (uri: string) => boolean; + destroy: () => void; +}; + +const mdFile = (content: string, name = 'note.md', type = 'text/markdown') => + new File([content], name, { type }); + +const make = async (content: string, name?: string, type?: string) => + (await makeMarkdownBook(mdFile(content, name, type))) as unknown as MdBook; + +const flattenToc = (items: BookDoc['toc'] = []): NonNullable => + items.flatMap((i) => [i, ...(i.subitems ? flattenToc(i.subitems) : [])]); + +describe('makeMarkdownBook', () => { + it('renders headings, inline marks, lists, code and tables', async () => { + const book = await make( + '# Title\n\nHello **world**.\n\n- a\n- b\n\n```js\nconst x = 1;\n```\n\n| h |\n| - |\n| v |\n', + ); + const doc = await book.sections[0]!.createDocument(); + expect(doc.querySelector('parsererror')).toBeNull(); + expect(doc.querySelector('h1')?.textContent).toBe('Title'); + expect(doc.querySelector('strong')?.textContent).toBe('world'); + expect(doc.querySelectorAll('li').length).toBe(2); + expect(doc.querySelector('table')).toBeTruthy(); + expect(doc.querySelector('code')?.getAttribute('class')).toContain('language-js'); + }); + + it('splits at H1 into one section per chapter, nests deeper headings in the TOC', async () => { + const book = await make('# One\n\na\n\n## Sub\n\nb\n\n# Two\n\nc\n'); + expect(book.sections.map((s) => s.id)).toEqual(['0', '1']); + expect(book.toc.length).toBe(2); + expect(book.toc[0]!.label).toBe('One'); + expect(book.toc[0]!.href).toBe('0#one'); + expect(book.toc[0]!.subitems![0]!.label).toBe('Sub'); + expect(book.toc[0]!.subitems![0]!.href).toBe('0#sub'); + expect(book.toc[1]!.href).toBe('1#two'); + }); + + it('treats content before the first H1 as a leading preamble section', async () => { + const book = await make('Intro text.\n\n# Only\n\nbody\n'); + expect(book.sections.length).toBe(2); + expect(book.toc.length).toBe(1); + expect(book.toc[0]!.href).toBe('1#only'); + }); + + it('yields a single section with a usable TOC when there is no H1', async () => { + const book = await make('## Sub only\n\ntext\n'); + expect(book.sections.length).toBe(1); + expect(book.toc.length).toBe(1); + expect(book.toc[0]!.href).toBe('0#sub-only'); + }); + + it('de-duplicates heading ids deterministically', async () => { + const book = await make('# Dup\n\n## Dup\n\n### Dup\n'); + const ids = flattenToc(book.toc).map((i) => i.href.split('#')[1]); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toEqual(['dup', 'dup-1', 'dup-2']); + }); + + it('keeps section ids as strings that resolve in a string-keyed section map (nav contract)', async () => { + const book = await make('# A\n\n## A1\n\n# B\n'); + const sectionMap = new Map(book.sections.map((s) => [s.id, s])); + for (const item of flattenToc(book.toc)) { + const [sid] = book.splitTOCHref(item.href); + expect(typeof sid).toBe('string'); + expect(sectionMap.get(sid as string)).toBeDefined(); + } + }); + + it('gives each section a CFI base that round-trips to its index (resume position)', async () => { + // foliate-js view.getCFI does `section.cfi ?? CFI.fake.fromIndex(index)`, + // so an empty-string cfi defeats the fallback and the generated location + // CFI loses its spine step. Reopening then resolves to no section and the + // reader falls back to the start. Verify each section carries a base CFI + // that survives the getCFI -> resolveCFI round-trip. + const book = await make('# One\n\na\n\n# Two\n\nb\n\n# Three\n\nc\n'); + book.sections.forEach((section, index) => { + const baseCFI = section.cfi ?? CFI.fake.fromIndex(index); + const locationCFI = CFI.joinIndir(baseCFI, '/4/2:3'); + expect(getIndexFromCfi(locationCFI)).toBe(index); + }); + }); + + it('produces XHTML that parses without errors despite void tags', async () => { + const book = await make('# H\n\nline one \nline two\n\n---\n\n![alt](https://e.com/i.png)\n'); + const doc = await book.sections[0]!.createDocument(); + expect(doc.querySelector('parsererror')).toBeNull(); + expect(doc.querySelector('br')).toBeTruthy(); + expect(doc.querySelector('img')?.getAttribute('src')).toBe('https://e.com/i.png'); + }); + + it('resolves TOC links, internal anchors, and rejects unknown/external', async () => { + const book = await make('# A\n\njump [x](#b-sec)\n\n# B Sec\n'); + expect(book.resolveHref('1#b-sec')).toMatchObject({ index: 1 }); + expect(book.resolveHref('#b-sec')).toMatchObject({ index: 1 }); + expect(book.resolveHref('#missing')).toBeNull(); + expect(book.resolveHref('9#x')).toBeNull(); + expect(book.isExternal('https://example.com')).toBe(true); + expect(book.isExternal('mailto:a@b.com')).toBe(true); + expect(book.isExternal('#b-sec')).toBe(false); + }); + + it('strips scripts and event handlers but keeps code class and heading ids', async () => { + const book = await make( + '# H\n\n\n\n\n\n```ts\nok\n```\n', + ); + const doc = await book.sections[0]!.createDocument(); + expect(doc.querySelector('script')).toBeNull(); + expect(doc.querySelector('img')?.getAttribute('onerror')).toBeNull(); + expect(doc.querySelector('code')?.getAttribute('class')).toContain('language-ts'); + expect(doc.querySelector('h1')?.getAttribute('id')).toBeTruthy(); + }); + + it('resolves the title from frontmatter, then first H1, then filename', async () => { + const fm = await make('---\ntitle: From Front\nauthor: Jane Doe\n---\n\n# Ignored\n'); + expect(fm.metadata.title).toBe('From Front'); + expect(fm.metadata.author).toBe('Jane Doe'); + const h1 = await make('# The Heading\n\nbody\n'); + expect(h1.metadata.title).toBe('The Heading'); + const fn = await make('just text\n', 'My Notes.md'); + expect(fn.metadata.title).toBe('My Notes'); + }); + + it('creates object URLs lazily and revokes every one on destroy', async () => { + const url = URL as unknown as { + createObjectURL: (b: Blob) => string; + revokeObjectURL: (u: string) => void; + }; + const origCreate = url.createObjectURL; + const origRevoke = url.revokeObjectURL; + const revoked: string[] = []; + let n = 0; + url.createObjectURL = vi.fn(() => `blob:md-${n++}`); + url.revokeObjectURL = vi.fn((u: string) => void revoked.push(u)); + try { + const book = await make('# A\n\n# B\n'); + expect(url.createObjectURL).toHaveBeenCalledTimes(0); // lazy + const u0 = book.sections[0]!.load(); + expect(book.sections[0]!.load()).toBe(u0); // cached per section + expect(url.createObjectURL).toHaveBeenCalledTimes(1); + book.destroy(); + expect(revoked).toContain(u0); + } finally { + url.createObjectURL = origCreate; + url.revokeObjectURL = origRevoke; + } + }); +}); + +describe('DocumentLoader markdown routing', () => { + it('routes .md, .markdown and a markdown blob typed text/plain to MD format', async () => { + const { DocumentLoader } = await import('@/libs/document'); + const cases = [ + mdFile('# X\n', 'a.md', 'text/markdown'), + mdFile('# X\n', 'a.markdown', ''), + mdFile('# X\n', 'a.md', 'text/plain'), + ]; + for (const file of cases) { + const { format } = await new DocumentLoader(file).open(); + expect(format).toBe('MD'); + } + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/nav-window.test.ts b/apps/readest-app/src/__tests__/utils/nav-window.test.ts new file mode 100644 index 000000000..35cca648f --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/nav-window.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { AppService } from '@/types/system'; + +const webviewWindowCtor = vi.fn(); + +vi.mock('@tauri-apps/api/webviewWindow', () => ({ + WebviewWindow: class { + constructor(label: string, options: Record) { + webviewWindowCtor(label, options); + } + once() {} + show() {} + }, +})); + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ label: 'main' }), + ScrollBarStyle: {}, +})); + +vi.mock('@/services/environment', () => ({ + isTauriAppPlatform: () => true, + isWebAppPlatform: () => false, + isPWA: () => false, +})); + +import { showReaderWindow } from '@/utils/nav'; + +const makeAppService = (os: 'macos' | 'windows' | 'linux'): AppService => + ({ + isMacOSApp: os === 'macos', + isWindowsApp: os === 'windows', + isLinuxApp: os === 'linux', + osPlatform: os, + }) as unknown as AppService; + +// Regression (#3682): reader/extra windows opened via nav.ts must also be +// opaque on Linux — a transparent WebKitGTK window goes invisible when the web +// process is busy. Only macOS (native decorations) stays non-transparent by +// design; Windows keeps its existing behavior. +describe('nav.ts window transparency', () => { + beforeEach(() => { + webviewWindowCtor.mockClear(); + }); + + test('Linux reader window is not transparent', () => { + showReaderWindow(makeAppService('linux'), ['book-1']); + expect(webviewWindowCtor).toHaveBeenCalledTimes(1); + const options = webviewWindowCtor.mock.calls[0]![1] as Record; + expect(options['transparent']).toBe(false); + }); + + test('macOS reader window is not transparent (native decorations)', () => { + showReaderWindow(makeAppService('macos'), ['book-1']); + const options = webviewWindowCtor.mock.calls[0]![1] as Record; + expect(options['transparent']).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/opds-req.test.ts b/apps/readest-app/src/__tests__/utils/opds-req.test.ts index 27343e010..4ea6c33ac 100644 --- a/apps/readest-app/src/__tests__/utils/opds-req.test.ts +++ b/apps/readest-app/src/__tests__/utils/opds-req.test.ts @@ -177,6 +177,63 @@ describe('opdsReq', () => { expect(auth).toBe(`Basic ${btoa('alice:s3cret')}`); }); + it('recovers when a Digest-only server rejects the preemptive Basic header with 400', async () => { + // Calibre in 'digest' (or 'auto' over http) mode responds to a Basic + // Authorization header with 400 "Unsupported authentication method" + // instead of a 401 challenge, so the preemptive Basic header dead-ends + // the request. The client must re-issue the request without credentials + // to obtain the WWW-Authenticate challenge, then negotiate Digest. + fetchMock + .mockResolvedValueOnce( + makeResponse({ status: 400, body: 'Unsupported authentication method' }), + ) + .mockResolvedValueOnce( + makeResponse({ + status: 401, + wwwAuthenticate: 'Digest realm="calibre", nonce="abc123", algorithm="MD5", qop="auth"', + }), + ) + .mockResolvedValueOnce(makeResponse({ status: 200, body: '' })); + + const res = await fetchWithAuth('http://calibre.example.com/opds', 'alice', 's3cret', false); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const bareInit = fetchMock.mock.calls[1]![1] as RequestInit; + const bareHeaders = bareInit.headers as Record; + expect(bareHeaders['Authorization']).toBeUndefined(); + const digestInit = fetchMock.mock.calls[2]![1] as RequestInit; + const digestHeaders = digestInit.headers as Record; + expect(digestHeaders['Authorization']).toMatch(/^Digest /); + expect(res.status).toBe(200); + }); + + it('recovers from the preemptive-Basic 400 through the proxy as well', async () => { + fetchMock + .mockResolvedValueOnce( + makeResponse({ status: 400, body: 'Unsupported authentication method' }), + ) + .mockResolvedValueOnce( + makeResponse({ + // The web proxy maps the upstream 401 to 403 and forwards the + // WWW-Authenticate challenge. + status: 403, + wwwAuthenticate: 'Digest realm="calibre", nonce="abc123", algorithm="MD5", qop="auth"', + }), + ) + .mockResolvedValueOnce(makeResponse({ status: 200, body: '' })); + + const res = await fetchWithAuth('http://calibre.example.com/opds', 'alice', 's3cret', true); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const bareUrl = fetchMock.mock.calls[1]![0] as string; + expect(new URL(bareUrl, 'https://web.readest.com').searchParams.get('auth')).toBeNull(); + const digestUrl = fetchMock.mock.calls[2]![0] as string; + expect(new URL(digestUrl, 'https://web.readest.com').searchParams.get('auth')).toMatch( + /^Digest /, + ); + expect(res.status).toBe(200); + }); + it('retries with Digest auth when the server issues a Digest challenge', async () => { fetchMock .mockResolvedValueOnce( diff --git a/apps/readest-app/src/__tests__/utils/page-curl.browser.test.ts b/apps/readest-app/src/__tests__/utils/page-curl.browser.test.ts new file mode 100644 index 000000000..8633cecb5 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/page-curl.browser.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { PageCurlRenderer } from '@/utils/pageCurl'; + +// Tests for the WebGL page-curl renderer (readest#555 mesh curl groundwork). +// A synthetic four-quadrant page texture makes the deformation checkable per +// pixel — green/blue across the fold axis, red/yellow rows to pin the +// vertical orientation. The texture reaches the renderer the same way +// production does: PNG blob → createImageBitmap. WebKit ignores +// UNPACK_FLIP_Y_WEBGL for ImageBitmap uploads, so the renderer must not +// depend on it — the orientation assertions catch that (upside-down curl +// on iOS, readest#555). + +const W = 400; +const H = 300; + +const makePageBitmap = async (): Promise => { + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d')!; + // Top row: green | blue. Bottom row: red | yellow. + ctx.fillStyle = 'rgb(0, 160, 0)'; + ctx.fillRect(0, 0, W / 2, H / 2); + ctx.fillStyle = 'rgb(0, 0, 160)'; + ctx.fillRect(W / 2, 0, W / 2, H / 2); + ctx.fillStyle = 'rgb(160, 0, 0)'; + ctx.fillRect(0, H / 2, W / 2, H / 2); + ctx.fillStyle = 'rgb(160, 160, 0)'; + ctx.fillRect(W / 2, H / 2, W / 2, H / 2); + const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')); + return createImageBitmap(blob); +}; + +describe('PageCurlRenderer (browser)', () => { + let renderer: PageCurlRenderer; + let host: HTMLDivElement; + + beforeEach(async () => { + host = document.createElement('div'); + Object.assign(host.style, { + position: 'absolute', + left: '0', + top: '0', + width: `${W}px`, + height: `${H}px`, + }); + document.body.appendChild(host); + renderer = new PageCurlRenderer(); + renderer.attach(host, W, H, 1); + renderer.setTexture(await makePageBitmap()); + }); + + afterEach(() => { + renderer?.dispose(); + host?.remove(); + }); + + it('covers the page exactly and upright at progress 0', () => { + renderer.render(0); + const topLeft = renderer.readPixel(40, 75); + const topRight = renderer.readPixel(W - 20, 75); + expect(topLeft[3]).toBe(255); + expect(topLeft[1]).toBeGreaterThan(100); // green + expect(topRight[3]).toBe(255); + expect(topRight[2]).toBeGreaterThan(100); // blue + // Vertical orientation: the bottom half must show the bottom of the + // page (red), not the top — an upside-down texture swaps these. + const bottomLeft = renderer.readPixel(40, H - 75); + expect(bottomLeft[3]).toBe(255); + expect(bottomLeft[0]).toBeGreaterThan(100); // red + expect(bottomLeft[1]).toBeLessThan(100); + }); + + it('curls the outer half away, folding its whitened back over the spine side', () => { + renderer.render(0.45, { x: 1, y: 0.5 }); + + // The outer (right) region has curled away: transparent, the live page + // beneath would show through. + const outer = renderer.readPixel(W - 60, 75); + expect(outer[3]).toBe(0); + + // The wrapped-over part lands near the spine ON TOP, showing the page + // back: whitened blue (the mirrored outer-half content). A straight + // fold mirrors horizontally only — the top row stays on top, so this + // is whitened BLUE (not whitened yellow from the bottom row). + const back = renderer.readPixel(100, 75); + expect(back[3]).toBe(255); + expect(back[0]).toBeGreaterThan(140); // whitened + expect(back[2]).toBeGreaterThan(180); // blue tint preserved + expect(back[1]).toBeLessThan(back[2]); // not yellow: rows did not flip + + // The far spine edge still shows the flat front (green). + const front = renderer.readPixel(12, 75); + expect(front[3]).toBe(255); + expect(front[1]).toBeGreaterThan(100); + expect(front[0]).toBeLessThan(120); + }); + + it('fully clears the page at progress 1', () => { + renderer.render(1, { x: 1, y: 0.5 }); + for (const x of [20, W / 2, W - 20]) { + expect(renderer.readPixel(x, 150)[3]).toBe(0); + } + }); + + it('tilts the fold for corner grabs', () => { + renderer.render(0.4, { x: 1, y: 1 }); + // A bottom-corner grab folds diagonally: at the same x, the bottom is + // curled away while the top is still flat. + const top = renderer.readPixel(W - 110, 20); + const bottom = renderer.readPixel(W - 110, H - 20); + expect(top[3]).toBe(255); + expect(bottom[3]).toBe(0); + }); + + it('mirrors the direction for rtl pages', () => { + renderer.render(0.45, { x: 0, y: 0.5 }, true); + // rtl grabs the LEFT edge: the left region curls away, the right stays. + const left = renderer.readPixel(60, 75); + const right = renderer.readPixel(W - 12, 75); + expect(left[3]).toBe(0); + expect(right[3]).toBe(255); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/permission.test.ts b/apps/readest-app/src/__tests__/utils/permission.test.ts new file mode 100644 index 000000000..f098e9175 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/permission.test.ts @@ -0,0 +1,23 @@ +import { describe, test, expect } from 'vitest'; +import { isStoragePermissionError } from '@/utils/permission'; + +describe('isStoragePermissionError', () => { + test('detects Android EACCES / permission-denied save failures', () => { + expect( + isStoragePermissionError( + new Error( + 'Failed to save library.json: failed to open file at path: ' + + '/storage/emulated/0/Readest/Books/library.json.bak with error: ' + + 'Permission denied (os error 13)', + ), + ), + ).toBe(true); + expect(isStoragePermissionError('EACCES: permission denied')).toBe(true); + }); + + test('ignores unrelated errors', () => { + expect(isStoragePermissionError(new Error('disk full'))).toBe(false); + expect(isStoragePermissionError(new Error('JSON parse error'))).toBe(false); + expect(isStoragePermissionError(undefined)).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/progress.test.ts b/apps/readest-app/src/__tests__/utils/progress.test.ts new file mode 100644 index 000000000..1eb37083a --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/progress.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { getChapterTickFractions } from '@/utils/progress'; +import type { TOCItem } from '@/libs/document'; + +const toc = (href: string, subitems?: TOCItem[]): TOCItem => + ({ id: 0, label: href, href, index: 0, subitems }) as TOCItem; + +// 5 spine sections -> sectionFractions has length 6 ([0, .2, .4, .6, .8, 1]), +// matching foliate's `getSectionFractions()` (boundaries incl. start and end). +const sectionFractions = [0, 0.2, 0.4, 0.6, 0.8, 1]; + +const makeView = (fractions: number[], hrefToIndex: Record) => ({ + getSectionFractions: () => fractions, + resolveNavigation: (href: string) => (href in hrefToIndex ? { index: hrefToIndex[href]! } : null), +}); + +describe('getChapterTickFractions', () => { + it('returns chapter-start fractions sorted, excluding the first and last tick', () => { + const view = makeView(sectionFractions, { + 'ch4.xhtml': 4, + 'ch1.xhtml': 1, + 'ch3.xhtml': 3, + 'ch2.xhtml': 2, + }); + + const ticks = getChapterTickFractions(view, [ + toc('ch4.xhtml'), + toc('ch1.xhtml'), + toc('ch3.xhtml'), + toc('ch2.xhtml'), + ]); + + // chapter starts = [0.2, 0.4, 0.6, 0.8]; the first (0.2) and last (0.8) are + // dropped so ticks never crowd the bar's rounded ends. + expect(ticks).toEqual([0.4, 0.6]); + }); + + it('drops the book-start chapter (section 0) and unresolved hrefs before trimming', () => { + const view = makeView(sectionFractions, { + 'intro.xhtml': 0, + 'ch1.xhtml': 1, + 'ch2.xhtml': 2, + 'ch3.xhtml': 3, + }); + + const ticks = getChapterTickFractions(view, [ + toc('intro.xhtml'), + toc('ch1.xhtml'), + toc('ch2.xhtml'), + toc('ch3.xhtml'), + toc('missing.xhtml'), + ]); + + // interior starts = [0.2, 0.4, 0.6]; first (0.2) and last (0.6) dropped. + expect(ticks).toEqual([0.4]); + }); + + it('collapses TOC entries (including nested subitems) in one section before trimming', () => { + const view = makeView(sectionFractions, { + 'ch1.xhtml': 1, + 'ch2.xhtml': 2, + 'ch2.xhtml#sec-a': 2, + 'ch3.xhtml': 3, + 'ch4.xhtml': 4, + }); + + const ticks = getChapterTickFractions(view, [ + toc('ch1.xhtml'), + toc('ch2.xhtml', [toc('ch2.xhtml#sec-a')]), + toc('ch3.xhtml'), + toc('ch4.xhtml'), + ]); + + // unique starts = [0.2, 0.4, 0.6, 0.8]; first and last dropped. + expect(ticks).toEqual([0.4, 0.6]); + }); + + it('returns an empty array when there are too few chapters or data is missing', () => { + const view = makeView(sectionFractions, { 'ch1.xhtml': 1, 'ch2.xhtml': 2 }); + // only 2 interior ticks -> trimming the first and last leaves none + expect(getChapterTickFractions(view, [toc('ch1.xhtml'), toc('ch2.xhtml')])).toEqual([]); + expect(getChapterTickFractions(null, [toc('ch1.xhtml')])).toEqual([]); + expect(getChapterTickFractions(view, [])).toEqual([]); + expect(getChapterTickFractions(view, null)).toEqual([]); + expect(getChapterTickFractions(makeView([], {}), [toc('ch1.xhtml')])).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/proofread.test.ts b/apps/readest-app/src/__tests__/utils/proofread.test.ts new file mode 100644 index 000000000..4547cc6ef --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/proofread.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect } from 'vitest'; +import type { ProofreadRule } from '@/types/book'; +import { ensureRuleId, mergeProofreadRules } from '@/utils/proofread'; + +function rule(overrides: Partial = {}): ProofreadRule { + return { + id: 'r1', + scope: 'book', + pattern: 'foo', + replacement: 'bar', + isRegex: false, + enabled: true, + caseSensitive: true, + order: 1000, + wholeWord: true, + onlyForTTS: false, + ...overrides, + }; +} + +describe('mergeProofreadRules', () => { + test('unions rules with disjoint ids', () => { + const local = [rule({ id: 'a' })]; + const remote = [rule({ id: 'b' })]; + const merged = mergeProofreadRules(local, remote); + expect(merged.map((r) => r.id).sort()).toEqual(['a', 'b']); + }); + + test('same id: remote wins when its updatedAt is newer', () => { + const local = [rule({ id: 'a', replacement: 'local', updatedAt: 100 })]; + const remote = [rule({ id: 'a', replacement: 'remote', updatedAt: 200 })]; + const merged = mergeProofreadRules(local, remote); + expect(merged).toHaveLength(1); + expect(merged[0]!.replacement).toBe('remote'); + }); + + test('same id: local wins when its updatedAt is newer', () => { + const local = [rule({ id: 'a', replacement: 'local', updatedAt: 300 })]; + const remote = [rule({ id: 'a', replacement: 'remote', updatedAt: 200 })]; + const merged = mergeProofreadRules(local, remote); + expect(merged).toHaveLength(1); + expect(merged[0]!.replacement).toBe('local'); + }); + + test('remote deletion (tombstone) wins over an older local edit', () => { + const local = [rule({ id: 'a', updatedAt: 100, deletedAt: null })]; + const remote = [rule({ id: 'a', updatedAt: 100, deletedAt: 200 })]; + const merged = mergeProofreadRules(local, remote); + expect(merged).toHaveLength(1); + expect(merged[0]!.deletedAt).toBe(200); + }); + + test('a deleted rule is not resurrected by a stale peer copy', () => { + // Device A deleted the rule (tombstone). Device B still has the live copy. + const localDeleted = [rule({ id: 'a', updatedAt: 50, deletedAt: 300 })]; + const remoteLive = [rule({ id: 'a', updatedAt: 50, deletedAt: null })]; + const merged = mergeProofreadRules(localDeleted, remoteLive); + expect(merged).toHaveLength(1); + expect(merged[0]!.deletedAt).toBe(300); + }); + + test('treats missing updatedAt as 0 and keeps the local copy on a tie', () => { + const local = [rule({ id: 'a', replacement: 'local' })]; + const remote = [rule({ id: 'a', replacement: 'remote' })]; + const merged = mergeProofreadRules(local, remote); + expect(merged).toHaveLength(1); + expect(merged[0]!.replacement).toBe('local'); + }); + + test('handles empty inputs', () => { + expect(mergeProofreadRules([], [])).toEqual([]); + expect(mergeProofreadRules([rule({ id: 'a' })], [])).toHaveLength(1); + expect(mergeProofreadRules([], [rule({ id: 'b' })])).toHaveLength(1); + }); + + // Rules with no id (legacy / hand-edited / foreign sync peer). Without + // backfilling a content-based id they would all collide on the Map's + // `undefined` key — distinct rules would clobber each other (silent loss), + // not duplicate. + test('two id-less identical rules merge to one', () => { + const local = [rule({ id: '', scope: 'book', pattern: 'teh', replacement: 'the' })]; + const remote = [rule({ id: '', scope: 'book', pattern: 'teh', replacement: 'the' })]; + const merged = mergeProofreadRules(local, remote); + expect(merged).toHaveLength(1); + }); + + test('two id-less DIFFERENT rules across sides are both kept', () => { + const local = [rule({ id: '', scope: 'book', pattern: 'teh' })]; + const remote = [rule({ id: '', scope: 'book', pattern: 'recieve' })]; + const merged = mergeProofreadRules(local, remote); + expect(merged.map((r) => r.pattern).sort()).toEqual(['recieve', 'teh']); + }); + + test('two DIFFERENT id-less rules on the SAME side are both kept (no undefined-key collapse)', () => { + const local = [ + rule({ id: '', scope: 'book', pattern: 'teh' }), + rule({ id: '', scope: 'book', pattern: 'recieve' }), + ]; + const merged = mergeProofreadRules(local, []); + expect(merged).toHaveLength(2); + }); +}); + +describe('ensureRuleId', () => { + test('backfills a missing id deterministically from content', () => { + const a = ensureRuleId(rule({ id: '', scope: 'book', pattern: 'teh', replacement: 'the' })); + const b = ensureRuleId(rule({ id: '', scope: 'book', pattern: 'teh', replacement: 'THE' })); + expect(a.id).toBeTruthy(); + // Identity mirrors the in-store dedup (scope + pattern + isRegex); the + // replacement is NOT part of it, so the same pattern collapses to one id. + expect(b.id).toBe(a.id); + }); + + test('a different pattern / scope / isRegex yields a different id', () => { + const base = ensureRuleId(rule({ id: '', scope: 'book', pattern: 'teh' })).id; + expect(ensureRuleId(rule({ id: '', scope: 'book', pattern: 'other' })).id).not.toBe(base); + expect(ensureRuleId(rule({ id: '', scope: 'library', pattern: 'teh' })).id).not.toBe(base); + expect( + ensureRuleId(rule({ id: '', scope: 'book', pattern: 'teh', isRegex: true })).id, + ).not.toBe(base); + }); + + test('leaves an existing id untouched', () => { + expect(ensureRuleId(rule({ id: 'fixed' })).id).toBe('fixed'); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/scrollable.test.ts b/apps/readest-app/src/__tests__/utils/scrollable.test.ts index 30f8664fa..3a92ea3af 100644 --- a/apps/readest-app/src/__tests__/utils/scrollable.test.ts +++ b/apps/readest-app/src/__tests__/utils/scrollable.test.ts @@ -67,6 +67,42 @@ describe('applyScrollableStyle', () => { expect(table.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true); }); + it('hoists a negative table margin onto the wrapper so it is not clipped (#4439)', () => { + // A decorative TOC lays out as nested tables; the inner table pulls itself + // up with a negative top margin. Inside an overflow:auto wrapper that margin + // would bleed the table (and its heading) past the box edge and get clipped. + document.body.innerHTML = ` +
+
CONTENTS
+
+ `; + applyScrollableStyle(document); + const table = document.querySelector('table')!; + const wrapper = table.parentElement!; + expect(wrapper.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true); + // The negative top margin moves to the wrapper (box stays in place)... + expect(wrapper.style.marginTop).toBe('-1em'); + // ...and is zeroed on the table so it sits flush inside the clip box. + expect(table.style.marginTop).toBe('0px'); + // The positive left margin is left alone (still counts toward overflow). + expect(wrapper.style.marginLeft).toBe(''); + expect(table.style.marginLeft).toBe('1em'); + }); + + it('leaves a table with only non-negative margins untouched', () => { + document.body.innerHTML = ` +
+
Cell
+
+ `; + applyScrollableStyle(document); + const table = document.querySelector('table')!; + const wrapper = table.parentElement!; + expect(wrapper.style.marginTop).toBe(''); + expect(table.style.marginTop).toBe('1em'); + expect(table.style.marginLeft).toBe('auto'); + }); + it('wraps a display equation (math that is the sole content of its container)', () => { document.body.innerHTML = `
diff --git a/apps/readest-app/src/__tests__/utils/sel-keyboard-adjustment.test.ts b/apps/readest-app/src/__tests__/utils/sel-keyboard-adjustment.test.ts new file mode 100644 index 000000000..3ff5809be --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/sel-keyboard-adjustment.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { getKeyboardSelectionAdjustment } from '@/utils/sel'; + +describe('getKeyboardSelectionAdjustment', () => { + it('maps Shift+ArrowRight to a forward character extension', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true })).toEqual({ + direction: 'right', + granularity: 'character', + }); + }); + + it('maps Shift+ArrowLeft to a backward character extension', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowLeft', shiftKey: true })).toEqual({ + direction: 'left', + granularity: 'character', + }); + }); + + it('treats Ctrl+Shift+Arrow as a word extension (Windows/Linux)', () => { + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true, ctrlKey: true }), + ).toEqual({ direction: 'right', granularity: 'word' }); + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowLeft', shiftKey: true, ctrlKey: true }), + ).toEqual({ direction: 'left', granularity: 'word' }); + }); + + it('treats Alt/Option+Shift+Arrow as a word extension (macOS)', () => { + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true, altKey: true }), + ).toEqual({ direction: 'right', granularity: 'word' }); + }); + + it('returns null without the Shift modifier', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowRight' })).toBeNull(); + expect(getKeyboardSelectionAdjustment({ key: 'ArrowLeft', ctrlKey: true })).toBeNull(); + }); + + it('returns null when the Meta/Cmd key is held (reserved for line-boundary selection)', () => { + expect( + getKeyboardSelectionAdjustment({ key: 'ArrowRight', shiftKey: true, metaKey: true }), + ).toBeNull(); + }); + + it('ignores non-horizontal arrows and other keys', () => { + expect(getKeyboardSelectionAdjustment({ key: 'ArrowUp', shiftKey: true })).toBeNull(); + expect(getKeyboardSelectionAdjustment({ key: 'ArrowDown', shiftKey: true })).toBeNull(); + expect(getKeyboardSelectionAdjustment({ key: 'a', shiftKey: true })).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/sel.test.ts b/apps/readest-app/src/__tests__/utils/sel.test.ts index 7e8b06613..1b90777cc 100644 --- a/apps/readest-app/src/__tests__/utils/sel.test.ts +++ b/apps/readest-app/src/__tests__/utils/sel.test.ts @@ -531,4 +531,95 @@ describe('sel utilities', () => { document.body.removeChild(container); }); }); + + describe('getWordRangeAt', () => { + const withTextNode = (text: string) => { + const container = document.createElement('div'); + const node = document.createTextNode(text); + container.appendChild(node); + document.body.appendChild(container); + return { container, node }; + }; + + it('expands a caret in the middle of a word to the whole word', async () => { + const { container, node } = withTextNode('Hello world test'); + const { getWordRangeAt } = await import('@/utils/sel'); + // offset 8 = the "r" inside "world" + const range = getWordRangeAt(node, 8); + expect(range?.toString()).toBe('world'); + document.body.removeChild(container); + }); + + it('selects the word when the caret sits at the word start boundary', async () => { + const { container, node } = withTextNode('Hello world test'); + const { getWordRangeAt } = await import('@/utils/sel'); + const range = getWordRangeAt(node, 6); // start of "world" + expect(range?.toString()).toBe('world'); + document.body.removeChild(container); + }); + + it('selects the word when the caret sits at the word end boundary', async () => { + const { container, node } = withTextNode('Hello world test'); + const { getWordRangeAt } = await import('@/utils/sel'); + const range = getWordRangeAt(node, 11); // end of "world" + expect(range?.toString()).toBe('world'); + document.body.removeChild(container); + }); + + it('selects a CJK word at the caret', async () => { + const { container, node } = withTextNode('阅读测试内容'); + const { getWordRangeAt } = await import('@/utils/sel'); + const range = getWordRangeAt(node, 1); + expect(range && range.toString().length > 0).toBe(true); + document.body.removeChild(container); + }); + + it('returns null on whitespace / non-word positions', async () => { + const { container, node } = withTextNode(' '); + const { getWordRangeAt } = await import('@/utils/sel'); + expect(getWordRangeAt(node, 1)).toBeNull(); + document.body.removeChild(container); + }); + + it('returns null when the node is not a text node', async () => { + const container = document.createElement('div'); + container.innerHTML = 'Hello'; + document.body.appendChild(container); + const { getWordRangeAt } = await import('@/utils/sel'); + expect(getWordRangeAt(container, 0)).toBeNull(); + document.body.removeChild(container); + }); + }); + + describe('getWordRangeFromPoint', () => { + it('resolves the word under the point via caretRangeFromPoint', async () => { + const container = document.createElement('div'); + const node = document.createTextNode('Hello world test'); + container.appendChild(node); + document.body.appendChild(container); + + const caretRange = document.createRange(); + caretRange.setStart(node, 8); + caretRange.collapse(true); + const doc = Object.assign(document, { + caretRangeFromPoint: () => caretRange, + }) as Document & { caretRangeFromPoint: (x: number, y: number) => Range | null }; + + const { getWordRangeFromPoint } = await import('@/utils/sel'); + const range = getWordRangeFromPoint(doc, 50, 50); + expect(range?.toString()).toBe('world'); + + delete (doc as { caretRangeFromPoint?: unknown }).caretRangeFromPoint; + document.body.removeChild(container); + }); + + it('returns null when no caret resolves at the point', async () => { + const doc = Object.assign(document, { + caretRangeFromPoint: () => null, + }) as Document & { caretRangeFromPoint: (x: number, y: number) => Range | null }; + const { getWordRangeFromPoint } = await import('@/utils/sel'); + expect(getWordRangeFromPoint(doc, 0, 0)).toBeNull(); + delete (doc as { caretRangeFromPoint?: unknown }).caretRangeFromPoint; + }); + }); }); diff --git a/apps/readest-app/src/__tests__/utils/serializer.test.ts b/apps/readest-app/src/__tests__/utils/serializer.test.ts index ca958664d..b37d5bfbd 100644 --- a/apps/readest-app/src/__tests__/utils/serializer.test.ts +++ b/apps/readest-app/src/__tests__/utils/serializer.test.ts @@ -108,6 +108,77 @@ describe('BookConfig serialization', () => { expect(tombstone.deletedAt).toBeTruthy(); }); + it('does not persist an array view setting that equals the global value', () => { + // Array/object view settings must be compared by value, not reference — + // otherwise annotationToolbarItems (an array) is stored as a per-book override on + // every save, shadowing later global changes (the customize-toolbar bug). + const global = { + ...globalViewSettings, + annotationToolbarItems: ['highlight', 'annotate', 'copy'], + } as unknown as ViewSettings; + const config: BookConfig = { + updatedAt: 1, + // Same content as global but a distinct array reference, as produced by the + // load -> merge -> serialize round-trip. + viewSettings: { + annotationToolbarItems: ['highlight', 'annotate', 'copy'], + } as Partial, + }; + + const parsed = JSON.parse(serializeConfig(config, global, defaultSearchConfig)); + + expect(parsed.viewSettings.annotationToolbarItems).toBeUndefined(); + }); + + it('persists an array view setting that differs from the global value', () => { + const global = { + ...globalViewSettings, + annotationToolbarItems: ['highlight', 'annotate', 'copy'], + } as unknown as ViewSettings; + const config: BookConfig = { + updatedAt: 1, + viewSettings: { annotationToolbarItems: ['copy'] } as Partial, + }; + + const parsed = JSON.parse(serializeConfig(config, global, defaultSearchConfig)); + + expect(parsed.viewSettings.annotationToolbarItems).toEqual(['copy']); + }); + + it('migrates v2 search config: matchWholeWords:true -> mode "whole-words"', () => { + const config = deserializeConfig( + JSON.stringify({ schemaVersion: 2, searchConfig: { matchWholeWords: true } }), + globalViewSettings, + defaultSearchConfig, + ); + const sc = config.searchConfig as BookSearchConfig; + expect(sc.mode).toBe('whole-words'); + expect(sc.matchWholeWords).toBe(true); + expect(sc.nearbyWords).toBe(10); + }); + + it('migrates v2 search config: matchWholeWords:false -> mode "contains"', () => { + const config = deserializeConfig( + JSON.stringify({ schemaVersion: 2, searchConfig: { matchWholeWords: false } }), + globalViewSettings, + defaultSearchConfig, + ); + const sc = config.searchConfig as BookSearchConfig; + expect(sc.mode).toBe('contains'); + expect(sc.matchWholeWords).toBe(false); + }); + + it('preserves an explicit mode and mirrors the deprecated boolean', () => { + const config = deserializeConfig( + JSON.stringify({ schemaVersion: 3, searchConfig: { mode: 'regex' } }), + globalViewSettings, + defaultSearchConfig, + ); + const sc = config.searchConfig as BookSearchConfig; + expect(sc.mode).toBe('regex'); + expect(sc.matchWholeWords).toBe(false); + }); + it('does not migrate annotations when schemaVersion is already 2', () => { const config = deserializeConfig( JSON.stringify({ diff --git a/apps/readest-app/src/__tests__/utils/settings-sync.test.ts b/apps/readest-app/src/__tests__/utils/settings-sync.test.ts new file mode 100644 index 000000000..cda2267ad --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/settings-sync.test.ts @@ -0,0 +1,132 @@ +import { describe, test, expect } from 'vitest'; +import type { SystemSettings } from '@/types/settings'; +import type { ReadSettings } from '@/types/settings'; +import type { ViewSettings } from '@/types/book'; +import { mergeSyncedGlobalSettings } from '@/utils/settingsSync'; + +const makeLocal = (overrides: Partial = {}): SystemSettings => + ({ + localBooksDir: '/device-local/books', + customRootDir: '/device-local/root', + lastOpenBooks: ['book-only-open-in-this-window'], + screenBrightness: 0.42, + lastSyncedAtBooks: 1234, + globalViewSettings: { disableClick: false, disableSwipe: false } as ViewSettings, + globalReadSettings: { sideBarWidth: '15%' } as ReadSettings, + ...overrides, + }) as SystemSettings; + +describe('mergeSyncedGlobalSettings cloud sync provider flags', () => { + test('adopts enabled flags and providerSelectedAt, preserving credentials and cursors', () => { + const local = makeLocal({ + webdav: { + enabled: false, + serverUrl: 'https://dav', + password: 'secret', + deviceId: 'd1', + lastSyncedAt: 42, + }, + googleDrive: { enabled: true, accountLabel: 'a@b' }, + onedrive: { enabled: false, accountLabel: 'c@d' }, + } as Partial); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: local.globalViewSettings, + globalReadSettings: local.globalReadSettings, + cloudSyncProviders: { + webdav: { enabled: true, providerSelectedAt: 999 }, + googleDrive: { enabled: false }, + onedrive: { enabled: true, providerSelectedAt: 888 }, + }, + }); + expect(merged.webdav.enabled).toBe(true); + expect(merged.webdav.providerSelectedAt).toBe(999); + expect(merged.webdav.password).toBe('secret'); + expect(merged.webdav.deviceId).toBe('d1'); + expect(merged.webdav.lastSyncedAt).toBe(42); + expect(merged.googleDrive.enabled).toBe(false); + expect(merged.googleDrive.accountLabel).toBe('a@b'); + expect(merged.onedrive.enabled).toBe(true); + expect(merged.onedrive.providerSelectedAt).toBe(888); + expect(merged.onedrive.accountLabel).toBe('c@d'); + }); + + test('a payload without provider flags leaves the slices untouched', () => { + const local = makeLocal({ + webdav: { enabled: true, password: 'secret' }, + onedrive: { enabled: true, accountLabel: 'c@d' }, + } as Partial); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: local.globalViewSettings, + globalReadSettings: local.globalReadSettings, + }); + expect(merged.webdav.enabled).toBe(true); + expect(merged.webdav.password).toBe('secret'); + expect(merged.onedrive.enabled).toBe(true); + expect(merged.onedrive.accountLabel).toBe('c@d'); + }); + + test('an absent onedrive flag in the payload leaves the local slice untouched', () => { + const local = makeLocal({ + webdav: { enabled: false }, + onedrive: { enabled: true, accountLabel: 'c@d' }, + } as Partial); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: local.globalViewSettings, + globalReadSettings: local.globalReadSettings, + cloudSyncProviders: { + webdav: { enabled: true }, + googleDrive: { enabled: false }, + }, + }); + expect(merged.onedrive.enabled).toBe(true); + expect(merged.onedrive.accountLabel).toBe('c@d'); + }); +}); + +describe('mergeSyncedGlobalSettings', () => { + test('adopts the broadcasting window global view settings', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: { disableClick: true, disableSwipe: true } as ViewSettings, + globalReadSettings: { sideBarWidth: '15%' } as ReadSettings, + }); + + expect(merged.globalViewSettings.disableClick).toBe(true); + expect(merged.globalViewSettings.disableSwipe).toBe(true); + }); + + test('adopts the broadcasting window global read settings', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: local.globalViewSettings, + globalReadSettings: { sideBarWidth: '30%' } as ReadSettings, + }); + + expect(merged.globalReadSettings.sideBarWidth).toBe('30%'); + }); + + test('preserves device/window-local fields from the local copy', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: { disableClick: true } as ViewSettings, + globalReadSettings: { sideBarWidth: '30%' } as ReadSettings, + }); + + expect(merged.localBooksDir).toBe('/device-local/books'); + expect(merged.customRootDir).toBe('/device-local/root'); + expect(merged.lastOpenBooks).toEqual(['book-only-open-in-this-window']); + expect(merged.screenBrightness).toBe(0.42); + expect(merged.lastSyncedAtBooks).toBe(1234); + }); + + test('returns a new object without mutating the local settings', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: { disableClick: true } as ViewSettings, + globalReadSettings: { sideBarWidth: '30%' } as ReadSettings, + }); + + expect(merged).not.toBe(local); + expect(local.globalViewSettings.disableClick).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/shortcut-keys.test.ts b/apps/readest-app/src/__tests__/utils/shortcut-keys.test.ts index 21bc1ed03..9e34ee4a9 100644 --- a/apps/readest-app/src/__tests__/utils/shortcut-keys.test.ts +++ b/apps/readest-app/src/__tests__/utils/shortcut-keys.test.ts @@ -1,5 +1,22 @@ import { describe, it, expect } from 'vitest'; -import { formatKeyForDisplay, filterPlatformKeys } from '../../utils/shortcutKeys'; +import { formatKeyForDisplay, filterPlatformKeys, matchesShortcut } from '../../utils/shortcutKeys'; + +const evt = ( + key: string, + modifiers: Partial<{ + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; + shiftKey: boolean; + }> = {}, +) => ({ + key, + ctrlKey: false, + altKey: false, + metaKey: false, + shiftKey: false, + ...modifiers, +}); describe('formatKeyForDisplay', () => { describe('Mac platform', () => { @@ -127,3 +144,40 @@ describe('filterPlatformKeys', () => { ]); }); }); + +describe('matchesShortcut', () => { + it('matches a shift+letter shortcut against an uppercased key event', () => { + expect(matchesShortcut(evt('P', { shiftKey: true }), ['shift+p'])).toBe(true); + }); + + it('does not match when a required modifier is missing', () => { + expect(matchesShortcut(evt('p'), ['shift+p'])).toBe(false); + }); + + it('does not match when an extra modifier is pressed', () => { + expect(matchesShortcut(evt('P', { shiftKey: true, ctrlKey: true }), ['shift+p'])).toBe(false); + }); + + it('treats alt and opt as the same modifier', () => { + expect(matchesShortcut(evt('p', { altKey: true }), ['alt+p'])).toBe(true); + expect(matchesShortcut(evt('p', { altKey: true }), ['opt+p'])).toBe(true); + }); + + it('treats cmd and meta as the same modifier', () => { + expect(matchesShortcut(evt('p', { metaKey: true }), ['cmd+p'])).toBe(true); + expect(matchesShortcut(evt('p', { metaKey: true }), ['meta+p'])).toBe(true); + }); + + it('matches when any key in the list matches', () => { + expect(matchesShortcut(evt('p', { altKey: true }), ['ctrl+p', 'cmd+p', 'alt+p'])).toBe(true); + }); + + it('matches symbol and space base keys', () => { + expect(matchesShortcut(evt(']', { ctrlKey: true }), ['ctrl+]'])).toBe(true); + expect(matchesShortcut(evt(' ', { shiftKey: true }), ['shift+ '])).toBe(true); + }); + + it('returns false for an empty key list', () => { + expect(matchesShortcut(evt('p', { shiftKey: true }), [])).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/style-dom.test.ts b/apps/readest-app/src/__tests__/utils/style-dom.test.ts index 819863653..3c132d2c2 100644 --- a/apps/readest-app/src/__tests__/utils/style-dom.test.ts +++ b/apps/readest-app/src/__tests__/utils/style-dom.test.ts @@ -417,6 +417,33 @@ describe('getStyles', () => { expect(css).toContain('background-color: #ffffff !important'); expect(css).toContain('color: #171717 !important'); }); + + it('scopes the inline-image baseline default to its own class so author values win #4866', () => { + const vs = makeViewSettings(); + const themeCode: ThemeCode = { + bg: '#ffffff', + fg: '#171717', + primary: '#0066cc', + palette: { + 'base-100': '#ffffff', + 'base-200': '#f2f2f2', + 'base-300': '#e0e0e0', + 'base-content': '#171717', + neutral: '#cccccc', + 'neutral-content': '#444444', + primary: '#0066cc', + secondary: '#3399ff', + accent: '#0055aa', + }, + isDarkMode: false, + }; + const css = getStyles(vs, themeCode); + // Baseline is opt-in via a dedicated class, not forced on every inline image. + expect(css).toMatch(/img\.has-text-siblings-baseline\s*\{[^}]*vertical-align:\s*baseline/); + // The size-clamp rule must not carry vertical-align anymore. + const clampRule = css.match(/\n\s*img\.has-text-siblings\s*\{[^}]*\}/)![0]; + expect(clampRule).not.toContain('vertical-align'); + }); }); // --------------------------------------------------------------------------- @@ -479,6 +506,23 @@ describe('applyImageStyle', () => { const img = document.querySelector('img')!; expect(img.classList.contains('has-text-siblings')).toBe(false); }); + + it('applies the baseline default when the image has no author vertical-align', () => { + document.body.innerHTML = '

Some text more text

'; + applyImageStyle(document); + const img = document.querySelector('img')!; + expect(img.classList.contains('has-text-siblings')).toBe(true); + expect(img.classList.contains('has-text-siblings-baseline')).toBe(true); + }); + + it('respects an author-set vertical-align (no baseline default) #4866', () => { + document.body.innerHTML = + '

Some text more text

'; + applyImageStyle(document); + const img = document.querySelector('img')!; + expect(img.classList.contains('has-text-siblings')).toBe(true); + expect(img.classList.contains('has-text-siblings-baseline')).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts b/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts index 6d369cff2..f1938a02c 100644 --- a/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts +++ b/apps/readest-app/src/__tests__/utils/style-get-styles.test.ts @@ -744,6 +744,32 @@ describe('getTranslationStyles branches (via getStyles)', () => { }); }); +// --------------------------------------------------------------------------- +// getRubyStyles branches (Word Lens gloss size + color) +// --------------------------------------------------------------------------- +describe('getRubyStyles branches (via getStyles)', () => { + const theme = makeThemeCode(); + const rtBlock = (css: string) => css.match(/ruby\.wl-gloss\s*>\s*rt\s*\{([^}]*)\}/)?.[1] ?? ''; + + it('defaults the gloss to 0.5em and muted (opacity 0.7, no color override)', () => { + const block = rtBlock(getStyles(makeViewSettings(), theme)); + expect(block).toMatch(/font-size:\s*0\.5em/); + expect(block).toMatch(/opacity:\s*0\.7/); + expect(block).not.toContain('color:'); + }); + + it('applies a configured gloss font size', () => { + const block = rtBlock(getStyles(makeViewSettings({ wordLensGlossFontSize: 0.8 }), theme)); + expect(block).toMatch(/font-size:\s*0\.8em/); + }); + + it('applies a configured gloss color at full opacity', () => { + const block = rtBlock(getStyles(makeViewSettings({ wordLensGlossColor: '#ff0000' }), theme)); + expect(block).toMatch(/color:\s*#ff0000/); + expect(block).toMatch(/opacity:\s*1\b/); + }); +}); + // --------------------------------------------------------------------------- // getStyles integration: userStylesheet appended // --------------------------------------------------------------------------- diff --git a/apps/readest-app/src/__tests__/utils/time-format.test.ts b/apps/readest-app/src/__tests__/utils/time-format.test.ts new file mode 100644 index 000000000..3c6ec1552 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/time-format.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; + +import { formatCountdown, formatPlaybackTime } from '@/utils/time'; + +describe('formatPlaybackTime', () => { + test('formats minutes and seconds by default', () => { + expect(formatPlaybackTime(0)).toBe('0:00'); + expect(formatPlaybackTime(5)).toBe('0:05'); + expect(formatPlaybackTime(62)).toBe('1:02'); + expect(formatPlaybackTime(600)).toBe('10:00'); + }); + + test('formats hours when the value reaches one hour', () => { + expect(formatPlaybackTime(3600)).toBe('1:00:00'); + expect(formatPlaybackTime(3600 + 61)).toBe('1:01:01'); + }); + + test('forceHours pins both labels of a row to the same layout', () => { + // Both labels use the format chosen by the TOTAL's magnitude so the row + // never re-layouts when the elapsed time crosses an hour. + expect(formatPlaybackTime(62, true)).toBe('0:01:02'); + }); + + test('clamps negative and non-finite input to zero', () => { + expect(formatPlaybackTime(-5)).toBe('0:00'); + expect(formatPlaybackTime(Number.NaN)).toBe('0:00'); + expect(formatPlaybackTime(Number.POSITIVE_INFINITY)).toBe('0:00'); + }); + + test('truncates fractional seconds', () => { + expect(formatPlaybackTime(59.9)).toBe('0:59'); + }); +}); + +describe('formatCountdown', () => { + test('formats total minutes and seconds', () => { + expect(formatCountdown(90_000)).toBe('1:30'); + expect(formatCountdown(5_000)).toBe('0:05'); + // Total minutes, never rolled into hours: 90 minutes reads 90:00. + expect(formatCountdown(90 * 60_000)).toBe('90:00'); + }); + + test('clamps negative remaining time to zero', () => { + expect(formatCountdown(-1_000)).toBe('0:00'); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/transfer.test.ts b/apps/readest-app/src/__tests__/utils/transfer.test.ts index 75b8935c0..d025464be 100644 --- a/apps/readest-app/src/__tests__/utils/transfer.test.ts +++ b/apps/readest-app/src/__tests__/utils/transfer.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { webDownload, type ProgressPayload } from '@/utils/transfer'; +import { createProgressThrottle, webDownload, type ProgressPayload } from '@/utils/transfer'; const buildResponse = ( body: Uint8Array, @@ -92,3 +92,61 @@ describe('webDownload', () => { expect(await result.blob.arrayBuffer()).toEqual(bytes.buffer); }); }); + +describe('createProgressThrottle', () => { + const p = (progress: number, transferSpeed = progress): ProgressPayload => ({ + progress, + total: 100, + transferSpeed, + }); + + test('coalesces a burst to a leading and a single trailing emission (READEST-2)', () => { + vi.useFakeTimers(); + const emit = vi.fn(); + const throttle = createProgressThrottle(emit, 100); + + // A dense synchronous burst (as buffered stream chunks produce). + for (let i = 1; i <= 50; i++) throttle.push(p(i)); + + // Leading edge fires once with the first payload; the other 49 coalesce. + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenLastCalledWith(p(1)); + + // Trailing edge fires after the interval with the latest payload only. + vi.advanceTimersByTime(100); + expect(emit).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenLastCalledWith(p(50)); + + vi.useRealTimers(); + }); + + test('flush emits the pending payload immediately', () => { + vi.useFakeTimers(); + const emit = vi.fn(); + const throttle = createProgressThrottle(emit, 100); + throttle.push(p(1)); // leading fires + throttle.push(p(2)); // throttled -> pending + emit.mockClear(); + + throttle.flush(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenLastCalledWith(p(2)); + vi.useRealTimers(); + }); + + test('cancel drops the pending payload and its trailing timer', () => { + vi.useFakeTimers(); + const emit = vi.fn(); + const throttle = createProgressThrottle(emit, 100); + throttle.push(p(1)); // leading fires + throttle.push(p(2)); // throttled -> pending + emit.mockClear(); + + throttle.cancel(); + vi.advanceTimersByTime(500); + + expect(emit).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/transform-feed.test.ts b/apps/readest-app/src/__tests__/utils/transform-feed.test.ts new file mode 100644 index 000000000..45074ddc7 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/transform-feed.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { createFeedBook } from '@/services/rss/feedBook'; +import { transformBookToDB, transformBookFromDB } from '@/utils/transform'; +import { parseFeedBookUrl } from '@/services/rss/feedBookUrl'; + +describe('feed book sync round-trip', () => { + it('rehydrates the feed:// url from metadata even though DBBook drops url', () => { + const feedUrl = 'https://feeds.feedburner.com/ruanyifeng'; + const book = createFeedBook(feedUrl, { title: 'Blog', items: [] }); + const db = transformBookToDB(book, 'user-1'); + // DBBook carries no url column: + expect('url' in db).toBe(false); + const restored = transformBookFromDB(db); + expect(restored.hash).toBe(book.hash); + expect(restored.url).toBeTruthy(); + expect(parseFeedBookUrl(restored.url!)).toEqual({ feedUrl }); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/transform.test.ts b/apps/readest-app/src/__tests__/utils/transform.test.ts index dd2679adf..3f94c5a75 100644 --- a/apps/readest-app/src/__tests__/utils/transform.test.ts +++ b/apps/readest-app/src/__tests__/utils/transform.test.ts @@ -339,3 +339,58 @@ describe('transformBook readingStatus + readingStatusUpdatedAt', () => { expect(back.readingStatusUpdatedAt).toBeUndefined(); }); }); + +describe('transformBook coverHash + coverUpdatedAt (issue #4544)', () => { + const userId = 'user-1'; + const baseBook: Book = { + hash: 'h1', + format: 'EPUB', + title: 'T', + author: 'A', + createdAt: 1, + updatedAt: 2, + }; + + it('serializes coverHash verbatim and coverUpdatedAt to ISO', () => { + const ts = Date.UTC(2026, 5, 22, 12, 0, 0); + const db = transformBookToDB( + { ...baseBook, coverHash: 'abcdef0123456789', coverUpdatedAt: ts }, + userId, + ); + expect(db.cover_hash).toBe('abcdef0123456789'); + expect(db.cover_updated_at).toBe(new Date(ts).toISOString()); + }); + + it('leaves cover columns null when unset', () => { + const db = transformBookToDB(baseBook, userId); + expect(db.cover_hash).toBeNull(); + expect(db.cover_updated_at).toBeNull(); + }); + + it('round-trips coverHash + coverUpdatedAt back to the client shape', () => { + const ts = Date.UTC(2026, 5, 22, 12, 0, 0); + const db = transformBookToDB( + { ...baseBook, coverHash: 'deadbeef', coverUpdatedAt: ts }, + userId, + ); + const back = transformBookFromDB(db); + expect(back.coverHash).toBe('deadbeef'); + expect(back.coverUpdatedAt).toBe(ts); + }); + + it('reads null cover fields when the DB columns are null', () => { + const back = transformBookFromDB({ + user_id: userId, + book_hash: 'h1', + format: 'EPUB', + title: 'T', + author: 'A', + cover_hash: null, + cover_updated_at: null, + created_at: new Date(1).toISOString(), + updated_at: new Date(2).toISOString(), + }); + expect(back.coverHash).toBeNull(); + expect(back.coverUpdatedAt).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/viewTransition.test.ts b/apps/readest-app/src/__tests__/utils/viewTransition.test.ts new file mode 100644 index 000000000..befd1e516 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/viewTransition.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { detectViewTransitionGroup, detectViewTransitionsAPI } from '@/utils/viewTransition'; + +// The DOM lib types startViewTransition as always present; go through a loose +// shape so the stub can also remove it. +type VTDocument = { startViewTransition?: () => void }; + +const stubEngine = ({ + startViewTransition, + nestedGroups, +}: { + startViewTransition: boolean; + nestedGroups: boolean; +}) => { + const doc = document as unknown as VTDocument; + if (startViewTransition) doc.startViewTransition = () => {}; + else delete doc.startViewTransition; + vi.stubGlobal('CSS', { + supports: (property: string, value: string) => + nestedGroups && property === 'view-transition-group' && value === 'nearest', + }); +}; + +afterEach(() => { + vi.unstubAllGlobals(); + delete (document as unknown as VTDocument).startViewTransition; +}); + +describe('detectViewTransitionsAPI', () => { + it('is true on any engine with document.startViewTransition, even without nested groups', () => { + stubEngine({ startViewTransition: true, nestedGroups: false }); + expect(detectViewTransitionsAPI()).toBe(true); + }); + + it('is false without the View Transitions API', () => { + stubEngine({ startViewTransition: false, nestedGroups: true }); + expect(detectViewTransitionsAPI()).toBe(false); + }); +}); + +describe('detectViewTransitionGroup', () => { + it('requires nested view-transition groups on top of the API', () => { + stubEngine({ startViewTransition: true, nestedGroups: false }); + expect(detectViewTransitionGroup()).toBe(false); + }); + + it('is true only when the API and nested groups are both present', () => { + stubEngine({ startViewTransition: true, nestedGroups: true }); + expect(detectViewTransitionGroup()).toBe(true); + }); + + it('is false without the API even if the group query matches', () => { + stubEngine({ startViewTransition: false, nestedGroups: true }); + expect(detectViewTransitionGroup()).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/window.test.ts b/apps/readest-app/src/__tests__/utils/window.test.ts index bf148582e..65c0cce72 100644 --- a/apps/readest-app/src/__tests__/utils/window.test.ts +++ b/apps/readest-app/src/__tests__/utils/window.test.ts @@ -24,7 +24,7 @@ vi.mock('@/utils/event', () => ({ import { getCurrentWindow } from '@tauri-apps/api/window'; import { type as osType } from '@tauri-apps/plugin-os'; -import { tauriHandleOnCloseWindow } from '@/utils/window'; +import { tauriHandleOnCloseWindow, tauriHandleToggleFullScreen } from '@/utils/window'; type CloseHandler = (event: { preventDefault: () => void }) => Promise | void; @@ -113,3 +113,62 @@ describe('tauriHandleOnCloseWindow', () => { expect(win.destroy).toHaveBeenCalled(); }); }); + +function makeFullscreenWindow({ + isFullscreen, + isMaximized, +}: { + isFullscreen: boolean; + isMaximized: boolean; +}) { + return { + isFullscreen: vi.fn().mockResolvedValue(isFullscreen), + isMaximized: vi.fn().mockResolvedValue(isMaximized), + setFullscreen: vi.fn().mockResolvedValue(undefined), + unmaximize: vi.fn().mockResolvedValue(undefined), + toggleMaximize: vi.fn().mockResolvedValue(undefined), + innerSize: vi.fn().mockResolvedValue({ width: 800, height: 600 }), + setSize: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('tauriHandleToggleFullScreen', () => { + test('enters fullscreen when the window is maximized (Phosh / Windows-maximized case)', async () => { + // On Phosh the window is always maximized, and on Windows users often run + // maximized. The fullscreen button must still enter fullscreen instead of + // just unmaximizing the window (issue #4034). + vi.mocked(osType).mockReturnValue('linux'); + const win = makeFullscreenWindow({ isFullscreen: false, isMaximized: true }); + vi.mocked(getCurrentWindow).mockReturnValue( + win as unknown as ReturnType, + ); + + await tauriHandleToggleFullScreen(); + + expect(win.setFullscreen).toHaveBeenCalledWith(true); + }); + + test('exits fullscreen when already fullscreen', async () => { + vi.mocked(osType).mockReturnValue('windows'); + const win = makeFullscreenWindow({ isFullscreen: true, isMaximized: false }); + vi.mocked(getCurrentWindow).mockReturnValue( + win as unknown as ReturnType, + ); + + await tauriHandleToggleFullScreen(); + + expect(win.setFullscreen).toHaveBeenCalledWith(false); + }); + + test('enters fullscreen when neither maximized nor fullscreen', async () => { + vi.mocked(osType).mockReturnValue('macos'); + const win = makeFullscreenWindow({ isFullscreen: false, isMaximized: false }); + vi.mocked(getCurrentWindow).mockReturnValue( + win as unknown as ReturnType, + ); + + await tauriHandleToggleFullScreen(); + + expect(win.setFullscreen).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/workers/iap-reconcile.test.ts b/apps/readest-app/src/__tests__/workers/iap-reconcile.test.ts new file mode 100644 index 000000000..788d3184b --- /dev/null +++ b/apps/readest-app/src/__tests__/workers/iap-reconcile.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +// Unit tests for the dedicated reconcile cron Worker's pure logic. The Worker +// itself (workers/iap-reconcile) is a self-contained Cloudflare project; only +// its runtime-agnostic helpers are exercised here. +import { + buildReconcileDataPoint, + countSubscriptionDrift, +} from '../../../workers/iap-reconcile/src/reconcile'; + +const makeSupabase = (counts: Record) => + ({ + from: (table: string) => ({ + select: () => ({ + eq: () => ({ + lt: () => Promise.resolve({ count: counts[table] ?? 0, error: null }), + }), + }), + }), + }) as unknown as SupabaseClient; + +describe('countSubscriptionDrift', () => { + it('counts active-but-expired rows in both store tables', async () => { + const supabase = makeSupabase({ + apple_iap_subscriptions: 2, + google_iap_subscriptions: 1, + }); + + await expect(countSubscriptionDrift(supabase)).resolves.toEqual({ + appleDrift: 2, + googleDrift: 1, + }); + }); + + it('reports zero drift when the stores are clean', async () => { + await expect(countSubscriptionDrift(makeSupabase({}))).resolves.toEqual({ + appleDrift: 0, + googleDrift: 0, + }); + }); + + it('throws when a query fails', async () => { + const supabase = { + from: () => ({ + select: () => ({ + eq: () => ({ + lt: () => Promise.resolve({ count: null, error: { message: 'boom' } }), + }), + }), + }), + } as unknown as SupabaseClient; + + await expect(countSubscriptionDrift(supabase)).rejects.toThrow(/boom/); + }); +}); + +describe('buildReconcileDataPoint', () => { + it('flags drift and records counts as doubles', () => { + expect(buildReconcileDataPoint({ appleDrift: 2, googleDrift: 1, durationMs: 30 })).toEqual({ + indexes: ['reconcile'], + blobs: ['reconcile', 'drift'], + doubles: [2, 1, 30], + }); + }); + + it('flags ok when there is no drift', () => { + expect(buildReconcileDataPoint({ appleDrift: 0, googleDrift: 0, durationMs: 9 }).blobs).toEqual( + ['reconcile', 'ok'], + ); + }); +}); diff --git a/apps/readest-app/src/app/api/apple/notifications/route.ts b/apps/readest-app/src/app/api/apple/notifications/route.ts new file mode 100644 index 000000000..aeeaf6849 --- /dev/null +++ b/apps/readest-app/src/app/api/apple/notifications/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from 'next/server'; +import { handleAppleNotification } from '@/libs/payment/iap/apple/notifications'; +import { recordIapWebhook } from '@/libs/payment/iap/telemetry'; + +// App Store Server Notifications V2 endpoint. Configure this URL in App Store +// Connect (Production and Sandbox). The payload is signed by Apple and verified +// inside `handleAppleNotification`, so no separate authentication is needed. +export async function POST(request: Request) { + const startedAt = Date.now(); + + let signedPayload: unknown; + try { + const body = await request.json(); + signedPayload = body?.signedPayload; + } catch { + recordIapWebhook({ + provider: 'apple', + outcome: 'rejected', + reason: 'invalid_body', + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + if (typeof signedPayload !== 'string' || !signedPayload) { + recordIapWebhook({ + provider: 'apple', + outcome: 'rejected', + reason: 'missing_payload', + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ error: 'Missing signedPayload' }, { status: 400 }); + } + + try { + const result = await handleAppleNotification(signedPayload); + recordIapWebhook({ + provider: 'apple', + outcome: result.handled ? 'handled' : 'skipped', + notificationType: result.notificationType, + status: result.status, + reason: result.reason, + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ received: true, ...result }); + } catch (error) { + // Respond 500 so Apple retries transient failures (e.g. a database hiccup). + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('Apple notification error:', message); + recordIapWebhook({ + provider: 'apple', + outcome: 'error', + reason: message, + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/readest-app/src/app/api/google/notifications/route.ts b/apps/readest-app/src/app/api/google/notifications/route.ts new file mode 100644 index 000000000..1e00326f4 --- /dev/null +++ b/apps/readest-app/src/app/api/google/notifications/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server'; +import { handleGoogleNotification } from '@/libs/payment/iap/google/notifications'; +import { recordIapWebhook } from '@/libs/payment/iap/telemetry'; + +// Google Play Real-Time Developer Notifications (RTDN) endpoint, delivered via a +// Cloud Pub/Sub push subscription. The push URL must include the shared secret +// (`?token=...`) matching GOOGLE_RTDN_VERIFICATION_TOKEN; the notification state +// itself is re-verified against the Play Developer API in the handler. +export async function POST(request: Request) { + const startedAt = Date.now(); + + const expectedToken = process.env['GOOGLE_RTDN_VERIFICATION_TOKEN']; + if (expectedToken) { + const token = new URL(request.url).searchParams.get('token'); + if (token !== expectedToken) { + recordIapWebhook({ + provider: 'google', + outcome: 'rejected', + reason: 'invalid_token', + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ error: 'Invalid verification token' }, { status: 401 }); + } + } else { + console.warn('GOOGLE_RTDN_VERIFICATION_TOKEN is not set; skipping token verification'); + } + + let messageData: unknown; + try { + const body = await request.json(); + messageData = body?.message?.data; + } catch { + recordIapWebhook({ + provider: 'google', + outcome: 'rejected', + reason: 'invalid_body', + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + // Pub/Sub may deliver an empty message (e.g. during endpoint validation). + // Acknowledge it so it is not retried. + if (typeof messageData !== 'string' || !messageData) { + recordIapWebhook({ + provider: 'google', + outcome: 'skipped', + reason: 'empty_message', + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ received: true, handled: false, reason: 'empty_message' }); + } + + try { + const result = await handleGoogleNotification(messageData); + recordIapWebhook({ + provider: 'google', + outcome: result.handled ? 'handled' : 'skipped', + notificationType: result.notificationType, + status: result.status, + reason: result.reason, + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ received: true, ...result }); + } catch (error) { + // Respond 500 so Pub/Sub retries transient failures. + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('Google notification error:', message); + recordIapWebhook({ + provider: 'google', + outcome: 'error', + reason: message, + durationMs: Date.now() - startedAt, + }); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/readest-app/src/app/api/opds/proxy/route.ts b/apps/readest-app/src/app/api/opds/proxy/route.ts index fbd8394d7..89136a287 100644 --- a/apps/readest-app/src/app/api/opds/proxy/route.ts +++ b/apps/readest-app/src/app/api/opds/proxy/route.ts @@ -6,6 +6,11 @@ import { isBlockedHost } from '@/utils/network'; // Cap redirect hops so the SSRF host check below can re-run on every one. const MAX_REDIRECTS = 5; +// In `next dev` the server runs on the developer's own machine, where a LAN +// OPDS catalog is the normal use case (the CatalogManager UI only forbids +// adding LAN URLs in production builds), so the host blocklist is skipped. +const isPrivateHostAllowed = () => process.env.NODE_ENV === 'development'; + /** * Fetch the target while running the SSRF host check on every redirect hop. * `fetch`'s default `redirect: 'follow'` would let a public URL 302 to an @@ -25,7 +30,7 @@ async function fetchFollowingRedirects( if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new SsrfBlockedError('Only http(s) URLs are supported'); } - if (isBlockedHost(parsed.hostname)) { + if (!isPrivateHostAllowed() && isBlockedHost(parsed.hostname)) { throw new SsrfBlockedError('This URL is not allowed'); } const response = await fetch(currentUrl, { ...init, redirect: 'manual' }); @@ -79,7 +84,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') { if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { return NextResponse.json({ error: 'Only http(s) URLs are supported' }, { status: 400 }); } - if (isBlockedHost(parsedUrl.hostname)) { + if (!isPrivateHostAllowed() && isBlockedHost(parsedUrl.hostname)) { return NextResponse.json({ error: 'This URL is not allowed' }, { status: 400 }); } diff --git a/apps/readest-app/src/app/api/stripe/webhook/route.ts b/apps/readest-app/src/app/api/stripe/webhook/route.ts index 21aa4995c..787eba8ff 100644 --- a/apps/readest-app/src/app/api/stripe/webhook/route.ts +++ b/apps/readest-app/src/app/api/stripe/webhook/route.ts @@ -4,6 +4,7 @@ import { getStripe, createOrUpdateSubscription, createOrUpdatePayment, + getHighestActivePlan, } from '@/libs/payment/stripe/server'; import { createSupabaseAdminClient } from '@/utils/supabase'; @@ -190,16 +191,20 @@ async function handleSubscriptionCancelled(subscription: Stripe.Subscription) { const { data: subscriptionData } = await supabase .from('subscriptions') - .select('user_id') + .select('user_id, stripe_customer_id') .eq('stripe_subscription_id', subscriptionId) .single(); if (subscriptionData?.user_id) { + // The user may still hold other active subscriptions (e.g. cancelling the + // old Plus subscription after upgrading to Pro). Reflect the highest plan + // that remains active rather than always dropping to free. + const plan = await getHighestActivePlan(getStripe(), subscriptionData.stripe_customer_id); await supabase .from('plans') .update({ - plan: 'free', - status: 'cancelled', + plan, + status: plan === 'free' ? 'cancelled' : 'active', }) .eq('id', subscriptionData.user_id); } diff --git a/apps/readest-app/src/app/auth/page.tsx b/apps/readest-app/src/app/auth/page.tsx index 1a6e473c4..d18e5c1ed 100644 --- a/apps/readest-app/src/app/auth/page.tsx +++ b/apps/readest-app/src/app/auth/page.tsx @@ -22,7 +22,7 @@ import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth'; import { openUrl } from '@tauri-apps/plugin-opener'; import { invoke } from '@tauri-apps/api/core'; -import { handleAuthCallback } from '@/helpers/auth'; +import { handleAuthCallback, parseOAuthCallbackUrl } from '@/helpers/auth'; import { getUserProfilePlan } from '@/utils/access'; import { getAppleIdAuth, Scope } from './utils/appleIdAuth'; import { authWithCustomTab, authWithSafari } from './utils/nativeAuth'; @@ -163,20 +163,26 @@ export default function AuthPage() { const handleOAuthUrl = async (url: string) => { console.log('Handle OAuth URL:', url); - const hashMatch = url.match(/#(.*)/); - if (hashMatch) { - const hash = hashMatch[1]; - const params = new URLSearchParams(hash); - const accessToken = params.get('access_token'); - const refreshToken = params.get('refresh_token'); - const type = params.get('type'); - if (accessToken) { - let next = params.get('next') ?? '/'; - if (getUserProfilePlan(accessToken) === 'free') { - next = '/user'; - } - handleAuthCallback({ accessToken, refreshToken, type, next, login, navigate: router.push }); + const { accessToken, refreshToken, type, next, error, errorCode, errorDescription } = + parseOAuthCallbackUrl(url); + if (error) { + console.error('OAuth callback error:', error, errorCode, errorDescription); + handleAuthCallback({ error, errorCode, errorDescription, login, navigate: router.push }); + return; + } + if (accessToken) { + let nextPath = next ?? '/'; + if (getUserProfilePlan(accessToken) === 'free') { + nextPath = '/user'; } + handleAuthCallback({ + accessToken, + refreshToken, + type, + next: nextPath, + login, + navigate: router.push, + }); } }; diff --git a/apps/readest-app/src/app/auth/utils/nativeAuth.ts b/apps/readest-app/src/app/auth/utils/nativeAuth.ts index 3e3cb2181..77b1dba3e 100644 --- a/apps/readest-app/src/app/auth/utils/nativeAuth.ts +++ b/apps/readest-app/src/app/auth/utils/nativeAuth.ts @@ -4,6 +4,12 @@ import { type as osType } from '@tauri-apps/plugin-os'; export interface AuthRequest { authUrl: string; + /** + * iOS `ASWebAuthenticationSession` callback scheme. Defaults to `readest` + * natively (the Supabase login); the Google Drive flow passes its reverse-DNS + * scheme so the session intercepts that redirect instead. + */ + callbackScheme?: string; } export interface AuthResponse { diff --git a/apps/readest-app/src/app/gdrive-callback/page.tsx b/apps/readest-app/src/app/gdrive-callback/page.tsx new file mode 100644 index 000000000..80c267709 --- /dev/null +++ b/apps/readest-app/src/app/gdrive-callback/page.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; +import { eventDispatcher } from '@/utils/event'; +import { WebDriveAuth } from '@/services/sync/providers/gdrive/WebDriveAuth'; +import type { FetchFn } from '@/services/sync/providers/gdrive/GoogleDriveProvider'; +import { saveWebDriveToken } from '@/services/sync/providers/gdrive/auth/webTokenStore'; +import { + consumeOAuthState, + consumeReturnPath, + parseImplicitRedirect, + tokenSetFromRedirect, +} from '@/services/sync/providers/gdrive/auth/webRedirectFlow'; +import { persistActiveCloudProvider } from '@/components/settings/integrations/cloudSync'; + +/** + * OAuth return route for the web Google Drive connect (full-page implicit flow). + * Google redirects here with the access token in the URL fragment; we validate + * the CSRF state, store the token, mark Drive the active cloud provider, then + * route back to where the user started. See `gdrive/auth/webRedirectFlow.ts`. + */ +export default function GDriveCallback() { + const router = useRouter(); + const _ = useTranslation(); + const { envConfig } = useEnv(); + const setSettings = useSettingsStore((s) => s.setSettings); + + useEffect(() => { + let cancelled = false; + (async () => { + const returnPath = consumeReturnPath(); + const expectedState = consumeOAuthState(); + const result = parseImplicitRedirect(window.location.hash); + try { + if (result.error) throw new Error(result.error); + if (!expectedState || result.state !== expectedState) throw new Error('state mismatch'); + const tokens = tokenSetFromRedirect(result); + if (!tokens) throw new Error('no access token returned'); + + saveWebDriveToken(tokens); + + const accountLabel = await new WebDriveAuth( + globalThis.fetch.bind(globalThis) as unknown as FetchFn, + ) + .accountLabel() + .catch(() => null); + + // Mark Drive the single active cloud provider (turns WebDAV off) and + // stamp the account label. persistActiveCloudProvider loads via + // appService when the settings store isn't hydrated on this route, + // persists, hydrates the store, and broadcasts to other windows. + await persistActiveCloudProvider(envConfig, 'gdrive', (s) => ({ + ...s, + googleDrive: { + ...s.googleDrive, + accountLabel: accountLabel ?? s.googleDrive?.accountLabel, + }, + })); + eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') }); + } catch (e) { + console.warn('[gdrive] web callback failed', e); + eventDispatcher.dispatch('toast', { type: 'error', message: _('Failed to connect') }); + } finally { + if (!cancelled) router.replace(returnPath); + } + })(); + return () => { + cancelled = true; + }; + }, [envConfig, router, setSettings, _]); + + return ( +
+ +
+ ); +} diff --git a/apps/readest-app/src/app/library/components/BookItem.tsx b/apps/readest-app/src/app/library/components/BookItem.tsx index 536a65575..9c0e0ab5a 100644 --- a/apps/readest-app/src/app/library/components/BookItem.tsx +++ b/apps/readest-app/src/app/library/components/BookItem.tsx @@ -16,6 +16,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings'; import { navigateToLogin } from '@/utils/nav'; +import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider'; import { formatAuthors, formatDescription, formatSeries } from '@/utils/book'; import ReadingProgress from './ReadingProgress'; import BookCover from '@/components/BookCover'; @@ -73,7 +74,7 @@ const BookItem: React.FC = ({ className={clsx( 'book-item flex', mode === 'grid' && 'h-full flex-col justify-end', - mode === 'list' && 'h-28 flex-row gap-4 overflow-hidden', + mode === 'list' && 'min-h-28 flex-row gap-4 overflow-hidden', mode === 'list' ? 'library-list-item' : 'library-grid-item', appService?.hasContextMenu ? 'cursor-pointer' : '', )} @@ -199,9 +200,11 @@ const BookItem: React.FC = ({ } }} > - {!book.uploadedAt && settings.autoUpload && ( - - )} + {!book.uploadedAt && + settings.autoUpload && + isReadestCloudStorageActive(settings) && ( + + )} {book.uploadedAt && !book.downloadedAt && ( )} diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 33396bf65..7ab7b01c1 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -43,6 +43,7 @@ import { compareSortValues, resolveEffectivePrimarySort, resolveEffectiveSecondarySort, + selectRecentShelfBooks, withReadingStatus, } from '../utils/libraryUtils'; import { eventDispatcher } from '@/utils/event'; @@ -51,7 +52,7 @@ import { MIMETYPES, EXTS } from '@/libs/document'; import { makeSafeFilename } from '@/utils/misc'; import { useSpatialNavigation } from '../hooks/useSpatialNavigation'; -import Alert from '@/components/Alert'; +import DeleteConfirmAlert from '@/components/DeleteConfirmAlert'; import Spinner from '@/components/Spinner'; import ModalPortal from '@/components/ModalPortal'; import BookshelfItem, { generateBookshelfItems } from './BookshelfItem'; @@ -60,6 +61,8 @@ import ShareBookDialog from './ShareBookDialog'; import { useAuth } from '@/context/AuthContext'; import GroupingModal from './GroupingModal'; import SetStatusAlert from './SetStatusAlert'; +import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf'; +import { useOpenBook } from '../hooks/useOpenBook'; interface BookshelfProps { libraryBooks: Book[]; @@ -74,6 +77,7 @@ interface BookshelfProps { ) => Promise; handleBookUpload: (book: Book, syncBooks?: boolean) => Promise; handleBookDelete: (book: Book, syncBooks?: boolean) => Promise; + handleBookPurge: (book: Book, syncBooks?: boolean) => Promise; handleSetSelectMode: (selectMode: boolean) => void; handleShowDetailsBook: (book: Book) => void; handleLibraryNavigation: (targetGroup: string) => void; @@ -89,6 +93,13 @@ interface BookshelfProps { type BookshelfListContext = { autoColumns: boolean; fixedColumns: number; + /** + * The recently-read shelf, rendered in the Virtuoso header so it scrolls with + * the shelf content (not sticky). `null` when hidden. Passed through context + * (rather than recreating the Header component) so Virtuoso keeps the Header + * identity stable and does not reset its scroller on every Bookshelf render. + */ + recentShelfHeader: React.ReactNode; }; const BOOKSHELF_GRID_CLASSES = @@ -118,21 +129,28 @@ const BookshelfGridList: GridComponents['List'] = React.fo )); BookshelfGridList.displayName = 'BookshelfGridList'; -const BookshelfLinearList: Components['List'] = React.forwardRef( - ({ children, style, 'data-testid': testId }, ref) => ( -
- {children} -
- ), -); +const BookshelfLinearList: Components['List'] = React.forwardRef< + HTMLDivElement, + ListProps +>(({ children, style, 'data-testid': testId }, ref) => ( +
+ {children} +
+)); BookshelfLinearList.displayName = 'BookshelfLinearList'; +const BookshelfHeader = ({ context }: { context?: BookshelfListContext }) => ( + <>{context?.recentShelfHeader ?? null} +); + const GRID_VIRTUOSO_COMPONENTS: GridComponents = { List: BookshelfGridList, + Header: BookshelfHeader, Footer: () =>
, }; -const LIST_VIRTUOSO_COMPONENTS: Components = { +const LIST_VIRTUOSO_COMPONENTS: Components = { List: BookshelfLinearList, + Header: BookshelfHeader, Footer: () =>
, }; @@ -146,6 +164,7 @@ const Bookshelf: React.FC = ({ handleBookUpload, handleBookDownload, handleBookDelete, + handleBookPurge, handleSetSelectMode, handleShowDetailsBook, handleLibraryNavigation, @@ -390,8 +409,12 @@ const Bookshelf: React.FC = ({ return filteredBooks.filter((book) => wanted.has(book.hash) && !book.deletedAt); }; - const confirmDelete = async () => { + const confirmDelete = async (purgeData: boolean) => { const books = getBooksToDelete(); + // Toggling "purge all reading data" on the confirmation routes the whole + // batch through the purge path, which also wipes each book's reading-data + // sidecars (config/nav) instead of leaving the metadata folder behind. + const deleteBook = purgeData ? handleBookPurge : handleBookDelete; const concurrency = 20; for (let i = 0; i < books.length; i += concurrency) { @@ -400,7 +423,7 @@ const Bookshelf: React.FC = ({ break; } const batch = books.slice(i, i + concurrency); - await Promise.all(batch.map((book) => handleBookDelete(book, false))); + await Promise.all(batch.map((book) => deleteBook(book, false))); } handlePushLibrary(); setSelectedBooks([]); @@ -662,12 +685,63 @@ const Bookshelf: React.FC = ({ // last book; list mode doesn't have an import tile. const gridTotalCount = hasItems ? sortedBookshelfItems.length + 1 : 0; + // Recently-read shelf: shares the availability-aware open path with per-item + // taps so cloud-only synced books download before opening. `openBook` is + // memoized inside the hook, keeping `openRecentBook` -> `recentShelfHeader` + // -> `listContext` identities stable (no full-grid re-render churn). + const { openBook } = useOpenBook({ setLoading, handleBookDownload }); + const openRecentBook = useCallback((book: Book) => openBook(book), [openBook]); + + // Flat recency slice of the whole library, independent of the main shelf's + // sort/grouping. Built from `libraryBooks` (not the sorted/filtered items). + const recentBooks = useMemo( + () => selectRecentShelfBooks(libraryBooks, RECENT_SHELF_BOOK_COUNT), + [libraryBooks], + ); + + // A top-level quick-resume strip: hidden while searching, inside a group, + // selecting, or when nothing has been read yet. + const showRecentShelf = + settings.libraryRecentShelfEnabled && + !queryTerm && + !groupId && + !isSelectMode && + recentBooks.length > 0; + + const recentShelfHeader = useMemo( + () => + showRecentShelf ? ( + + ) : null, + [ + showRecentShelf, + recentBooks, + coverFit, + settings.libraryAutoColumns, + settings.libraryColumns, + openRecentBook, + handleBookUpload, + handleBookDownload, + handleShowDetailsBook, + ], + ); + const listContext = useMemo( () => ({ autoColumns: settings.libraryAutoColumns, fixedColumns: settings.libraryColumns, + recentShelfHeader, }), - [settings.libraryAutoColumns, settings.libraryColumns], + [settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader], ); const renderBookshelfItem = useCallback( @@ -780,10 +854,11 @@ const Bookshelf: React.FC = ({ /> )} {hasItems && !isGridMode && ( - overscan={200} totalCount={sortedBookshelfItems.length} components={LIST_VIRTUOSO_COMPONENTS} + context={listContext} computeItemKey={computeItemKey} itemContent={renderBookshelfItem} scrollerRef={handleScrollerRef} @@ -843,11 +918,12 @@ const Bookshelf: React.FC = ({ paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`, }} > - { abortDeletionRef.current = true; setShowDeleteAlert(false); diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 731640b92..1c7ea0a63 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -1,10 +1,8 @@ import clsx from 'clsx'; import { useCallback } from 'react'; import { useEnv } from '@/context/EnvContext'; -import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; -import { useAppRouter } from '@/hooks/useAppRouter'; import { useLongPress } from '@/hooks/useLongPress'; import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu'; import { revealItemInDir } from '@tauri-apps/plugin-opener'; @@ -13,7 +11,6 @@ import { openExternalUrl } from '@/utils/open'; import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads'; import { getOSPlatform } from '@/utils/misc'; import { throttle } from '@/utils/throttle'; -import { navigateToReader, showReaderWindow } from '@/utils/nav'; import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings'; import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os'; @@ -25,6 +22,7 @@ import { import { md5Fingerprint } from '@/utils/md5'; import BookItem from './BookItem'; import GroupItem from './GroupItem'; +import { useOpenBook } from '../hooks/useOpenBook'; export const generateBookshelfItems = ( books: Book[], @@ -127,80 +125,24 @@ const BookshelfItem: React.FC = ({ handleUpdateReadingStatus, }) => { const _ = useTranslation(); - const router = useAppRouter(); - const { envConfig, appService } = useEnv(); + const { appService } = useEnv(); const { settings } = useSettingsStore(); - const { updateBook } = useLibraryStore(); + const { openBook } = useOpenBook({ setLoading, handleBookDownload }); const showBookDetailsModal = useCallback(async (book: Book) => { handleShowDetailsBook(book); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const makeBookAvailable = async (book: Book) => { - if (book.uploadedAt && !book.downloadedAt) { - if (await appService?.isBookAvailable(book)) { - if (!book.downloadedAt || !book.coverDownloadedAt) { - book.downloadedAt = Date.now(); - book.coverDownloadedAt = Date.now(); - await updateBook(envConfig, book); - } - return true; - } - let available = false; - const loadingTimeout = setTimeout(() => setLoading(true), 200); - try { - available = await handleBookDownload(book, { queued: false }); - await updateBook(envConfig, book); - } finally { - if (loadingTimeout) clearTimeout(loadingTimeout); - setLoading(false); - } - return available; - } - return true; - }; - const handleBookClick = useCallback( async (book: Book) => { if (isSelectMode) { toggleSelection(book.hash); return; } - // In-place books point at a file outside Books// that the user - // (or another app) may have moved, renamed, or deleted between sessions. - // Probe the source before navigating: if it's gone, drop the stale - // library record instead of opening the reader only to fail inside - // loadBookContent and bounce back with a toast. We restrict this to - // purely-local in-place books — cloud-synced books (`uploadedAt`) still - // go through `makeBookAvailable`'s on-demand download path below, and - // hash-copy books (no `filePath`) shouldn't lose their Books// - // file under normal use, so we don't second-guess those here. - if (book.filePath && !book.uploadedAt && !book.deletedAt) { - const available = await appService?.isBookAvailable(book); - if (!available) { - eventDispatcher.dispatch('toast', { - message: _( - 'Book file no longer exists. Confirm deletion to remove it from the library.', - ), - type: 'info', - }); - eventDispatcher.dispatch('delete-books', { ids: [book.hash] }); - return; - } - } - const available = await makeBookAvailable(book); - if (!available) return; - if (appService?.hasWindow && settings.openBookInNewWindow) { - showReaderWindow(appService, [book.hash]); - } else { - setTimeout(() => { - navigateToReader(router, [book.hash]); - }, 0); - } + await openBook(book); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isSelectMode, settings.openBookInNewWindow, appService], + [isSelectMode, openBook, toggleSelection], ); const handleGroupClick = useCallback( diff --git a/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx b/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx index 55e036682..7e3c6827c 100644 --- a/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx +++ b/apps/readest-app/src/app/library/components/ImportFromFolderDialog.tsx @@ -62,13 +62,21 @@ export interface ImportFromFolderResult { * (`settings.externalLibraryFolders`) and import its books in place * — Readest will read each file straight from its original location * instead of copying it into Books//. Sidecars (cover, config, - * notes) still live in Readest's data dir, so deleting the local - * copy of a book on Readest's side will physically remove the - * source file under the registered folder. Defaults to `false`, - * which keeps the legacy "copy into Readest" behaviour and leaves - * the registered folder list untouched. + * notes) still live in Readest's data dir. Deleting such a book from + * Readest only removes those Readest-side sidecars; the original + * source file under the registered folder is left untouched. + * Defaults to `false`, which keeps the legacy "copy into Readest" + * behaviour and leaves the registered folder list untouched. */ readInPlace: boolean; + /** + * When `true`, keep this folder watched: on every library open and app + * focus, Readest re-scans it and imports any newly-added books. Recorded + * in `settings.autoImportFolders`. Only meaningful together with + * {@link readInPlace} (auto-import reads books in place), so it is forced + * `false` whenever `readInPlace` is off. Defaults to `false`. + */ + autoImport: boolean; } interface ImportFromFolderDialogProps { @@ -98,6 +106,13 @@ interface ImportFromFolderDialogProps { * the switch again. Defaults to `false`. */ initialReadInPlace?: boolean; + /** + * Initial value for the "Auto-import new books from this folder" checkbox. + * The caller seeds it from whether the shown folder is already in + * `settings.autoImportFolders`, so re-opening the dialog on a watched + * folder shows the box already ticked. Defaults to `false`. + */ + initialAutoImport?: boolean; /** * Predicate the dialog uses to decide whether the currently-displayed * folder is already registered as an external library folder. When @@ -139,6 +154,7 @@ const ImportFromFolderDialog: React.FC = ({ initialSelectedGroupIds, initialMinSizeKB, initialReadInPlace = false, + initialAutoImport = false, isRegisteredExternalRoot, onPickDirectory, onCancel, @@ -175,10 +191,16 @@ const ImportFromFolderDialog: React.FC = ({ // in-place by design (the importer's `shouldImportInPlace` check is // path-prefix based and ignores any per-import opt-out). const [readInPlace, setReadInPlace] = useState(initialReadInPlace); + // "Auto-import new books from this folder" — a sub-option of "Read in + // place". Auto-import scans the folder on every open/focus and reads its + // books in place, so it is only offered (and only takes effect) when the + // folder is read in place; `effectiveAutoImport` enforces that. + const [autoImport, setAutoImport] = useState(initialAutoImport); const [picking, setPicking] = useState(false); const readInPlaceLocked = !!directory && (isRegisteredExternalRoot?.(directory) ?? false); const effectiveReadInPlace = readInPlaceLocked || readInPlace; + const effectiveAutoImport = effectiveReadInPlace && autoImport; // Enter to confirm, Escape / Android Back to cancel. We must wire // `onCancel` even though also listens for Back, because @@ -241,6 +263,7 @@ const ImportFromFolderDialog: React.FC = ({ minSizeKB: safeMinSizeKB, flatten: folderMode === 'flatten', readInPlace: effectiveReadInPlace, + autoImport: effectiveAutoImport, }); }; @@ -379,6 +402,27 @@ const ImportFromFolderDialog: React.FC = ({ + {/* Auto-import sub-option. Only shown when the folder is read in + place — auto-import re-scans and reads books straight from the + folder, so it has no meaning for copied imports. */} + {effectiveReadInPlace && ( + + )}
{/* Folder-structure mode — radios let the user choose between @@ -434,7 +478,7 @@ const ImportFromFolderDialog: React.FC = ({ + +
+
+ ); +}; + +export default NowPlayingBar; diff --git a/apps/readest-app/src/app/library/components/RecentShelf.tsx b/apps/readest-app/src/app/library/components/RecentShelf.tsx new file mode 100644 index 000000000..d6267dbcc --- /dev/null +++ b/apps/readest-app/src/app/library/components/RecentShelf.tsx @@ -0,0 +1,238 @@ +import clsx from 'clsx'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { MdChevronLeft, MdChevronRight } from 'react-icons/md'; +import { Book } from '@/types/book'; +import { LibraryCoverFitType } from '@/types/settings'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useLongPress } from '@/hooks/useLongPress'; +import BookItem from './BookItem'; + +/** + * How many recently-read books the top shelf holds. Fixed (no user option) so + * the row stays a compact quick-resume strip rather than a second library. + */ +export const RECENT_SHELF_BOOK_COUNT = 12; + +interface RecentShelfProps { + books: Book[]; + coverFit: LibraryCoverFitType; + // Mirror the bookshelf grid's column model so covers are the same size. + autoColumns: boolean; + fixedColumns: number; + onOpenBook: (book: Book) => void; + handleBookUpload: (book: Book) => void; + handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void; + showBookDetailsModal: (book: Book) => void; +} + +/** + * Each slide is exactly one bookshelf-grid column wide. The width is the grid's + * own gap-aware formula — `(100% - (cols - 1) * gap) / cols` — so it matches a + * CSS-grid column for any column count or gap (flex `basis-1/N` does NOT, since + * it ignores the row gap). `cols`/`gap` come from CSS vars set on the row. + * `min-w-0` stops a flex item from growing to its cover image's intrinsic width. + */ +const RECENT_SLIDE_WIDTH = + 'calc((100% - (var(--rs-cols, 6) - 1) * var(--rs-gap, 0px)) / var(--rs-cols, 6))'; + +type RecentSlideProps = Pick< + RecentShelfProps, + 'coverFit' | 'onOpenBook' | 'handleBookUpload' | 'handleBookDownload' | 'showBookDetailsModal' +> & { book: Book }; + +const RecentSlide: React.FC = ({ + book, + coverFit, + onOpenBook, + handleBookUpload, + handleBookDownload, + showBookDetailsModal, +}) => { + // Pointer-based tap, exactly like the grid (`BookItem` stops click + // propagation). A swipe-to-scroll moves past useLongPress's moveThreshold and + // cancels the tap, so horizontal scrolling never opens a book. + const { pressing, handlers } = useLongPress({ onTap: () => onOpenBook(book) }, [ + book, + onOpenBook, + ]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpenBook(book); + } + }; + + return ( +
+ {/* Same chassis as the grid item (BookshelfItem grid branch) so the cover, + title, progress and badges render identically. */} +
+
+ +
+
+
+ ); +}; + +/** + * Recently-read shelf at the top of the library: a flat, recency-ordered strip + * of covers, independent of the main shelf's sort/grouping. It scrolls only + * horizontally and shares the grid's column widths, gap and insets, so each + * cover lines up with the shelf below and renders identically (reuses + * `BookItem`) at any column count. + */ +const RecentShelf: React.FC = ({ + books, + coverFit, + autoColumns, + fixedColumns, + onOpenBook, + handleBookUpload, + handleBookDownload, + showBookDetailsModal, +}) => { + const _ = useTranslation(); + // `--rs-cols` mirrors the grid's column count: the responsive ladder + // (BOOKSHELF_GRID_CLASSES) when auto, or the fixed setting otherwise. + // `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula + // subtracts the right gap at each breakpoint. + const colsClass = autoColumns + ? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]' + : ''; + const colsStyle = autoColumns + ? undefined + : ({ '--rs-cols': fixedColumns } as React.CSSProperties); + + const scrollerRef = useRef(null); + const [showLeft, setShowLeft] = useState(false); + const [showRight, setShowRight] = useState(false); + // Vertical center of the cover artwork (px from the scroller top). The slide + // carries a title below the cover, so centering the arrows on the artwork + // keeps them visually balanced. Null falls back to the row's mid-height. + const [coverCenter, setCoverCenter] = useState(null); + + // Cheap, runs on every scroll: which edges have more content to reveal. + const updateArrows = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + setShowLeft(el.scrollLeft > 1); + setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }, []); + + // Heavier: also re-measures the cover center. Runs on mount/resize, not scroll. + const measure = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + updateArrows(); + const cover = el.querySelector('.bookitem-main'); + if (cover) { + const rect = cover.getBoundingClientRect(); + setCoverCenter(rect.top - el.getBoundingClientRect().top + rect.height / 2); + } + }, [updateArrows]); + + useEffect(() => { + measure(); + const el = scrollerRef.current; + if (!el) return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [measure, books, autoColumns, fixedColumns, coverFit]); + + const scrollByPage = (direction: -1 | 1) => { + const el = scrollerRef.current; + if (!el) return; + el.scrollBy({ left: direction * el.clientWidth * 0.8, behavior: 'smooth' }); + }; + + return ( +
+

+ {_('Recently read')} +

+
+ {/* Horizontal-only scroll; px insets + gap mirror the grid. */} +
+
+ {books.map((book) => ( + + ))} +
+
+ {showLeft && ( + + )} + {showRight && ( + + )} +
+ {/* Modern divider: an inset hairline with breathing room above and below + so it does not crowd the first shelf row. */} + + ); +}; + +export default RecentShelf; diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index f81846cb7..d5d465ace 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -6,7 +6,6 @@ import { PiSun, PiMoon } from 'react-icons/pi'; import { TbSunMoon } from 'react-icons/tb'; import { MdCloudSync, MdSync, MdSyncProblem } from 'react-icons/md'; -import { invoke, PermissionState } from '@tauri-apps/api/core'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; import { DOWNLOAD_READEST_URL } from '@/services/constants'; import { setBackupDialogVisible } from '@/app/library/components/BackupWindow'; @@ -15,6 +14,12 @@ import { useAuth } from '@/context/AuthContext'; import { useEnv } from '@/context/EnvContext'; import { useThemeStore } from '@/store/themeStore'; import { useQuotaStats } from '@/hooks/useQuotaStats'; +import { useFileSyncStore } from '@/store/fileSyncStore'; +import { + getCloudSyncProvider, + cloudProviderDisplayName, + settingsKeyForBackend, +} from '@/services/sync/cloudSyncProvider'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; @@ -44,11 +49,6 @@ interface SettingsMenuProps { setIsDropdownOpen?: (isOpen: boolean) => void; } -interface Permissions { - postNotification: PermissionState; - manageStorage: PermissionState; -} - const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdownOpen }) => { const _ = useTranslation(); const router = useRouter(); @@ -64,7 +64,6 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState( settings.autoImportBooksOnOpen, ); - const [alwaysInForeground, setAlwaysInForeground] = useState(settings.alwaysInForeground); const [savedBookCoverForLockScreen, setSavedBookCoverForLockScreen] = useState( settings.savedBookCoverForLockScreen || '', ); @@ -100,6 +99,8 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow setIsDropdownOpen?.(false); }; const { isSyncing, setLibrary } = useLibraryStore(); + const fileSyncByKind = useFileSyncStore((s) => s.byKind); + const fileSyncLastError = useFileSyncStore((s) => s.lastErrorByKind); const { stats, hasActiveTransfers, setIsTransferQueueOpen } = useTransferQueue(); const openTransferQueue = () => { @@ -258,23 +259,6 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow setSavedBookCoverForLockScreen(newValue); }; - const toggleAlwaysInForeground = async () => { - const requestAlwaysInForeground = !settings.alwaysInForeground; - - if (requestAlwaysInForeground) { - let permission = await invoke('plugin:native-tts|checkPermissions'); - if (permission.postNotification !== 'granted') { - permission = await invoke('plugin:native-tts|requestPermissions', { - permissions: ['postNotification'], - }); - } - if (permission.postNotification !== 'granted') return; - } - - saveSysSettings(envConfig, 'alwaysInForeground', requestAlwaysInForeground); - setAlwaysInForeground(requestAlwaysInForeground); - }; - const handleSyncLibrary = () => { onPullLibrary(true, true); setIsDropdownOpen?.(false); @@ -294,11 +278,35 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow const coverDir = savedBookCoverPath ? savedBookCoverPath.split('/').pop() : 'Images'; const savedBookCoverDescription = `💾 ${coverDir}/last-book-cover.png`; - const lastSyncTime = Math.max( - settings.lastSyncedAtBooks || 0, - settings.lastSyncedAtConfigs || 0, - settings.lastSyncedAtNotes || 0, - ); + // While a third-party provider is selected the native cursors freeze (the + // book/progress/note channels are gated), so the sync row must report the + // file engine's health instead — otherwise it reads "Synced 3 months ago" + // forever and looks broken. + const cloudProvider = getCloudSyncProvider(settings); + const cloudProviderName = cloudProviderDisplayName(cloudProvider); + const providerSyncing = cloudProvider !== 'readest' && !!fileSyncByKind[cloudProvider]?.isSyncing; + const providerLastError = + cloudProvider !== 'readest' ? fileSyncLastError[cloudProvider] : undefined; + const lastSyncTime = + cloudProvider !== 'readest' + ? settings[settingsKeyForBackend(cloudProvider)]?.lastSyncedAt || 0 + : Math.max( + settings.lastSyncedAtBooks || 0, + settings.lastSyncedAtConfigs || 0, + settings.lastSyncedAtNotes || 0, + ); + const syncRowLabel = + cloudProvider !== 'readest' + ? providerLastError + ? _('Sync failed') + : lastSyncTime + ? _('Synced {{time}}', { + time: dayjs(lastSyncTime).fromNow(), + }) + : _('Never synced') + : lastSyncTime + ? _('Synced {{time}}', { time: dayjs(lastSyncTime).fromNow() }) + : _('Never synced'); return ( = ({ onPullLibrary, setIsDropdow onClick={openTransferQueue} /> - + {cloudProvider === 'readest' ? ( + + ) : null} @@ -370,13 +381,15 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow )} - + {cloudProvider === 'readest' && ( + + )} - {isTauriAppPlatform() && !appService?.isMobile && ( + {isTauriAppPlatform() && ( = ({ onPullLibrary, setIsDropdow onClick={toggleAlwaysShowStatusBar} /> )} - {appService?.isAndroidApp && ( - - )} { if (bytes === 0) return '0 B'; @@ -109,7 +116,10 @@ const TransferItemRow: React.FC<{ {transfer.error || _('Failed')} )} {transfer.status === 'completed' && (completedLabel[transfer.type] || _('Completed'))} - {transfer.status === 'cancelled' && _('Cancelled')} + {transfer.status === 'cancelled' && + (transfer.cancelReason === 'policy' + ? `${_('Cancelled')} · ${_('Cloud provider switched')}` + : _('Cancelled'))} {' · '} {formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
@@ -125,7 +135,7 @@ const TransferItemRow: React.FC<{
- {(transfer.status === 'failed' || transfer.status === 'cancelled') && ( + {isFailedLikeTransfer(transfer) && ( + +
+
+ + ); +}; + +export default AddFeedModal; diff --git a/apps/readest-app/src/app/library/components/feeds/FeedsView.tsx b/apps/readest-app/src/app/library/components/feeds/FeedsView.tsx new file mode 100644 index 000000000..7333a5b31 --- /dev/null +++ b/apps/readest-app/src/app/library/components/feeds/FeedsView.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { MdAdd, MdDelete, MdRefresh, MdArrowBack } from 'react-icons/md'; +import Dialog from '@/components/Dialog'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useEnv } from '@/context/EnvContext'; +import { useAuth } from '@/context/AuthContext'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useFeedStore } from '@/store/feedStore'; +import { openFeedArticle, handleOpenArticle } from '@/services/rss/articleIngest'; +import { navigateToReader } from '@/utils/nav'; +import { eventDispatcher } from '@/utils/event'; +import { formatDate } from '@/utils/book'; +import type { RssFeed, RssFeedItem } from '@/types/rss'; +import AddFeedModal from './AddFeedModal'; + +interface FeedsViewProps { + onClose: () => void; +} + +export function FeedsView({ onClose }: FeedsViewProps) { + const _ = useTranslation(); + const router = useRouter(); + const { envConfig, appService } = useEnv(); + const { user } = useAuth(); + const { settings } = useSettingsStore(); + const feeds = useFeedStore((s) => s.feeds); + const [selectedFeed, setSelectedFeed] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [refreshing, setRefreshing] = useState(null); + + const hydratedRef = useRef(false); + + useEffect(() => { + if (!appService) return; + appService + .loadFeeds() + .then((loaded) => { + useFeedStore.getState().hydrate(loaded); + hydratedRef.current = true; + }) + .catch(() => { + hydratedRef.current = true; + }); + }, [appService]); + + useEffect(() => { + if (!appService || !hydratedRef.current) return; + appService.saveFeeds(feeds).catch(() => {}); + }, [appService, feeds]); + + const handleRefresh = async (feed: RssFeed) => { + setRefreshing(feed.id); + try { + await useFeedStore.getState().refreshFeed(feed.id); + } finally { + setRefreshing(null); + } + }; + + const handleRemove = (id: string) => { + useFeedStore.getState().removeFeed(id); + if (selectedFeed?.id === id) setSelectedFeed(null); + }; + + const handleOpenItem = async (item: RssFeedItem, feed: RssFeed) => { + if (!appService) return; + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('Opening article…'), + timeout: 2500, + }); + await handleOpenArticle( + { + item, + feed, + books: useLibraryStore.getState().library, + appService, + settings, + isLoggedIn: !!user, + translate: _, + }, + { + openArticle: openFeedArticle, + updateBooks: (books) => useLibraryStore.getState().updateBooks(envConfig, books), + markRead: () => useFeedStore.getState().markItemRead(feed.id, item.id), + navigate: (hash) => navigateToReader(router, [hash]), + onError: (message) => + eventDispatcher.dispatch('toast', { type: 'error', message, timeout: 3500 }), + }, + ); + }; + + // Keep selectedFeed in sync when the store updates (refresh may add items). + const liveFeed = selectedFeed ? (feeds.find((f) => f.id === selectedFeed.id) ?? null) : null; + + const sortedItems = liveFeed + ? [...liveFeed.items].sort((a, b) => { + if (a.read !== b.read) return a.read ? 1 : -1; + const da = a.publishedAt ? new Date(a.publishedAt).getTime() : 0; + const db = b.publishedAt ? new Date(b.publishedAt).getTime() : 0; + return db - da; + }) + : []; + + return ( + <> + +
+ {liveFeed ? ( + /* Article list */ +
+ + {sortedItems.length === 0 ? ( +

+ {_('No articles in this feed.')} +

+ ) : ( +
    + {sortedItems.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ) : ( + /* Feed list */ +
+
+ + {feeds.length === 0 ? _('No feeds yet.') : ''} + + +
+ {feeds.length === 0 ? ( +

+ {_('Subscribe to a feed to see articles here.')} +

+ ) : ( +
    + {feeds.map((feed) => { + const unread = useFeedStore.getState().unreadCount(feed.id); + return ( +
  • +
    + +
    + + +
    +
    + {feed.errorMessage && ( +

    {feed.errorMessage}

    + )} +
  • + ); + })} +
+ )} +
+ )} +
+
+ setShowAddModal(false)} /> + + ); +} diff --git a/apps/readest-app/src/app/library/hooks/useAutoImportFolders.ts b/apps/readest-app/src/app/library/hooks/useAutoImportFolders.ts new file mode 100644 index 000000000..50e635f76 --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useAutoImportFolders.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useWindowActiveChanged } from '@/app/reader/hooks/useWindowActiveChanged'; +import { debounce } from '@/utils/debounce'; + +/** Quiet window collapsing the near-simultaneous mount + focus/visibility triggers. */ +const AUTO_IMPORT_DEBOUNCE_MS = 800; + +export interface UseAutoImportFoldersOptions { + /** + * Master gate. When false the hook never scans. Compose it in the caller + * from the setting toggle, folder count, library-loaded, and platform checks. + */ + enabled: boolean; + /** Absolute paths to re-scan (settings.externalLibraryFolders). */ + folders: string[]; + /** Scans the folders and imports any new books. Supplied by the library page. */ + scanAndImport: (folders: string[]) => Promise; +} + +/** + * Re-scans the user's registered external library folders and imports newly + * added books — on mount and whenever the app regains focus (desktop) or + * becomes visible again (mobile). Local-folder counterpart of + * {@link useLibraryFileSync}. Mount once on the library page. This hook only + * decides *when* to run; the scan/dedup/import lives in `scanAndImport`. + */ +export const useAutoImportFolders = ({ + enabled, + folders, + scanAndImport, +}: UseAutoImportFoldersOptions) => { + // Read the latest values at fire time so a toggle-off or folder-list change + // between schedule and fire is honoured without rebuilding the debounced fn. + const enabledRef = useRef(enabled); + const foldersRef = useRef(folders); + const scanRef = useRef(scanAndImport); + const runningRef = useRef(false); + useEffect(() => { + enabledRef.current = enabled; + foldersRef.current = folders; + scanRef.current = scanAndImport; + }); + + const run = useCallback(async () => { + if (!enabledRef.current || runningRef.current) return; + const targets = foldersRef.current; + if (targets.length === 0) return; + runningRef.current = true; + try { + await scanRef.current(targets); + } catch (e) { + console.error('Auto-import folders: scan failed', e); + } finally { + runningRef.current = false; + } + }, []); + + const debouncedRun = useMemo(() => debounce(() => void run(), AUTO_IMPORT_DEBOUNCE_MS), [run]); + + // Fire on mount and whenever the gate opens or the folder set changes. + const foldersKey = folders.join('\n'); + useEffect(() => { + if (enabled && folders.length > 0) debouncedRun(); + return () => debouncedRun.cancel(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, foldersKey, debouncedRun]); + + // Fire when the app/window regains focus (desktop) or becomes visible (mobile). + useWindowActiveChanged((isActive) => { + if (isActive) debouncedRun(); + }); +}; diff --git a/apps/readest-app/src/app/library/hooks/useBooksSync.ts b/apps/readest-app/src/app/library/hooks/useBooksSync.ts index 7fce895d9..32766da73 100644 --- a/apps/readest-app/src/app/library/hooks/useBooksSync.ts +++ b/apps/readest-app/src/app/library/hooks/useBooksSync.ts @@ -9,15 +9,25 @@ import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants'; import { throttle } from '@/utils/throttle'; import { debounce } from '@/utils/debounce'; import { eventDispatcher } from '@/utils/event'; -import { pickFresherReadingStatus } from '@/app/library/utils/libraryUtils'; +import { useSettingsStore } from '@/store/settingsStore'; +import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider'; +import { runActiveFileLibrarySync } from '@/services/sync/file/runLibrarySync'; +import { checkMixedFleetOnce } from '@/services/sync/fleetDetection'; +import { useSyncContext } from '@/context/SyncContext'; +import { + pickFresherReadingStatus, + needsCoverRefresh, + pickFresherCover, +} from '@/app/library/utils/libraryUtils'; export const useBooksSync = () => { const _ = useTranslation(); const { user } = useAuth(); - const { appService } = useEnv(); + const { envConfig, appService } = useEnv(); const { library, isSyncing, libraryLoaded } = useLibraryStore(); const { setLibrary, setIsSyncing, setSyncProgress } = useLibraryStore(); const { useSyncInited, syncedBooks, syncBooks, lastSyncedAtBooks } = useSync(); + const { syncClient } = useSyncContext(); const isPullingRef = useRef(false); const getNewBooks = useCallback(() => { @@ -49,6 +59,33 @@ export const useBooksSync = () => { const pullLibrary = useCallback( async (fullRefresh = false, verbose = false) => { + // While a third-party provider is selected, the native book channel + // is gated (syncBooks would return undefined and the toast would read + // "undefined book(s) synced"); every library-refresh surface — pull to + // refresh, the SettingsMenu sync row, BackupWindow — routes through + // here, so route them all to the file engine instead. Works logged out + // (file sync has no Readest account dependency). + const provider = getCloudSyncProvider(useSettingsStore.getState().settings); + if (provider !== 'readest') { + if (isPullingRef.current) return; + try { + isPullingRef.current = true; + const result = await runActiveFileLibrarySync(envConfig, _); + if (verbose) { + // Same message as the native Readest Cloud sync below, so every + // provider reports its work the same way. + eventDispatcher.dispatch('toast', { + type: result ? 'info' : 'error', + message: result + ? _('{{count}} book(s) synced', { count: result.booksSynced }) + : _('Sync failed'), + }); + } + } finally { + isPullingRef.current = false; + } + return; + } if (!user) return; if (isPullingRef.current) return; try { @@ -66,7 +103,7 @@ export const useBooksSync = () => { isPullingRef.current = false; } }, - [_, user, libraryLoaded, syncBooks], + [_, user, libraryLoaded, syncBooks, envConfig], ); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -74,6 +111,15 @@ export const useBooksSync = () => { throttle( async () => { if (isPullingRef.current) return; + // Third-party provider selected: the native book channel is gated, + // so the interval runs the read-only mixed-fleet probe instead — + // a device still writing natively would otherwise fork progress + // silently (the auto library sync itself is useLibraryFileSync's). + const settingsNow = useSettingsStore.getState().settings; + if (getCloudSyncProvider(settingsNow) !== 'readest') { + void checkMixedFleetOnce(syncClient, settingsNow, _); + return; + } const newBooks = getNewBooks(); if (!newBooks.lastSyncedAt) return; isPullingRef.current = true; @@ -114,16 +160,23 @@ export const useBooksSync = () => { // Process old books first so that when we update the library the order is preserved syncedBooks.sort((a, b) => a.updatedAt - b.updatedAt); const bookHashesInSynced = new Set(syncedBooks.map((book) => book.hash)); + const syncedByHash = new Map(syncedBooks.map((book) => [book.hash, book])); const liveLibrary = useLibraryStore.getState().library; const oldBooks = liveLibrary.filter((book) => bookHashesInSynced.has(book.hash)); + // Books whose cover must be (re)fetched: never-downloaded, or a newer cover + // edit arrived from another device (issue #4544). Captured before the + // download loop so the post-download merge can still tell which covers were + // refreshed (the loop mutates coverDownloadedAt). const oldBooksNeedsDownload = oldBooks.filter((book) => { - return !book.deletedAt && book.uploadedAt && !book.coverDownloadedAt; + const matchingBook = syncedByHash.get(book.hash); + return !!matchingBook && needsCoverRefresh(book, matchingBook); }); + const coverRefreshHashes = new Set(oldBooksNeedsDownload.map((book) => book.hash)); const processOldBook = async (oldBook: Book) => { - const matchingBook = syncedBooks.find((newBook) => newBook.hash === oldBook.hash); + const matchingBook = syncedByHash.get(oldBook.hash); if (matchingBook) { - if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.coverDownloadedAt) { + if (coverRefreshHashes.has(oldBook.hash)) { oldBook.coverImageUrl = await appService?.generateCoverImageUrl(oldBook); } const mergedBook = @@ -135,6 +188,11 @@ export const useBooksSync = () => { const status = pickFresherReadingStatus(oldBook, matchingBook); mergedBook.readingStatus = status.readingStatus; mergedBook.readingStatusUpdatedAt = status.readingStatusUpdatedAt; + // Cover is likewise resolved by its own coverUpdatedAt, independent of + // the row's updatedAt — issue #4544. + const cover = pickFresherCover(oldBook, matchingBook); + mergedBook.coverHash = cover.coverHash; + mergedBook.coverUpdatedAt = cover.coverUpdatedAt; return mergedBook; } return oldBook; diff --git a/apps/readest-app/src/app/library/hooks/useLibraryFileSync.ts b/apps/readest-app/src/app/library/hooks/useLibraryFileSync.ts new file mode 100644 index 000000000..ed80295ab --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useLibraryFileSync.ts @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useQuotaStats } from '@/hooks/useQuotaStats'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useFileSyncStore } from '@/store/fileSyncStore'; +import { isCloudSyncAllowed } from '@/utils/access'; +import { isWebAppPlatform } from '@/services/environment'; +import { hasValidWebDriveToken } from '@/services/sync/providers/gdrive/auth/webTokenStore'; +import { debounce } from '@/utils/debounce'; +import { FileSyncEngine } from '@/services/sync/file/engine'; +import { createAppLocalStore } from '@/services/sync/file/appLocalStore'; +import { + createFileSyncProvider, + type FileSyncBackendKind, +} from '@/services/sync/file/providerRegistry'; +import { getCloudSyncProvider, settingsKeyForBackend } from '@/services/sync/cloudSyncProvider'; + +/** + * Library-scoped auto-sync for the active third-party cloud provider (WebDAV / + * Google Drive) — the parity counterpart of {@link useBooksSync} (native cloud). + * + * The reader's per-book `useFileSync` keeps a book's progress/notes in sync while + * reading, but never touches the shared `library.json` index. This hook fills + * that gap: it runs the active provider's `engine.syncLibrary` whenever the + * library changes — importing, deleting, or closing a book all mutate the + * library array — so `library.json` (book metadata + tombstones) stays current + * across devices, exactly as native sync does. Mount once on the library page. + * + * Behaviour: + * - Debounced to collapse import bursts (and the engine's own + * pull-merge writes, which re-fire this effect — debounce + the engine's + * incremental cursor make repeats converge to a no-op). + * - Gated on the global file-sync mutex so it never collides with a manual + * "Sync now" or another backend reconciling the same local library. + * - Honours the provider's Sync Strategy (send / receive / silent) and the + * "Upload Book Files" toggle, same as the manual sync. + * - Passes the FULL library, including soft-deleted books, so deletions + * propagate as tombstones in `library.json` and deleted books are not + * re-discovered and re-downloaded. + */ + +/** Quiet window before an auto library sync fires; collapses import bursts. */ +const SYNC_DEBOUNCE_MS = 5_000; + +export const useLibraryFileSync = () => { + const _ = useTranslation(); + const { envConfig, appService } = useEnv(); + const { setSettings, saveSettings } = useSettingsStore(); + const settings = useSettingsStore((s) => s.settings); + const library = useLibraryStore((s) => s.library); + const libraryLoaded = useLibraryStore((s) => s.libraryLoaded); + const { userProfilePlan } = useQuotaStats(); + + // The single active cloud provider (WebDAV and Google Drive are exclusive). + const provider = getCloudSyncProvider(settings); + const activeKind: FileSyncBackendKind | null = provider === 'readest' ? null : provider; + + const isAllowed = isCloudSyncAllowed(userProfilePlan ?? 'free'); + const isReady = useMemo(() => { + if (!isAllowed) return false; + if (activeKind === 'webdav') { + const w = settings.webdav; + return !!(w?.enabled && w?.serverUrl && w?.username); + } + if (activeKind === 'gdrive') { + // Web Drive tokens are session-scoped with no refresh; once expired, + // every run would abort with AUTH_FAILED on the index pull. Skip the + // auto-sync until the user reconnects (the Drive settings form shows + // the Reconnect CTA), mirroring its disabled "Sync now". + if (isWebAppPlatform() && !hasValidWebDriveToken()) return false; + return !!settings.googleDrive?.enabled; + } + if (activeKind === 'onedrive') return !!settings.onedrive?.enabled; + return false; + }, [isAllowed, activeKind, settings.webdav, settings.googleDrive, settings.onedrive]); + + // Build the engine async (Drive probes the OS keychain). Keyed on the + // connection-relevant settings so an unrelated write (e.g. lastSyncedAt) + // doesn't rebuild it — which for Drive would re-probe the keychain. + const engineKey = useMemo(() => { + if (activeKind === 'webdav') { + const w = settings.webdav; + return `webdav:${w?.enabled}:${w?.serverUrl}:${w?.username}:${w?.password}:${w?.rootPath}`; + } + if (activeKind === 'gdrive') return `gdrive:${settings.googleDrive?.enabled}`; + if (activeKind === 'onedrive') return `onedrive:${settings.onedrive?.enabled}`; + return 'none'; + }, [activeKind, settings.webdav, settings.googleDrive, settings.onedrive]); + + const [engine, setEngine] = useState(null); + useEffect(() => { + let cancelled = false; + setEngine(null); + if (!isReady || !appService || activeKind === null) return; + (async () => { + const current = useSettingsStore.getState().settings; + const provider = await createFileSyncProvider(activeKind, current); + if (cancelled || !provider) return; + const store = createAppLocalStore({ appService, settings: current, envConfig }); + setEngine(new FileSyncEngine(provider, store)); + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [engineKey, isReady, activeKind, appService, envConfig]); + + const ensureDeviceId = useCallback((): string => { + const latest = useSettingsStore.getState().settings; + const key = activeKind ? settingsKeyForBackend(activeKind) : 'webdav'; + let id = latest[key]?.deviceId; + if (!id) { + id = uuidv4(); + const next = { ...latest, [key]: { ...latest[key], deviceId: id } }; + setSettings(next); + saveSettings(envConfig, next); + } + return id; + }, [activeKind, envConfig, setSettings, saveSettings]); + + const updateLastSyncedAt = useCallback( + async (ts: number) => { + const latest = useSettingsStore.getState().settings; + const key = activeKind ? settingsKeyForBackend(activeKind) : 'webdav'; + const next = { ...latest, [key]: { ...latest[key], lastSyncedAt: ts } }; + setSettings(next); + await saveSettings(envConfig, next); + }, + [activeKind, envConfig, setSettings, saveSettings], + ); + + const runSync = useCallback(async () => { + if (!engine || activeKind === null || !isReady) return; + // NEVER sync before the library is loaded from disk: syncing a transient + // empty library could push an empty index and clobber the remote. + if (!useLibraryStore.getState().libraryLoaded) return; + + const kind = activeKind; + const latest = useSettingsStore.getState().settings; + const ps = latest[settingsKeyForBackend(kind)]; + const strategy = ps?.strategy ?? 'silent'; + + const syncStore = useFileSyncStore.getState(); + // Honour the global library-sync mutex: a manual "Sync now" (or another + // backend) already reconciling the same local library wins; this auto-run + // skips and the next library change re-triggers it. + if (!syncStore.beginSync(kind, _('Syncing…'))) return; + try { + const books = useLibraryStore.getState().library; + const deviceId = ensureDeviceId(); + await engine.syncLibrary(books, { + strategy: strategy === 'prompt' ? 'silent' : strategy, + syncBooks: ps?.syncBooks ?? false, + fullSync: false, + deviceId, + onProgress: ({ index, total, action }) => { + const label = action === 'downloading' ? _('Downloading') : _('Uploading'); + useFileSyncStore + .getState() + .updateProgress( + kind, + _('{{action}} {{n}} / {{total}}', { action: label, n: index + 1, total }), + ); + }, + }); + await updateLastSyncedAt(Date.now()); + useFileSyncStore.getState().setLastError(kind, null); + } catch (e) { + useFileSyncStore.getState().setLastError(kind, e instanceof Error ? e.message : String(e)); + console.warn('library file sync failed', e); + } finally { + useFileSyncStore.getState().endSync(kind); + } + }, [engine, activeKind, isReady, ensureDeviceId, updateLastSyncedAt, _]); + + // Keep one stable debounced trigger that always calls the latest runSync (via + // ref), so it isn't recreated — and lost — on every settings/engine change. + const runSyncRef = useRef(runSync); + runSyncRef.current = runSync; + const debouncedSync = useMemo( + () => debounce(() => void runSyncRef.current(), SYNC_DEBOUNCE_MS), + [], + ); + useEffect(() => () => debouncedSync.cancel(), [debouncedSync]); + + // Library changes — import (adds a row), delete (sets deletedAt), book close + // (bumps updatedAt) — all mutate `library`, so this single effect covers them + // plus the initial load pull. + useEffect(() => { + if (!isReady || !engine || !libraryLoaded) return; + debouncedSync(); + }, [library, libraryLoaded, isReady, engine, debouncedSync]); +}; diff --git a/apps/readest-app/src/app/library/hooks/useOpenBook.ts b/apps/readest-app/src/app/library/hooks/useOpenBook.ts new file mode 100644 index 000000000..27a895937 --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useOpenBook.ts @@ -0,0 +1,97 @@ +import { Dispatch, SetStateAction, useCallback } from 'react'; +import { Book } from '@/types/book'; +import { useEnv } from '@/context/EnvContext'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useAppRouter } from '@/hooks/useAppRouter'; +import { eventDispatcher } from '@/utils/event'; +import { navigateToReader, showReaderWindow } from '@/utils/nav'; + +interface UseOpenBookOptions { + setLoading: Dispatch>; + handleBookDownload: ( + book: Book, + options?: { redownload?: boolean; queued?: boolean }, + ) => Promise; +} + +/** + * Shared "open this book" flow used both by per-item taps (`BookshelfItem`) and + * the recently-read shelf. Centralizing it keeps the availability handling in + * one place: cloud-synced books (which arrive on other devices as metadata + + * progress without the file blob) are downloaded on demand, and a stale + * in-place record is dropped instead of bouncing the user into a broken reader. + */ +export const useOpenBook = ({ setLoading, handleBookDownload }: UseOpenBookOptions) => { + const _ = useTranslation(); + const router = useAppRouter(); + const { envConfig, appService } = useEnv(); + const { settings } = useSettingsStore(); + const { updateBook } = useLibraryStore(); + + const makeBookAvailable = useCallback( + async (book: Book) => { + if (book.uploadedAt && !book.downloadedAt) { + if (await appService?.isBookAvailable(book)) { + if (!book.downloadedAt || !book.coverDownloadedAt) { + book.downloadedAt = Date.now(); + book.coverDownloadedAt = Date.now(); + await updateBook(envConfig, book); + } + return true; + } + let available = false; + const loadingTimeout = setTimeout(() => setLoading(true), 200); + try { + available = await handleBookDownload(book, { queued: false }); + await updateBook(envConfig, book); + } finally { + if (loadingTimeout) clearTimeout(loadingTimeout); + setLoading(false); + } + return available; + } + return true; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [appService, envConfig, handleBookDownload, setLoading], + ); + + const openBook = useCallback( + async (book: Book) => { + // In-place books point at a file outside Books// that the user (or + // another app) may have moved, renamed, or deleted between sessions. Probe + // the source before navigating: if it's gone, drop the stale record + // instead of opening the reader only to fail and bounce back. Restricted + // to purely-local in-place books — cloud-synced books (`uploadedAt`) still + // go through `makeBookAvailable`'s on-demand download path. + if (book.filePath && !book.uploadedAt && !book.deletedAt) { + const available = await appService?.isBookAvailable(book); + if (!available) { + eventDispatcher.dispatch('toast', { + message: _( + 'Book file no longer exists. Confirm deletion to remove it from the library.', + ), + type: 'info', + }); + eventDispatcher.dispatch('delete-books', { ids: [book.hash] }); + return; + } + } + const available = await makeBookAvailable(book); + if (!available) return; + if (appService?.hasWindow && settings.openBookInNewWindow) { + showReaderWindow(appService, [book.hash]); + } else { + setTimeout(() => { + navigateToReader(router, [book.hash]); + }, 0); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [appService, makeBookAvailable, settings.openBookInNewWindow], + ); + + return { openBook, makeBookAvailable }; +}; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 7dd172fe0..b022e73f1 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -8,15 +8,29 @@ import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation'; import { Book } from '@/types/book'; import { AppService, DeleteAction } from '@/types/system'; -import { buildBookLookupIndex } from '@/services/bookService'; +import { + buildBookLookupIndex, + collectKnownSourcePaths, + normalizeFilePathForIndex, + selectNewImportableFiles, +} from '@/services/bookService'; import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav'; -import { getBookWithUpdatedMetadata, listFormater } from '@/utils/book'; +import { getCoverFilename, getBookWithUpdatedMetadata, listFormater } from '@/utils/book'; import { getImportErrorMessage } from '@/services/errors'; import { ingestFile } from '@/services/ingestService'; import { eventDispatcher } from '@/utils/event'; import { ProgressPayload } from '@/utils/transfer'; import { throttle } from '@/utils/throttle'; import { transferManager } from '@/services/transferManager'; +import { + getCloudSyncProvider, + isReadestCloudStorageActive, + cloudProviderDisplayName, +} from '@/services/sync/cloudSyncProvider'; +import { + runActiveFileBookDownload, + runActiveFileBookUpload, +} from '@/services/sync/file/runLibrarySync'; import { getDirPath, getFilename, joinPaths } from '@/utils/path'; import { parseOpenWithFiles } from '@/helpers/openWith'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; @@ -36,14 +50,20 @@ import { useTheme } from '@/hooks/useTheme'; import { useUICSS } from '@/hooks/useUICSS'; import { useDemoBooks } from './hooks/useDemoBooks'; import { useBooksSync } from './hooks/useBooksSync'; +import { useLibraryFileSync } from './hooks/useLibraryFileSync'; +import { useAutoImportFolders } from './hooks/useAutoImportFolders'; import { useInboxDrainer } from '@/hooks/useInboxDrainer'; import { useOPDSSubscriptions } from '@/hooks/useOPDSSubscriptions'; import { useBookDataStore } from '@/store/bookDataStore'; import { useTransferStore } from '@/store/transferStore'; import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; +import { useBackgroundTexture } from '@/hooks/useBackgroundTexture'; +import { getLibraryViewSettings } from '@/helpers/settings'; import { useAppUrlIngress } from '@/hooks/useAppUrlIngress'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; +import { useOpenBookLink } from '@/hooks/useOpenBookLink'; +import { useReadingWidget } from '@/hooks/useReadingWidget'; import { useOpenShareLink } from '@/hooks/useOpenShareLink'; import { useClipUrlIngress } from '@/hooks/useClipUrlIngress'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; @@ -65,6 +85,10 @@ import { KeyboardShortcutsHelp } from '@/components/KeyboardShortcutsHelp'; import { BookDetailModal } from '@/components/metadata'; import { UpdaterWindow } from '@/components/UpdaterWindow'; import { CatalogDialog } from './components/OPDSDialog'; +import { FeedsView } from './components/feeds/FeedsView'; +import AddFeedModal from './components/feeds/AddFeedModal'; +import { fetchAndParseFeed } from '@/services/rss/feedClient'; +import { createFeedBook, generateFeedCoverSvg, rasterizeCoverSvg } from '@/services/rss/feedBook'; import { MigrateDataWindow } from './components/MigrateDataWindow'; import { BackupWindow } from './components/BackupWindow'; import { CacheManagerWindow } from './components/CacheManagerWindow'; @@ -88,6 +112,8 @@ import ImportFromFolderDialog, { ImportFromFolderResult, } from './components/ImportFromFolderDialog'; import ImportFromUrlDialog from './components/ImportFromUrlDialog'; +import NowPlayingBar from './components/NowPlayingBar'; +import { ttsSessionManager } from '@/services/tts'; import { convertToEpubWithWorker } from '@/services/send/conversion/conversionWorker'; import { getClipOptions } from '@/services/send/clipOptions'; import { invoke } from '@tauri-apps/api/core'; @@ -99,6 +125,9 @@ import SettingsDialog from '@/components/settings/SettingsDialog'; import ModalPortal from '@/components/ModalPortal'; import TransferQueuePanel from './components/TransferQueuePanel'; +/** Skip tiny non-book artifacts during folder auto-scan (matches the manual import dialog default). */ +const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024; + /** * Key used to persist the last directory the user imported books from. * Stored in localStorage so re-opening the dialog (even across app @@ -179,6 +208,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const [showCatalogManager, setShowCatalogManager] = useState( searchParams?.get('opds') === 'true', ); + const [showFeeds, setShowFeeds] = useState(false); + const [showAddFeed, setShowAddFeed] = useState(false); const [showImportFromUrl, setShowImportFromUrl] = useState(false); const [loading, setLoading] = useState(false); // Seed from the library store: if we already have books in memory (the @@ -201,6 +232,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP initialSelectedGroupIds?: string[]; initialMinSizeKB?: number; initialReadInPlace?: boolean; + initialAutoImport?: boolean; } | null>(null); const [currentGroupPath, setCurrentGroupPath] = useState(undefined); const [currentSeriesAuthorGroup, setCurrentSeriesAuthorGroup] = useState<{ @@ -222,6 +254,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }, []); const containerRef = useRef(null); const pageRef = useRef(null); + // Tracks paths that failed to import in this session so auto-import does not + // re-attempt (and re-toast) them on every subsequent folder scan. + const autoImportFailedPathsRef = useRef>(new Set()); const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`; @@ -241,14 +276,39 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP useTheme({ systemUIVisible: true, appThemeColor: 'base-200' }); useUICSS(); + // Apply the library's own background texture (separate from the reader's, + // issue #4743). Re-applies on mount so returning from a textured book + // restores the library background, and whenever the library texture — or the + // reader/global texture it inherits when unset — changes from the Color panel. + const { applyBackgroundTexture } = useBackgroundTexture(); + useEffect(() => { + applyBackgroundTexture(envConfig, getLibraryViewSettings(settings)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + envConfig, + applyBackgroundTexture, + settings.libraryBackgroundTextureId, + settings.libraryBackgroundOpacity, + settings.libraryBackgroundSize, + settings.globalViewSettings?.backgroundTextureId, + settings.globalViewSettings?.backgroundOpacity, + settings.globalViewSettings?.backgroundSize, + ]); + useAppUrlIngress(); useOpenWithBooks(); useOpenAnnotationLink(); + useOpenBookLink(); + useReadingWidget(); useOpenShareLink(); useClipUrlIngress(); useTransferQueue(libraryLoaded); const { pullLibrary, pushLibrary } = useBooksSync(); + // Library-scoped auto-sync for the active third-party cloud provider (WebDAV / + // Google Drive): keeps library.json current on import / delete / book-close, + // parity with useBooksSync. No-op when no provider is enabled. + useLibraryFileSync(); const { checkOPDSSubscriptions } = useOPDSSubscriptions(); useInboxDrainer(); const { isDragging } = useDragDropImport(); @@ -507,6 +567,32 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP return false; }; + const handleShowFeeds = () => { + setShowAddFeed(true); + }; + + const handleAddFeedSubmit = async (url: string) => { + const parsed = await fetchAndParseFeed(url); + const book = createFeedBook(url, parsed); + if (appService) { + try { + const cover = generateFeedCoverSvg(url, book.title); + const pngBytes = await rasterizeCoverSvg(cover); + await appService.createDir(book.hash, 'Books', true); + await appService.writeFile(getCoverFilename(book), 'Books', pngBytes); + book.coverImageUrl = await appService.generateCoverImageUrl(book); + } catch (e) { + console.warn('Failed to generate feed book cover:', e); + } + } + await useLibraryStore.getState().updateBooks(envConfig, [book]); + eventDispatcher.dispatch('toast', { + type: 'success', + message: _('Subscribed to "{{title}}"', { title: book.title }), + timeout: 3000, + }); + }; + const handleShowOPDSDialog = () => { setShowCatalogManager(true); }; @@ -676,7 +762,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP // eslint-disable-next-line react-hooks/exhaustive-deps }, [demoBooks, libraryLoaded]); - const importBooks = async (files: SelectedFile[], groupId?: string) => { + const importBooks = async ( + files: SelectedFile[], + groupId?: string, + options: { silent?: boolean } = {}, + ): Promise<{ failedPaths: string[] }> => { setLoading(true); const { library } = useLibraryStore.getState(); // Build the lookup index ONCE per import batch so each book lookup is @@ -690,6 +780,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP // importBook can recognize a re-import of the same file. const lookupIndex = buildBookLookupIndex(library, appService?.osPlatform); const failedImports: Array<{ filename: string; errorMessage: string }> = []; + const failedPaths: string[] = []; const successfulImports: string[] = []; // Readest's own Books/ prefix is resolved once at app init and persisted @@ -748,6 +839,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP return book; } catch (error) { const filename = typeof file === 'string' ? file : file.name; + if (typeof file === 'string') failedPaths.push(file); const baseFilename = getFilename(filename); const errorMessage = error instanceof Error ? _(getImportErrorMessage(error.message)) : ''; failedImports.push({ filename: baseFilename, errorMessage }); @@ -776,9 +868,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP pushLibrary(); - if (failedImports.length > 1) { + if (!options.silent && failedImports.length > 1) { setFailedImportsModal(failedImports); - } else if (failedImports.length === 1) { + } else if (!options.silent && failedImports.length === 1) { const { filename, errorMessage } = failedImports[0]!; eventDispatcher.dispatch('toast', { message: @@ -788,7 +880,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP timeout: 5000, type: 'error', }); - } else if (successfulImports.length > 0) { + } + // Surface the success toast when books were imported. In silent (auto-import) + // mode failures are suppressed, so show success independently of them; in + // interactive mode keep the original behaviour (only when nothing failed). + if (successfulImports.length > 0 && (options.silent || failedImports.length === 0)) { eventDispatcher.dispatch('toast', { message: _('Successfully imported {{count}} book(s)', { count: successfulImports.length, @@ -799,8 +895,79 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP } setLoading(false); + return { failedPaths }; }; + /** + * Re-scan the given watched folders (the user's `autoImportFolders`) and + * import any newly-added books. Reuses the same in-place import + dedup as + * manual folder import, but stays quiet: unreadable folders are skipped (no + * toast), and `importBooks` runs only when genuinely-new files exist (its + * success toast then fires). + */ + const autoImportFromWatchedFolders = async (folders: string[]) => { + if (!appService || loading) return; + const { library } = useLibraryStore.getState(); + const osPlatform = appService.osPlatform; + // Known local source paths — live AND soft-deleted (files the user deleted + // but whose in-place source is still on disk), plus paths that already failed + // to import this session — so we neither resurrect a deleted book nor + // re-parse/re-toast a bad file on every focus. + const existingPaths = collectKnownSourcePaths(library, osPlatform); + for (const key of autoImportFailedPathsRef.current) existingPaths.add(key); + const newFiles: SelectedFile[] = []; + for (const folder of folders) { + try { + await appService.allowPathsInScopes?.([folder], true); + const items = await appService.readDirectory(folder, 'None'); + const entries = await Promise.all( + items.map(async (item) => ({ + fullPath: await joinPaths(folder, item.path), + size: item.size, + })), + ); + const fresh = selectNewImportableFiles(entries, { + extensions: SUPPORTED_BOOK_EXTS, + minSizeBytes: AUTO_IMPORT_MIN_SIZE_BYTES, + existingPaths, + osPlatform, + }); + for (const entry of fresh) { + newFiles.push({ path: entry.fullPath }); + // Prevent the same file matching again via a later overlapping folder. + const key = normalizeFilePathForIndex(entry.fullPath, osPlatform); + if (key) existingPaths.add(key); + } + } catch (e) { + // One unreadable/temporarily-missing folder must not abort the others + // or nag the user (unlike the manual path, which nudges a re-pick). + console.error('Auto-import: failed to scan folder', folder, e); + } + } + if (newFiles.length > 0) { + const { failedPaths } = await importBooks(newFiles, undefined, { silent: true }); + for (const p of failedPaths) { + const key = normalizeFilePathForIndex(p, osPlatform); + if (key) autoImportFailedPathsRef.current.add(key); + } + } + }; + + // Local-folder counterpart of useLibraryFileSync: re-scan the folders the + // user opted into auto-import (a subset of externalLibraryFolders, chosen + // per-folder in the Import-from-Folder dialog) and import newly-added books + // on library open and app focus. Desktop + Android only (iOS security-scoped + // bookmarks are out of scope). + useAutoImportFolders({ + enabled: + (settings.autoImportFolders?.length ?? 0) > 0 && + libraryLoaded && + isTauriAppPlatform() && + !appService?.isIOSApp, + folders: settings.autoImportFolders ?? [], + scanAndImport: autoImportFromWatchedFolders, + }); + const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => { if (progress.total === 0) return; const progressPct = (progress.progress / progress.total) * 100; @@ -812,6 +979,20 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const handleBookUpload = useCallback( async (book: Book, _syncBooks = true) => { + // Route the explicit action to the selected cloud provider: while + // WebDAV / Google Drive is active the Readest Cloud transfer queue is + // gated and would only answer with the "paused" notice. + if (getCloudSyncProvider(useSettingsStore.getState().settings) !== 'readest') { + const ok = await runActiveFileBookUpload(envConfig, book); + eventDispatcher.dispatch('toast', { + type: ok ? 'info' : 'error', + timeout: 2000, + message: ok + ? _('Book uploaded: {{title}}', { title: book.title }) + : _('Failed to upload book: {{title}}', { title: book.title }), + }); + return ok; + } // Use transfer queue for uploads - priority 1 for manual uploads (higher priority) const transferId = transferManager.queueUpload(book, 1); if (transferId) { @@ -824,6 +1005,19 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }); return true; } + // An explicit Upload action must never silently no-op: explain the + // provider gate when it is the reason the queue refused the book. + const currentSettings = useSettingsStore.getState().settings; + if (!isReadestCloudStorageActive(currentSettings)) { + const provider = getCloudSyncProvider(currentSettings); + eventDispatcher.dispatch('toast', { + type: 'info', + timeout: 5000, + message: _('Uploads to Readest Cloud are paused while {{provider}} sync is selected', { + provider: cloudProviderDisplayName(provider), + }), + }); + } return false; }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -833,6 +1027,20 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const handleBookDownload = useCallback( async (book: Book, downloadOptions: { redownload?: boolean; queued?: boolean } = {}) => { const { redownload = false, queued = false } = downloadOptions; + // Same provider routing as handleBookUpload — this path is also how a + // not-yet-local book gets fetched when the user opens it. + if (getCloudSyncProvider(useSettingsStore.getState().settings) !== 'readest') { + const ok = await runActiveFileBookDownload(envConfig, book); + if (ok) await updateBook(envConfig, book); + eventDispatcher.dispatch('toast', { + type: ok ? 'info' : 'error', + timeout: 2000, + message: ok + ? _('Book downloaded: {{title}}', { title: book.title }) + : _('Failed to download book: {{title}}', { title: book.title }), + }); + return ok; + } if (redownload || !queued) { try { await appService?.downloadBook(book, false, redownload, (progress) => { @@ -903,6 +1111,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP book.coverDownloadedAt = null; } await updateBook(envConfig, book); + if (ttsSessionManager.getSessionByHash(book.hash)) { + await ttsSessionManager.stopActive('deleted'); + } clearBookData(book.hash); if (syncBooks) pushLibrary(); } @@ -944,6 +1155,32 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP metadata.coverImageBlobUrl || metadata.coverImageUrl, metadata.coverImageFile, ); + // Cover-change sync (issue #4544): recompute the cover's content hash. + // If it actually changed, bump coverHash + coverUpdatedAt so peers + // re-download it (the book row already syncs via updatedAt). + // computeCoverHash returns null for a '_blank' deletion — we skip the + // bump there (cover deletion is intentionally not synced; peers keep + // their cover until a new one is set). + const newCoverHash = (await appService?.computeCoverHash(updatedBook)) ?? null; + if (newCoverHash && newCoverHash !== book.coverHash) { + // For a book already in the cloud, re-upload the cover FIRST and only + // advertise the new version if it succeeded — otherwise peers would + // try to fetch a cover that isn't there. A not-yet-uploaded book + // carries the new cover on its first full upload, so the bump is safe. + let coverUploaded = true; + if (user && updatedBook.uploadedAt) { + try { + await appService?.uploadBookCover(updatedBook); + } catch (uploadError) { + console.warn('Failed to upload updated cover:', uploadError); + coverUploaded = false; + } + } + if (coverUploaded) { + updatedBook.coverHash = newCoverHash; + updatedBook.coverUpdatedAt = Date.now(); + } + } } catch (error) { console.warn('Failed to update cover image:', error); } @@ -1027,6 +1264,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP // by `runFolderImport` itself via the prefix check, so books // under a registered folder are imported in-place either way. readInPlace: false, + // Non-dialog path never opts into auto-import. + autoImport: false, }); return; } @@ -1059,6 +1298,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP ? parsedMinSize : undefined, initialReadInPlace: storedReadInPlace === '1', + initialAutoImport: isAutoImportFolder(storedDirectory), }); }; @@ -1163,6 +1403,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP return roots.some((r) => normalizeRoot(r) === target); }; + /** + * `true` when `directory` is in `settings.autoImportFolders` after path + * normalization. Seeds the dialog's "Auto-import new books from this + * folder" checkbox so re-opening on a watched folder shows it ticked. + */ + const isAutoImportFolder = (directory: string): boolean => { + const target = normalizeRoot(directory); + if (!target) return false; + const roots = settings.autoImportFolders ?? []; + return roots.some((r) => normalizeRoot(r) === target); + }; + /** * Add `directory` to `settings.externalLibraryFolders` (and persist * settings) so the ingest layer's `shouldImportInPlace` will pick @@ -1190,6 +1442,32 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP } }; + /** + * Add or remove `directory` from `settings.autoImportFolders` (and persist) + * per the user's per-folder "Auto-import new books from this folder" choice. + * A no-op when the folder is already in the desired state. Errors are + * swallowed — the import itself still succeeds; we just won't watch (or stop + * watching) the folder until the next successful settings write. + */ + const setAutoImportFolder = async (directory: string, enabled: boolean): Promise => { + const target = normalizeRoot(directory); + if (!target) return; + const liveSettings = useSettingsStore.getState().settings; + const existing = liveSettings.autoImportFolders ?? []; + const present = existing.some((r) => normalizeRoot(r) === target); + if (enabled === present) return; + const next = enabled + ? [...existing, directory] + : existing.filter((r) => normalizeRoot(r) !== target); + const nextSettings = { ...liveSettings, autoImportFolders: next }; + setSettings(nextSettings); + try { + await saveSettings(envConfig, nextSettings); + } catch (e) { + console.error('Failed to persist autoImportFolders update:', e); + } + }; + /** * Recursively scan {@link result.directory}, keep files matching one * of {@link result.extensions} that are at least @@ -1236,6 +1514,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP if (result.readInPlace) { await registerExternalLibraryFolder(result.directory); } + // Opt this folder into (or out of) auto-import per the dialog's per-folder + // checkbox. `result.autoImport` already implies `readInPlace` (the dialog + // gates it), so registration above has run; unchecking removes the folder + // from the watched set while leaving it registered as read-in-place. + await setAutoImportFolder(result.directory, result.autoImport); // Re-grant scopes for the directory before scanning. This matters // when `result.directory` came from somewhere the dialog plugin @@ -1337,7 +1620,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }; if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) { - return
; + return
; } const showBookshelf = libraryLoaded || libraryBooks.length > 0; @@ -1368,6 +1651,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP } onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined} onOpenCatalogManager={handleShowOPDSDialog} + onOpenFeeds={handleShowFeeds} onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)} onSelectAll={handleSelectAll} onDeselectAll={handleDeselectAll} @@ -1453,6 +1737,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP handleBookUpload={handleBookUpload} handleBookDownload={handleBookDownload} handleBookDelete={handleBookDelete('both')} + handleBookPurge={handleBookDelete('purge')} handleSetSelectMode={handleSetSelectMode} handleShowDetailsBook={handleShowDetailsBook} handleLibraryNavigation={handleLibraryNavigation} @@ -1467,6 +1752,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
))} + {showDetailsBook && ( {isSettingsDialogOpen && } {showCatalogManager && } + {showFeeds && setShowFeeds(false)} />} + setShowAddFeed(false)} + onSubmit={handleAddFeedSubmit} + /> {failedImportsModal && ( setImportFromFolderState(null)} diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index 3b78d1f2d..03446f56b 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -148,6 +148,12 @@ export const expandBookshelfSelection = (ids: string[], items: (Book | BooksGrou return [...hashes]; }; +// Calibre custom column names and values, flattened for searching (#4811). +const getCalibreColumnsText = (item: Book) => + (item.metadata?.calibreColumns ?? []) + .map(({ name, value }) => `${name} ${Array.isArray(value) ? value.join(' ') : value}`) + .join(' '); + export const createBookFilter = (queryTerm: string | null) => (item: Book) => { if (!queryTerm) return true; if (item.deletedAt) return false; @@ -164,7 +170,9 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => { authors.includes(lowerQuery) || item.format.toLowerCase().includes(lowerQuery) || (item.groupName && item.groupName.toLowerCase().includes(lowerQuery)) || - (item.metadata?.description && item.metadata.description.toLowerCase().includes(lowerQuery)) + (item.metadata?.description && + item.metadata.description.toLowerCase().includes(lowerQuery)) || + getCalibreColumnsText(item).toLowerCase().includes(lowerQuery) ); } const title = formatTitle(item.title); @@ -174,10 +182,22 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => { searchTerm.test(authors) || searchTerm.test(item.format) || (item.groupName && searchTerm.test(item.groupName)) || - (item.metadata?.description && searchTerm.test(item.metadata?.description)) + (item.metadata?.description && searchTerm.test(item.metadata?.description)) || + searchTerm.test(getCalibreColumnsText(item)) ); }; +/** + * Fraction of the book that has been read, in [0, 1]. `progress` is a 1-based + * `[current, total]` page pair; books that have never been opened have no + * progress and read 0 (they sort to the unread end). + */ +const getBookReadRatio = (book: Book): number => { + const [current, total] = book.progress ?? []; + if (!current || !total || total <= 0) return 0; + return current / total; +}; + const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string): number => { switch (sortBy) { case LibrarySortByType.Title: { @@ -196,6 +216,8 @@ const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string): return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); case LibrarySortByType.Format: return a.format.localeCompare(b.format, uiLanguage || navigator.language); + case LibrarySortByType.Progress: + return getBookReadRatio(a) - getBookReadRatio(b); case LibrarySortByType.Series: { // Group by series name first so books of the same series stay consecutive, // then order within a series by index. Comparing index alone would interleave @@ -246,6 +268,28 @@ export const createBookSorter = return compareBookByKey(a, b, secondarySortBy, uiLanguage); }; +/** + * A book counts as "read" once it has reading progress. Importing a book sets + * timestamps but never `progress`; only opening it does. Gating on this keeps + * freshly-added-but-unopened books off the shelf. + */ +const hasBeenRead = (book: Book): boolean => book.progress != null; + +/** + * Pick the books for the recently-read shelf: most-recently-read first, capped + * at `count`. Recency uses `updatedAt` (the library's "Updated" sort key) so the + * row matches the app's existing sort convention. NB: `updatedAt` is last-modified + * (also bumped by status/metadata edits and sync), not strictly last-read. + * Independent of the main shelf's sort/grouping — always a flat, recency slice. + */ +export const selectRecentShelfBooks = (books: Book[], count: number): Book[] => { + const byRecency = createBookSorter(LibrarySortByType.Updated, ''); + return books + .filter((book) => !book.deletedAt && hasBeenRead(book)) + .sort((a, b) => -byRecency(a, b)) + .slice(0, count); +}; + /** * Build a `groupName -> max(book.updatedAt)` map for all groups touched by * the given books. Each book bumps both its direct group and every ancestor @@ -474,6 +518,9 @@ export const getBookSortValue = (book: Book, sortBy: LibrarySortByType): number case LibrarySortByType.Format: return book.format; + case LibrarySortByType.Progress: + return getBookReadRatio(book); + case LibrarySortByType.Published: { const published = book.metadata?.published; if (!published) return 0; @@ -533,6 +580,10 @@ export const getGroupSortValue = ( // Return the most recent createdAt return Math.max(...books.map((b) => b.createdAt)); + case LibrarySortByType.Progress: + // Return the most-progressed book's read ratio + return Math.max(...books.map((b) => getBookReadRatio(b))); + case LibrarySortByType.Published: { // Return the most recent published date const publishedDates = books @@ -638,6 +689,47 @@ export const pickFresherReadingStatus = ( }; }; +type CoverFields = Pick; +type CoverSyncFields = Pick< + Book, + 'coverHash' | 'coverUpdatedAt' | 'coverDownloadedAt' | 'deletedAt' | 'uploadedAt' +>; + +const coverMs = (t?: number | null) => t ?? 0; + +/** + * Decide whether a peer should (re)download a book's cover from the cloud + * (issue #4544). True when the synced book is in the cloud AND either: + * - this device has never fetched the cover (first download), or + * - a newer cover edit exists (synced `coverUpdatedAt` strictly newer) whose + * content hash differs from the local one. + * + * Gating on `coverUpdatedAt` (not just the hash) prevents two failure modes: + * - churn: once a device adopts the synced `coverUpdatedAt` after downloading, + * the comparison stops firing on every subsequent sync; + * - the unpushed-local-edit race: a device that just edited its cover (newer + * local `coverUpdatedAt`) is not made to overwrite it with the stale cloud + * copy before its own push lands. + */ +export const needsCoverRefresh = (local: CoverSyncFields, synced: CoverSyncFields): boolean => { + if (synced.deletedAt || !synced.uploadedAt) return false; + if (!local.coverDownloadedAt) return true; // first download + if (!synced.coverHash) return false; // nothing to compare (legacy book) + if (coverMs(synced.coverUpdatedAt) <= coverMs(local.coverUpdatedAt)) return false; + return synced.coverHash !== local.coverHash; +}; + +/** + * Field-level last-writer-wins for the cover, by `coverUpdatedAt` (ties → + * `local`, which already holds the file). Mirrors {@link pickFresherReadingStatus}: + * the row's `updatedAt` is dominated by page-turn progress, so the cover must be + * resolved by its own timestamp or progress would clobber a cover edit. + */ +export const pickFresherCover = (local: CoverFields, synced: CoverFields): CoverFields => + coverMs(synced.coverUpdatedAt) > coverMs(local.coverUpdatedAt) + ? { coverHash: synced.coverHash, coverUpdatedAt: synced.coverUpdatedAt } + : { coverHash: local.coverHash, coverUpdatedAt: local.coverUpdatedAt }; + /** * Resolve the ordered list of context-menu item ids for a book from its state. * diff --git a/apps/readest-app/src/app/onedrive-callback/page.tsx b/apps/readest-app/src/app/onedrive-callback/page.tsx new file mode 100644 index 000000000..a32f82bf9 --- /dev/null +++ b/apps/readest-app/src/app/onedrive-callback/page.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useSettingsStore } from '@/store/settingsStore'; +import { eventDispatcher } from '@/utils/event'; +import { resolveOneDriveAccountLabel } from '@/services/sync/providers/onedrive/onedriveAuth'; +import { getMicrosoftClientId } from '@/services/sync/providers/onedrive/buildOneDriveProvider'; +import { + consumeReturnPath, + consumeWebOAuthState, + consumeWebPkceVerifier, + exchangeWebAuthCode, + oneDriveWebRedirectUri, + saveWebOneDriveToken, +} from '@/services/sync/providers/onedrive/webAuthCodeFlow'; +import { persistActiveCloudProvider } from '@/components/settings/integrations/cloudSync'; + +/** + * OAuth return route for the web OneDrive connect (full-page auth-code + PKCE + * flow). Microsoft redirects here with the authorization code in the URL + * query string (not the `#` fragment, unlike gdrive's implicit flow); we + * validate the CSRF state, exchange the code for tokens, store them, mark + * OneDrive the active cloud provider, then route back to where the user + * started. See `onedrive/webAuthCodeFlow.ts`. + */ +export default function OneDriveCallback() { + const router = useRouter(); + const _ = useTranslation(); + const { envConfig } = useEnv(); + const setSettings = useSettingsStore((s) => s.setSettings); + + useEffect(() => { + let cancelled = false; + (async () => { + const returnPath = consumeReturnPath(); + const expectedState = consumeWebOAuthState(); + const verifier = consumeWebPkceVerifier(); + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const returnedState = params.get('state'); + const oauthError = params.get('error'); + try { + if (oauthError) throw new Error(oauthError); + if (!expectedState || returnedState !== expectedState) { + throw new Error('state mismatch'); + } + if (!code || !verifier) throw new Error('missing code or verifier'); + + const clientId = getMicrosoftClientId(); + if (!clientId) throw new Error('OneDrive is not configured for the web build'); + + const fetchFn = globalThis.fetch.bind(globalThis); + const tokens = await exchangeWebAuthCode({ + clientId, + code, + verifier, + redirectUri: oneDriveWebRedirectUri(), + fetchFn, + }); + + saveWebOneDriveToken(tokens); + + const accountLabel = await resolveOneDriveAccountLabel(tokens.accessToken, fetchFn).catch( + () => null, + ); + + // Mark OneDrive the single active cloud provider (turns others off) and + // stamp the account label. persistActiveCloudProvider loads via + // appService when the settings store isn't hydrated on this route, + // persists, hydrates the store, and broadcasts to other windows. + await persistActiveCloudProvider(envConfig, 'onedrive', (s) => ({ + ...s, + onedrive: { + ...s.onedrive, + accountLabel: accountLabel ?? s.onedrive?.accountLabel, + }, + })); + eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') }); + } catch (e) { + console.warn('[onedrive] web callback failed', e); + eventDispatcher.dispatch('toast', { type: 'error', message: _('Failed to connect') }); + } finally { + if (!cancelled) router.replace(returnPath); + } + })(); + return () => { + cancelled = true; + }; + }, [envConfig, router, setSettings, _]); + + return ( +
+ +
+ ); +} diff --git a/apps/readest-app/src/app/opds/components/CatalogManager.tsx b/apps/readest-app/src/app/opds/components/CatalogManager.tsx index 956aaf04d..5a5944e00 100644 --- a/apps/readest-app/src/app/opds/components/CatalogManager.tsx +++ b/apps/readest-app/src/app/opds/components/CatalogManager.tsx @@ -29,7 +29,7 @@ import { eventDispatcher } from '@/utils/event'; import { SectionTitle } from '@/components/settings/primitives'; import { deleteSubscriptionState, loadSubscriptionState } from '@/services/opds'; import type { OPDSSubscriptionState } from '@/services/opds/types'; -import { validateOPDSURL } from '../utils/opdsUtils'; +import { getUnaddedPopularCatalogs, validateOPDSURL } from '../utils/opdsUtils'; import { FailedDownloadsDialog } from './FailedDownloadsDialog'; import { formatOPDSCustomHeadersInput, @@ -129,7 +129,11 @@ export function CatalogManager({ inSubPage = false }: CatalogManagerProps = {}) const [headerError, setHeaderError] = useState(''); const [proxyConsentError, setProxyConsentError] = useState(''); const [isValidating, setIsValidating] = useState(false); - const popularCatalogs = appService?.isOnlineCatalogsAccessible ? POPULAR_CATALOGS : []; + // Only surface popular catalogs the user hasn't already added; otherwise an + // added entry would render in both sections and read as a duplicate (#4782). + const popularCatalogs = appService?.isOnlineCatalogsAccessible + ? getUnaddedPopularCatalogs(POPULAR_CATALOGS, catalogs) + : []; const [subscriptionStates, setSubscriptionStates] = useState< Record >({}); @@ -593,53 +597,46 @@ export function CatalogManager({ inSubPage = false }: CatalogManagerProps = {})
{_('Popular Catalogs')}
- {popularCatalogs - .filter((catalog) => !catalog.disabled) - .map((catalog) => { - const isAdded = catalogs.some((c) => c.url === catalog.url); - return ( -
-
-

- -

- {catalog.description && ( -

- {catalog.description} -

- )} -
- {!isAdded && ( - - )} - -
-
+ {popularCatalogs.map((catalog) => ( +
+
+

+ +

+ {catalog.description && ( +

+ {catalog.description} +

+ )} +
+ +
- ); - })} +
+
+ ))}
diff --git a/apps/readest-app/src/app/opds/components/FeedView.tsx b/apps/readest-app/src/app/opds/components/FeedView.tsx index 85009f9be..01fe58385 100644 --- a/apps/readest-app/src/app/opds/components/FeedView.tsx +++ b/apps/readest-app/src/app/opds/components/FeedView.tsx @@ -7,6 +7,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import { OPDSFeed, OPDSGenericLink } from '@/types/opds'; import { PublicationCard } from './PublicationCard'; import { NavigationCard } from './NavigationCard'; +import { GroupCarousel } from './GroupCarousel'; import { groupByArray } from '../utils/opdsUtils'; interface FeedViewProps { @@ -42,6 +43,11 @@ export function FeedView({ const hasFacets = feed.facets && feed.facets.length > 0; + // When a feed lists several groups, full grids make scrolling past them + // tedious. Render each group as a compact horizontal carousel instead so the + // page stays short and groups are easy to skim (readest issue #4750). + const useCarousel = (feed.groups?.length ?? 0) >= 2; + const handlePaginationClick = (links?: OPDSGenericLink[]) => { if (links && links.length > 0) { const url = resolveURL(links[0]?.href || '', baseURL); @@ -172,10 +178,14 @@ export function FeedView({ {/* Groups */} {feed.groups?.map((group, groupIndex: number) => ( -
+
{group.metadata && ( -
-

{group.metadata.title}

+
+

+ {group.metadata.title} +

{group.links && group.links.length > 0 && (
)} - {group.navigation && ( -
- {group.navigation.map((item, itemIndex: number) => ( - - ))} -
- )} + {group.navigation && + (useCarousel ? ( + ( +
+ +
+ )} + /> + ) : ( +
+ {group.navigation.map((item, itemIndex: number) => ( + + ))} +
+ ))} - {group.publications && ( -
- {group.publications.map((pub, itemIndex: number) => ( - onPublicationSelect(groupIndex, itemIndex)} - resolveURL={resolveURL} - onGenerateCachedImageUrl={onGenerateCachedImageUrl} - /> - ))} -
- )} + {group.publications && + (useCarousel ? ( + ( +
+ onPublicationSelect(groupIndex, itemIndex)} + resolveURL={resolveURL} + onGenerateCachedImageUrl={onGenerateCachedImageUrl} + /> +
+ )} + /> + ) : ( +
+ {group.publications.map((pub, itemIndex: number) => ( + onPublicationSelect(groupIndex, itemIndex)} + resolveURL={resolveURL} + onGenerateCachedImageUrl={onGenerateCachedImageUrl} + /> + ))} +
+ ))}
))} diff --git a/apps/readest-app/src/app/opds/components/GroupCarousel.tsx b/apps/readest-app/src/app/opds/components/GroupCarousel.tsx new file mode 100644 index 000000000..51d17a867 --- /dev/null +++ b/apps/readest-app/src/app/opds/components/GroupCarousel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; +import { MdChevronLeft, MdChevronRight } from 'react-icons/md'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface GroupCarouselProps { + count: number; + itemContent: (index: number) => React.ReactNode; + // Initial row height (px) so the list renders before the real item height is + // measured, avoiding a layout flash. + defaultRowHeight: number; + // Center the scroll arrows on the cover image rather than the whole card. + coverCentered?: boolean; +} + +export function GroupCarousel({ + count, + itemContent, + defaultRowHeight, + coverCentered = false, +}: GroupCarouselProps) { + const _ = useTranslation(); + const virtuosoRef = useRef(null); + const scrollerRef = useRef(null); + const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); + const [showLeftArrow, setShowLeftArrow] = useState(false); + const [showRightArrow, setShowRightArrow] = useState(false); + const [rowHeight, setRowHeight] = useState(defaultRowHeight); + // Vertical center of the cover (px from the carousel top). Cards carry a + // title/author below the cover, so centering on the artwork keeps the arrows + // visually balanced. Null falls back to centering on the whole row. + const [coverCenter, setCoverCenter] = useState(null); + + const measure = () => { + const scroller = scrollerRef.current; + if (!scroller) return; + const item = scroller.querySelector('[data-carousel-item]'); + if (item) { + setRowHeight(item.getBoundingClientRect().height); + } + if (coverCentered) { + const cover = scroller.querySelector('figure'); + if (cover) { + const top = scroller.getBoundingClientRect().top; + const rect = cover.getBoundingClientRect(); + setCoverCenter(rect.top - top + rect.height / 2); + } + } + }; + + // Page through the carousel by index rather than pixels: Virtuoso sizes the + // horizontal track lazily, so a pixel `scrollBy` clamps to the rendered width. + // Aligning the current edge item to the opposite side advances ~one page. + const scrollByPage = (direction: -1 | 1) => { + const { startIndex, endIndex } = rangeRef.current; + if (direction === 1) { + virtuosoRef.current?.scrollToIndex({ + index: Math.min(count - 1, endIndex), + align: 'start', + behavior: 'smooth', + }); + } else { + virtuosoRef.current?.scrollToIndex({ + index: Math.max(0, startIndex), + align: 'end', + behavior: 'smooth', + }); + } + }; + + return ( +
+ { + scrollerRef.current = ref as HTMLElement; + }} + rangeChanged={(range) => { + rangeRef.current = range; + }} + atTopStateChange={(atStart) => setShowLeftArrow(!atStart)} + atBottomStateChange={(atEnd) => setShowRightArrow(!atEnd)} + totalListHeightChanged={measure} + /> + {showLeftArrow && ( + + )} + {showRightArrow && ( + + )} +
+ ); +} diff --git a/apps/readest-app/src/app/opds/components/PublicationCard.tsx b/apps/readest-app/src/app/opds/components/PublicationCard.tsx index 6d821bad7..99899dba6 100644 --- a/apps/readest-app/src/app/opds/components/PublicationCard.tsx +++ b/apps/readest-app/src/app/opds/components/PublicationCard.tsx @@ -1,10 +1,8 @@ 'use client'; import { useMemo } from 'react'; -import { useTranslation } from '@/hooks/useTranslation'; import { CachedImage } from '@/components/CachedImage'; import { OPDSPublication, REL } from '@/types/opds'; -import { groupByArray } from '../utils/opdsUtils'; interface PublicationCardProps { publication: OPDSPublication; @@ -21,12 +19,6 @@ export function PublicationCard({ resolveURL, onGenerateCachedImageUrl, }: PublicationCardProps) { - const _ = useTranslation(); - const linksByRel = useMemo( - () => groupByArray(publication.links, (link) => link.rel), - [publication.links], - ); - const thumbnailImage = useMemo(() => { const thumbnails = publication.images?.filter((img) => REL.THUMBNAIL.some((rel: string) => img.rel?.includes(rel)), @@ -53,28 +45,9 @@ export function PublicationCard({ return authorList.map((a) => (typeof a === 'string' ? a : a?.name)).filter(Boolean); }, [publication.metadata?.author]); - const price = useMemo(() => { - const priceLink = publication.links?.find((link) => link.properties?.price); - if (priceLink?.properties?.price) { - const priceObj = Array.isArray(priceLink.properties.price) - ? priceLink.properties.price[0] - : priceLink.properties.price; - - if (priceObj) { - const { currency, value } = priceObj; - return `${currency ? currency + ' ' : ''}${value}`; - } - } - if (linksByRel.has(REL.ACQ + '/open-access')) { - return _('Open Access'); - } - return null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [publication.links, linksByRel]); - return (
-
+
0 && (

{authors.join(', ')}

)} - {price && ( -
-
{price}
-
- )}
); diff --git a/apps/readest-app/src/app/opds/components/PublicationView.tsx b/apps/readest-app/src/app/opds/components/PublicationView.tsx index 24731c2f5..5cadfc306 100644 --- a/apps/readest-app/src/app/opds/components/PublicationView.tsx +++ b/apps/readest-app/src/app/opds/components/PublicationView.tsx @@ -175,7 +175,14 @@ export function PublicationView({ const content = publication.metadata?.[SYMBOL.CONTENT] || publication.metadata?.content; const description = publication.metadata?.description; - const descriptionHtml = useMemo(() => getOPDSDescriptionHtml(content), [content]); + // OPDS 2.0 JSON keeps the summary in the plain `description` string (no typed + // ), and catalogs like pglaf/Gutenberg fill it with HTML. Fall back + // to it so that markup renders as markup instead of literal tags; the helper + // sanitizes either source (readest issue #4749). + const descriptionHtml = useMemo( + () => getOPDSDescriptionHtml(content ?? description), + [content, description], + ); return (
diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx index dae5db596..04c01cc31 100644 --- a/apps/readest-app/src/app/opds/page.tsx +++ b/apps/readest-app/src/app/opds/page.tsx @@ -18,6 +18,7 @@ import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useCustomOPDSStore } from '@/store/customOPDSStore'; import { transferManager } from '@/services/transferManager'; +import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider'; import { useTransferQueue } from '@/hooks/useTransferQueue'; import { useTheme } from '@/hooks/useTheme'; import { useLibrary } from '@/hooks/useLibrary'; @@ -42,6 +43,7 @@ import { needsProxy, probeFilename, } from './utils/opdsReq'; +import { getPublicationDetailHref, parsePublicationDocument } from './utils/opdsPublication'; import { ImportError } from '@/services/errors'; import { READEST_OPDS_USER_AGENT } from '@/services/constants'; import { findBookByOPDSSources, upsertOPDSSourceMapping } from '@/services/opds/sourceMap'; @@ -55,6 +57,7 @@ import { normalizeOPDSCustomHeaders } from './utils/customHeaders'; import { closeOPDSBrowser, stashOPDSReturnTarget } from './utils/opdsClose'; import { findExistingBookForPublication } from './utils/findExistingBook'; import Dialog from '@/components/Dialog'; +import { uniqueId } from '@/utils/misc'; type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error'; @@ -474,13 +477,78 @@ export default function BrowserPage() { [_, state, handleNavigate, addToHistory], ); - const publication = + const basePublication = selectedPublication && state.feed ? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[ selectedPublication.itemIndex ] || state.feed.publications?.[selectedPublication.itemIndex] : state.publication; + const fetchPublicationDocument = useCallback( + async (url: string): Promise => { + try { + const useProxy = isWebAppPlatform(); + const username = usernameRef.current || ''; + const password = passwordRef.current || ''; + const customHeaders = customHeadersRef.current; + const res = await fetchWithAuth(url, username, password, useProxy, {}, customHeaders); + if (!res.ok) return null; + const text = await res.text(); + return parsePublicationDocument(text, res.url); + } catch (e) { + console.warn('Failed to load OPDS publication document:', e); + return null; + } + }, + [], + ); + + // When a publication is listed in summary form but advertises a `rel="self"` + // link to its full document, dereference it and show the richer metadata + // (description, publisher, subjects, language) — readest issue #4749, matching + // what Thorium does. The summary renders immediately and is upgraded in place + // once the document loads; `source` ties the resolved record to the exact + // base publication it enriches so a stale fetch can't bleed into the next one. + const [detailPublication, setDetailPublication] = useState<{ + source: OPDSPublication; + resolved: OPDSPublication; + } | null>(null); + + useEffect(() => { + // Only enrich feed-selected summaries; a directly-loaded entry document + // (state.publication, no selection) is already the full record. + const detailLink = + selectedPublication && basePublication + ? getPublicationDetailHref(basePublication) + : undefined; + if (!basePublication || !detailLink) { + setDetailPublication(null); + return; + } + let cancelled = false; + const url = resolveURL(detailLink.href, state.baseURL); + void fetchPublicationDocument(url).then((resolved) => { + if (!cancelled && resolved) { + setDetailPublication({ source: basePublication, resolved }); + } + }); + return () => { + cancelled = true; + }; + }, [selectedPublication, basePublication, state.baseURL, fetchPublicationDocument]); + + const publication = useMemo(() => { + if (!basePublication || detailPublication?.source !== basePublication) return basePublication; + const { resolved } = detailPublication; + // Prefer the full document's metadata; keep the feed's links/cover when the + // document omits them so downloads and the cover never regress. + return { + metadata: resolved.metadata, + links: resolved.links?.length ? resolved.links : basePublication.links, + images: resolved.images?.length ? resolved.images : basePublication.images, + }; + }, [basePublication, detailPublication]); + const handleDownload = useCallback( async ( href: string, @@ -521,7 +589,7 @@ export default function BrowserPage() { const pathname = decodeURIComponent(new URL(url).pathname); const ext = getFileExtFromMimeType(parsed?.mediaType) || getFileExtFromPath(pathname); - const basename = pathname.replaceAll('/', '_'); + const basename = uniqueId(); const filename = ext ? `${basename}.${ext}` : basename; let dstFilePath = await appService?.resolveFilePath(filename, 'Cache'); console.log('Downloading to:', url, dstFilePath); @@ -559,7 +627,13 @@ export default function BrowserPage() { console.error('OPDS: failed to update source map:', sourceMapError); } } - if (user && book && !book.uploadedAt && settings.autoUpload) { + if ( + user && + book && + !book.uploadedAt && + settings.autoUpload && + isReadestCloudStorageActive(settings) + ) { setTimeout(() => { transferManager.queueUpload(book); }, 3000); diff --git a/apps/readest-app/src/app/opds/utils/opdsPublication.ts b/apps/readest-app/src/app/opds/utils/opdsPublication.ts new file mode 100644 index 000000000..cb2f490c9 --- /dev/null +++ b/apps/readest-app/src/app/opds/utils/opdsPublication.ts @@ -0,0 +1,72 @@ +import { getPublication } from 'foliate-js/opds.js'; +import type { OPDSBaseLink, OPDSPublication } from '@/types/opds'; +import { looksLikeXMLContent, MIME, parseMediaType, parseOPDSXML, resolveURL } from './opdsUtils'; + +// Media type of a standalone OPDS 2.0 publication document. +const OPDS_PUBLICATION_JSON = 'application/opds-publication+json'; + +/** + * Find a publication's canonical document link (`rel="self"`) that dereferences + * to a *full* OPDS publication record. OPDS feeds frequently list publications + * in summary form (title + cover + acquisition links) and carry the complete + * metadata — description, publisher, subjects, language — only in the standalone + * publication document this link points at (readest issue #4749, matching what + * Thorium shows). Returns its href/type, or undefined when the publication has + * no such link. + * + * Recognized document types: OPDS 2.0 JSON (`application/opds-publication+json`) + * and an Atom catalog entry (`application/atom+xml;type=entry`). + */ +export const getPublicationDetailHref = ( + publication: OPDSPublication, +): { href: string; type?: string } | undefined => { + for (const link of publication.links ?? []) { + const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? '']; + if (!link.href || !rels.includes('self')) continue; + const parsed = parseMediaType(link.type); + if (!parsed) continue; + const { mediaType, parameters } = parsed; + if (mediaType === OPDS_PUBLICATION_JSON) return { href: link.href, type: link.type }; + if (mediaType === MIME.ATOM && parameters['type'] === 'entry') { + return { href: link.href, type: link.type }; + } + } + return undefined; +}; + +const absolutizeLinks = ( + links: T[] | undefined, + docURL: string, +): T[] | undefined => + links?.map((link) => (link.href ? { ...link, href: resolveURL(link.href, docURL) } : link)); + +/** + * Parse a fetched OPDS publication document (the body behind a detail link, see + * getPublicationDetailHref) into an OPDSPublication. Supports OPDS 2.0 JSON and + * Atom entry XML. Link and image hrefs are resolved to absolute URLs against the + * document's own URL so the detail view keeps resolving downloads and the cover + * correctly even though it carries the original feed's base URL. Returns null + * when the body is not a recognizable single publication. + */ +export const parsePublicationDocument = (text: string, docURL: string): OPDSPublication | null => { + let publication: OPDSPublication | null = null; + if (looksLikeXMLContent(text)) { + const doc = parseOPDSXML(text); + if (doc.documentElement?.localName !== 'entry') return null; + publication = getPublication(doc.documentElement) as OPDSPublication; + } else { + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return null; + } + if (!json || typeof json !== 'object' || !('metadata' in json)) return null; + publication = json as OPDSPublication; + } + return { + metadata: publication.metadata, + links: absolutizeLinks(publication.links, docURL) ?? [], + images: absolutizeLinks(publication.images, docURL) ?? [], + }; +}; diff --git a/apps/readest-app/src/app/opds/utils/opdsReq.ts b/apps/readest-app/src/app/opds/utils/opdsReq.ts index 65d2f1074..b39f889bd 100644 --- a/apps/readest-app/src/app/opds/utils/opdsReq.ts +++ b/apps/readest-app/src/app/opds/utils/opdsReq.ts @@ -342,11 +342,14 @@ export const fetchWithAuth = async ( const fetchURL = useProxy ? getProxiedURL(cleanUrl, preemptiveAuth || '', false, normalizedCustomHeaders) : cleanUrl; - const headers: Record = { + const baseHeaders: Record = { 'User-Agent': READEST_OPDS_USER_AGENT, Accept: 'application/atom+xml, application/xml, text/xml, */*', ...(!useProxy ? normalizedCustomHeaders : {}), ...(options.headers as Record), + }; + const headers: Record = { + ...baseHeaders, ...(preemptiveAuth && !useProxy ? { Authorization: preemptiveAuth } : {}), }; @@ -358,6 +361,24 @@ export const fetchWithAuth = async ( danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true }, }); + // Calibre in 'digest' (or 'auto' over http) mode rejects a Basic + // Authorization header outright with 400 "Unsupported authentication + // method" instead of returning a 401 challenge, so the preemptive Basic + // header would dead-end the request. Re-issue it without credentials to + // surface the WWW-Authenticate challenge and let the retry below negotiate + // the scheme the server actually wants. + if (res.status === 400 && preemptiveAuth) { + res = await fetch( + useProxy ? getProxiedURL(cleanUrl, '', false, normalizedCustomHeaders) : fetchURL, + { + ...options, + method: options.method || 'GET', + headers: baseHeaders, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true }, + }, + ); + } + // Handle authentication if needed if (!res.ok && (res.status === 401 || res.status === 403) && finalUsername && finalPassword) { const wwwAuthenticate = res.headers.get('WWW-Authenticate'); diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts index 4a3e0ae1e..410416267 100644 --- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts +++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts @@ -1,6 +1,6 @@ import { isOPDSCatalog } from 'foliate-js/opds.js'; import { replace as expandURITemplate, getVariables } from 'foliate-js/uri-template.js'; -import { OPDSBaseLink } from '@/types/opds'; +import { OPDSBaseLink, OPDSCatalog } from '@/types/opds'; import { EXTS } from '@/libs/document'; import { fetchWithAuth } from './opdsReq'; @@ -319,6 +319,25 @@ export const validateOPDSURL = async ( } }; +/** + * Filter the built-in "popular" OPDS catalogs down to those the user hasn't + * already added to their personal list. Matching is by normalized URL (trim + + * lowercase), mirroring the store's `findByUrl` dedup so case/whitespace + * differences still hide a popular entry once it's been added. Disabled + * popular entries are always excluded. Without this an added popular catalog + * would keep rendering in the Popular section and look like a duplicate + * (issue #4782). + */ +export const getUnaddedPopularCatalogs = ( + popularCatalogs: OPDSCatalog[], + addedCatalogs: OPDSCatalog[], +): OPDSCatalog[] => { + const addedUrls = new Set(addedCatalogs.map((c) => c.url.trim().toLowerCase())); + return popularCatalogs.filter( + (catalog) => !catalog.disabled && !addedUrls.has(catalog.url.trim().toLowerCase()), + ); +}; + export const getFileExtFromPath = (pathname: string, delimiter = '/'): string => { const parts = pathname.split(delimiter); for (const ext of Object.values(EXTS)) { diff --git a/apps/readest-app/src/app/reader/components/AutoScrollControl.tsx b/apps/readest-app/src/app/reader/components/AutoScrollControl.tsx new file mode 100644 index 000000000..7b52b58cb --- /dev/null +++ b/apps/readest-app/src/app/reader/components/AutoScrollControl.tsx @@ -0,0 +1,184 @@ +'use client'; + +import clsx from 'clsx'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { MdAdd, MdClose, MdPause, MdPlayArrow, MdRemove } from 'react-icons/md'; +import { Insets } from '@/types/misc'; +import { useEnv } from '@/context/EnvContext'; +import { useReaderStore } from '@/store/readerStore'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { useTranslation } from '@/hooks/useTranslation'; +import { MAX_AUTO_SCROLL_SPEED, MIN_AUTO_SCROLL_SPEED } from '@/services/constants'; + +const INITIAL_SHOW_DURATION = 2500; +const HIDE_DELAY = 2000; + +interface AutoScrollControlProps { + bookKey: string; + paused: boolean; + speed: number; + onTogglePause: () => void; + onAdjustSpeed: (dir: 1 | -1) => void; + onStop: () => void; + gridInsets: Insets; +} + +// The Auto Scroll session pill (#4998): speed −/+ around the current +// percentage, pause/resume, and exit. Fades away while scrolling so the mode +// stays immersive; any mouse movement or pausing (tap on the page) brings it +// back. Same floating-pill chassis as ParagraphBar. +const AutoScrollControl: React.FC = ({ + bookKey, + paused, + speed, + onTogglePause, + onAdjustSpeed, + onStop, + gridInsets, +}) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { hoveredBookKey } = useReaderStore(); + const iconSize = useResponsiveSize(18); + + const [isBarVisible, setIsBarVisible] = useState(true); + const hideTimerRef = useRef | null>(null); + const isHoveredRef = useRef(false); + const pausedRef = useRef(paused); + pausedRef.current = paused; + + const clearHideTimer = useCallback(() => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }, []); + + const startHideTimer = useCallback( + (delay: number = HIDE_DELAY) => { + clearHideTimer(); + hideTimerRef.current = setTimeout(() => { + if (!isHoveredRef.current && !pausedRef.current) { + setIsBarVisible(false); + } + }, delay); + }, + [clearHideTimer], + ); + + useEffect(() => { + if (paused) { + clearHideTimer(); + setIsBarVisible(true); + } else { + startHideTimer(INITIAL_SHOW_DURATION); + } + return clearHideTimer; + }, [paused, startHideTimer, clearHideTimer]); + + useEffect(() => { + let lastMoveTime = 0; + const handleMouseMove = () => { + const now = Date.now(); + if (now - lastMoveTime < 100) return; + lastMoveTime = now; + setIsBarVisible(true); + startHideTimer(); + }; + window.addEventListener('mousemove', handleMouseMove, { passive: true }); + return () => window.removeEventListener('mousemove', handleMouseMove); + }, [startHideTimer]); + + const isVisible = isBarVisible && hoveredBookKey !== bookKey; + + const buttonClass = clsx( + 'flex items-center justify-center rounded-full p-1.5', + 'transition-all duration-200 ease-out', + 'not-eink:hover:bg-base-200 active:scale-90', + ); + + return ( +
{ + isHoveredRef.current = true; + clearHideTimer(); + setIsBarVisible(true); + }} + onMouseLeave={() => { + isHoveredRef.current = false; + startHideTimer(); + }} + > +
+ + +
+ {speed}% +
+ + + +
+ + + +
+ + +
+
+ ); +}; + +export default AutoScrollControl; diff --git a/apps/readest-app/src/app/reader/components/AutoscrollIndicator.tsx b/apps/readest-app/src/app/reader/components/AutoscrollIndicator.tsx new file mode 100644 index 000000000..03837b1d3 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/AutoscrollIndicator.tsx @@ -0,0 +1,35 @@ +import clsx from 'clsx'; +import React from 'react'; +import { AutoscrollAnchor } from '../hooks/useMiddleClickAutoscroll'; + +// The anchor marker shown while middle-click autoscroll is engaged: a circle +// with arrows along the scroll axis, akin to the browsers' autoscroll bullet. +const AutoscrollIndicator: React.FC<{ anchor: AutoscrollAnchor }> = ({ anchor }) => ( + +); + +export default AutoscrollIndicator; diff --git a/apps/readest-app/src/app/reader/components/BooksGrid.tsx b/apps/readest-app/src/app/reader/components/BooksGrid.tsx index 8313e832c..2d93bee45 100644 --- a/apps/readest-app/src/app/reader/components/BooksGrid.tsx +++ b/apps/readest-app/src/app/reader/components/BooksGrid.tsx @@ -10,7 +10,7 @@ import { useSidebarStore } from '@/store/sidebarStore'; import { useBookDataStore } from '@/store/bookDataStore'; import { useTranslation } from '@/hooks/useTranslation'; import { getGridTemplate, getInsetEdges } from '@/utils/grid'; -import { getViewInsets } from '@/utils/insets'; +import { useContentInsets } from '../hooks/useContentInsets'; import SearchResultsNav from './sidebar/SearchResultsNav'; import BooknotesNav from './sidebar/BooknotesNav'; import FoliateViewer from './FoliateViewer'; @@ -118,33 +118,10 @@ const BookCellInner: React.FC = ({ const config = getConfig(bookKey); const { book, bookDoc } = bookData || {}; - // viewSettings drives both viewInsets and the inset-derived geometry. - // Memoize off its identity so contentInsets stays stable while the - // user is just turning pages — viewSettings only changes when the - // user toggles settings, not on every relocate. Same logic for - // gridInsets which is keyed off the resolved Insets numbers. - const viewInsets = useMemo( - () => (viewSettings ? getViewInsets(viewSettings) : { top: 0, right: 0, bottom: 0, left: 0 }), - [viewSettings], - ); - const contentInsets = useMemo( - () => ({ - top: gridInsets.top + viewInsets.top, - right: gridInsets.right + viewInsets.right, - bottom: gridInsets.bottom + viewInsets.bottom, - left: gridInsets.left + viewInsets.left, - }), - [ - gridInsets.top, - gridInsets.right, - gridInsets.bottom, - gridInsets.left, - viewInsets.top, - viewInsets.right, - viewInsets.bottom, - viewInsets.left, - ], - ); + // viewInsets/contentInsets stay stable while the user is just turning pages + // (margins are unchanged) but update when a margin setting changes — even + // though saveViewSettings mutates viewSettings in place (#4898). + const { viewInsets, contentInsets } = useContentInsets(viewSettings, gridInsets); // Stable callback so HeaderBar doesn't see a new prop reference per // BooksGrid render. @@ -165,6 +142,10 @@ const BookCellInner: React.FC = ({ return (
| null>(null); const [scrollMargins, setScrollMargins] = useState({ top: 0, bottom: 0 }); const docLoaded = useRef(false); + // A pending anti-flash timer must not fire setNavigating on an unmounted component. + useEffect(() => { + return () => { + if (navSpinnerTimerRef.current) clearTimeout(navSpinnerTimerRef.current); + }; + }, []); + useAutoFocus({ ref: containerRef }); useDiscordPresence( @@ -148,7 +166,7 @@ const FoliateViewer: React.FC<{ useProgressAutoSave(bookKey); useBookCoverAutoSave(bookKey); const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey); - useWebDAVSync(bookKey); + useFileSync(bookKey); useTextTranslation(bookKey, viewRef.current); // Coalesce setProgress writes within a single animation frame. @@ -195,6 +213,7 @@ const FoliateViewer: React.FC<{ pageInfo, detail.time, detail.range, + detail.fraction, ); }, [bookKey, setProgress]); @@ -202,6 +221,20 @@ const FoliateViewer: React.FC<{ // Always stash the latest detail; if another rAF is already pending // it'll pick this up and the intermediate states are skipped. pendingRelocateRef.current = event as CustomEvent; + // requestAnimationFrame is paused while the WebView is backgrounded, so the + // rAF-coalesced commit below would never run during background TTS - which + // freezes book.progress (and readerProgressStore, and the home-screen + // widget that reads them). Commit synchronously when hidden so progress + // stays current. The page-follow relocate still fires; only the commit was + // being deferred. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + if (relocateRafRef.current != null) { + cancelAnimationFrame(relocateRafRef.current); + relocateRafRef.current = null; + } + commitRelocate(); + return; + } if (relocateRafRef.current != null) return; relocateRafRef.current = requestAnimationFrame(commitRelocate); }; @@ -253,6 +286,7 @@ const FoliateViewer: React.FC<{ 'language', 'sanitizer', 'simplecc', + 'nbsp', 'proofread', 'warichu', ], @@ -382,6 +416,8 @@ const FoliateViewer: React.FC<{ detail.doc.addEventListener('keyup', handleKeyup.bind(null, bookKey)); detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey)); detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey)); + detail.doc.addEventListener('mousemove', handleMousemove.bind(null, bookKey)); + detail.doc.addEventListener('auxclick', handleAuxclick.bind(null, bookKey)); detail.doc.addEventListener( 'click', handleClick.bind(null, bookKey, doubleClickDisabled, !!bookData?.isFixedLayout), @@ -441,6 +477,20 @@ const FoliateViewer: React.FC<{ }; }; + const navigateStartHandler = useCallback(() => { + if (navSpinnerTimerRef.current) clearTimeout(navSpinnerTimerRef.current); + // Delay so instant same-section jumps don't flash the spinner. + navSpinnerTimerRef.current = setTimeout(() => setNavigating(true), 200); + }, []); + + const navigateEndHandler = useCallback(() => { + if (navSpinnerTimerRef.current) { + clearTimeout(navSpinnerTimerRef.current); + navSpinnerTimerRef.current = null; + } + setNavigating(false); + }, []); + const stabilizedHandler = useCallback(() => { setLoading(false); // Layout/relayout warichu after paginator has set column-width via columnize() @@ -492,6 +542,8 @@ const FoliateViewer: React.FC<{ const { handlePageFlip } = usePagination(bookKey, viewRef, containerRef); const mouseHandlers = useMouseEvent(bookKey, handlePageFlip); const touchHandlers = useTouchEvent(bookKey); + const autoscrollAnchor = useMiddleClickAutoscroll(bookKey, viewRef, containerRef); + const autoScroll = useAutoScroll(bookKey, viewRef); const [selectedImage, setSelectedImage] = useState(null); const [selectedTableHtml, setSelectedTableHtml] = useState(null); @@ -591,11 +643,15 @@ const FoliateViewer: React.FC<{ useOpenMediaEvent(bookKey, handleImagePress, handleTablePress); + useCapturedTurn(bookKey, viewRef); + useFoliateEvents(viewRef.current, { onLoad: docLoadHandler, onStabilized: stabilizedHandler, onRelocate: progressRelocateHandler, onRendererRelocate: docRelocateHandler, + onNavigateStart: navigateStartHandler, + onNavigateEnd: navigateEndHandler, }); useEffect(() => { @@ -686,10 +742,14 @@ const FoliateViewer: React.FC<{ } else { view.renderer.removeAttribute('animated'); } - if (viewSettings.disableSwipe) { - view.renderer.setAttribute('no-swipe', ''); - } else { - view.renderer.removeAttribute('no-swipe'); + applyPageTurnAttributes(view, viewSettings, bookDoc.rendition?.layout === 'pre-paginated'); + // iOS WebKit composites large/persistent page layers without the Android + // high-DPR Blink freeze, so opt this renderer into the GPU-accelerated + // page-turn path (persistent compositor layers + no main-thread + // rafAnimateScroll fallback) to keep 120Hz ProMotion turns smooth + // (readest#4768). + if (appService?.isIOSApp) { + view.renderer.setAttribute('gpu-composite', ''); } if (appService?.isAndroidApp) { if (eink) { @@ -745,20 +805,31 @@ const FoliateViewer: React.FC<{ }, []); const applyMarginAndGap = () => { - const viewSettings = getViewSettings(bookKey)!; + // Invoked from effects/observers that can fire after the book is torn down, + // when getViewSettings(bookKey) returns null. The `!` assertion hid that, so + // the reads below (getViewInsets, viewSettings.showHeader) crashed on null + // (READEST-2V). Bail: there is no view left to lay out. + const viewSettings = getViewSettings(bookKey); + if (!viewSettings) return; const viewState = getViewState(bookKey); + const bookData = getBookData(bookKey); const viewInsets = getViewInsets(viewSettings); const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder; const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader; const showDoubleBorderFooter = showDoubleBorder && viewSettings.showFooter; const showTopHeader = viewSettings.showHeader && !viewSettings.vertical; - const showBottomFooter = viewSettings.showFooter && !viewSettings.vertical; + // The bottom band is reserved only while the footer displays something + // there (and never in scrolled mode, where the info floats in pills) — + // see footerReservesBand. Otherwise the empty reservation shows as a + // full-width blank bar that steals space from the book text. + const showBottomFooter = footerReservesBand(viewSettings) && !viewSettings.vertical; const moreTopInset = showTopHeader ? Math.max(0, 16 - insets.top) : 0; - const ttsBarHeight = - viewState?.ttsEnabled && viewSettings.showTTSBar ? 52 + gridInsets.bottom * 0.33 : 0; + const miniPlayerClearance = viewState?.ttsEnabled + ? TTS_MINI_PLAYER_CLEARANCE + gridInsets.bottom * 0.33 + : 0; const moreBottomInset = showBottomFooter - ? Math.max(0, Math.max(ttsBarHeight, 16) - insets.bottom) - : Math.max(0, ttsBarHeight); + ? Math.max(0, Math.max(miniPlayerClearance, 16) - insets.bottom) + : Math.max(0, miniPlayerClearance); const moreRightInset = showDoubleBorderHeader ? 32 : 0; const moreLeftInset = showDoubleBorderFooter ? 32 : 0; const topMargin = (showTopHeader ? insets.top : viewInsets.top) + moreTopInset; @@ -776,8 +847,10 @@ const FoliateViewer: React.FC<{ const safeBottomPadding = appService?.hasSafeAreaInset ? gridInsets.bottom * 0.33 : 0; const footerBarHeight = safeBottomPadding + viewSettings.marginBottomPx; const scrollTop = headerVisible ? gridInsets.top + viewSettings.marginTopPx : 0; - const scrollBottom = footerVisible ? Math.max(footerBarHeight, ttsBarHeight) : ttsBarHeight; - setScrollMargins({ top: scrollTop, bottom: scrollBottom }); + const scrollBottom = footerVisible + ? Math.max(footerBarHeight, miniPlayerClearance) + : miniPlayerClearance; + setScrollMargins({ top: bookData?.isFixedLayout ? 0 : scrollTop, bottom: scrollBottom }); } else { setScrollMargins({ top: 0, bottom: 0 }); } @@ -824,6 +897,7 @@ const FoliateViewer: React.FC<{ viewSettings?.overrideColor, viewSettings?.invertImgColorInDark, viewSettings?.applyThemeToPDF, + viewSettings?.contrast, viewSettings?.hideScrollbar, ]); @@ -888,10 +962,17 @@ const FoliateViewer: React.FC<{ viewSettings?.doubleBorder, viewSettings?.showHeader, viewSettings?.showFooter, - viewSettings?.showTTSBar, viewSettings?.scrolled, viewSettings?.noContinuousScroll, viewState?.ttsEnabled, + // footerReservesBand inputs: the band must collapse/return live when the + // user flips these settings. + viewSettings?.showStickyProgressBar, + viewSettings?.showRemainingTime, + viewSettings?.showRemainingPages, + viewSettings?.showProgressInfo, + viewSettings?.showCurrentTime, + viewSettings?.showCurrentBatteryStatus, ]); return ( @@ -928,9 +1009,21 @@ const FoliateViewer: React.FC<{ {...mouseHandlers} {...touchHandlers} /> + {autoscrollAnchor && } + {autoScroll.active && ( + + )} - {((!docLoaded.current && loading) || viewState?.loading) && ( + {((!docLoaded.current && loading) || navigating || viewState?.loading) && (
diff --git a/apps/readest-app/src/app/reader/components/HintInfo.tsx b/apps/readest-app/src/app/reader/components/HintInfo.tsx index 2524b5a30..91dd9c190 100644 --- a/apps/readest-app/src/app/reader/components/HintInfo.tsx +++ b/apps/readest-app/src/app/reader/components/HintInfo.tsx @@ -60,8 +60,6 @@ const HintInfo: React.FC = ({ }; }, [hintMessage]); - if (!hintMessage) return null; - return ( <>
= ({ >

{hintMessage || ''} diff --git a/apps/readest-app/src/app/reader/components/ImageViewer.tsx b/apps/readest-app/src/app/reader/components/ImageViewer.tsx index 149c19ff4..b576c5b40 100644 --- a/apps/readest-app/src/app/reader/components/ImageViewer.tsx +++ b/apps/readest-app/src/app/reader/components/ImageViewer.tsx @@ -40,6 +40,7 @@ const ImageViewer: React.FC = ({ const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); + const [isWheelZooming, setIsWheelZooming] = useState(false); const [showZoomLabel, setShowZoomLabel] = useState(true); const lastTouchDistance = useRef(0); const dragStart = useRef({ x: 0, y: 0 }); @@ -47,10 +48,25 @@ const ImageViewer: React.FC = ({ const containerRef = useRef(null); const imageRef = useRef(null); const zoomLabelTimeoutRef = useRef | null>(null); + const wheelZoomEndTimeoutRef = useRef | null>(null); // Escape (desktop) and Android Back key → close the viewer. useKeyDownActions({ onCancel: onClose }); + // A macOS trackpad pinch arrives as a rapid stream of ctrl+wheel events. + // Flag the gesture as active so the transform transition is suppressed while + // it streams (see the transition note below), then clear it shortly after the + // last event since wheel has no explicit gesture-end. + const markWheelZooming = () => { + setIsWheelZooming(true); + if (wheelZoomEndTimeoutRef.current) { + clearTimeout(wheelZoomEndTimeoutRef.current); + } + wheelZoomEndTimeoutRef.current = setTimeout(() => { + setIsWheelZooming(false); + }, 200); + }; + const hideZoomLabelAfterDelay = () => { if (zoomLabelTimeoutRef.current) { clearTimeout(zoomLabelTimeoutRef.current); @@ -154,6 +170,9 @@ const ImageViewer: React.FC = ({ if (zoomLabelTimeoutRef.current) { clearTimeout(zoomLabelTimeoutRef.current); } + if (wheelZoomEndTimeoutRef.current) { + clearTimeout(wheelZoomEndTimeoutRef.current); + } }; }, []); @@ -171,6 +190,8 @@ const ImageViewer: React.FC = ({ const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; + markWheelZooming(); + const delta = e.deltaY; const newScale = Math.min( Math.max(scale * Math.exp(-delta * WHEEL_SENSITIVITY), MIN_SCALE), @@ -519,10 +540,12 @@ const ImageViewer: React.FC = ({ maxWidth: '100%', maxHeight: '100%', transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`, - // No transition while dragging: the 0.05s ease made the image lag - // behind the cursor, which flickered on desktop (#4451). Keep the - // smoothing only for discrete zoom (buttons, double-click, wheel). - transition: isDragging ? 'none' : 'transform 0.05s ease-out', + // No transition during continuous gestures: the 0.05s ease made the + // image lag behind a moving pointer, which flickered on desktop + // (#4451). The same lag flickered a trackpad pinch (a rapid + // ctrl+wheel stream) on macOS (#4742). Keep the smoothing only for + // discrete zoom (buttons, double-click, keyboard). + transition: isDragging || isWheelZooming ? 'none' : 'transform 0.05s ease-out', // Promote to a GPU layer so transform changes don't repaint the // page (the `transform-gpu` class is overridden by this inline // transform, so its hint is lost). diff --git a/apps/readest-app/src/app/reader/components/ProgressBar.tsx b/apps/readest-app/src/app/reader/components/ProgressBar.tsx index 4fa4ff3d5..e31010cde 100644 --- a/apps/readest-app/src/app/reader/components/ProgressBar.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressBar.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React, { useEffect, useState } from 'react'; +import React, { useMemo } from 'react'; import { Trans } from 'react-i18next'; import type { Insets } from '@/types/misc'; import { useEnv } from '@/context/EnvContext'; @@ -7,12 +7,15 @@ import { useReaderStore } from '@/store/readerStore'; import { useBookProgress } from '@/store/readerProgressStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useBookDataStore } from '@/store/bookDataStore'; -import { formatNumber, formatProgress, getReferencePageInfo } from '@/utils/progress'; -import { saveViewSettings } from '@/helpers/settings'; -import { eventDispatcher } from '@/utils/event'; +import { + formatNumber, + formatProgress, + getChapterTickFractions, + getReferencePageInfo, +} from '@/utils/progress'; import { SIZE_PER_LOC, SIZE_PER_TIME_UNIT } from '@/services/constants'; -import type { ProgressBarMode } from '@/types/book.ts'; import StatusInfo from './StatusInfo.tsx'; +import StickyProgressBar from './StickyProgressBar.tsx'; interface ProgressBarProps { bookKey: string; @@ -28,7 +31,7 @@ const ProgressBar: React.FC = ({ gridInsets, }) => { const _ = useTranslation(); - const { envConfig, appService } = useEnv(); + const { appService } = useEnv(); const getBookData = useBookDataStore((s) => s.getBookData); const getViewSettings = useReaderStore((s) => s.getViewSettings); const getView = useReaderStore((s) => s.getView); @@ -68,6 +71,18 @@ const ProgressBar: React.FC = ({ ? `${referenceInfo.current}${isVertical ? ' · ' : ' / '}${referenceInfo.total}` : formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang); + // Sticky progress bar is horizontal-only; vertical mode keeps its side footer. + const stickyBarActive = viewSettings.showStickyProgressBar && !isVertical; + const tickFractions = useMemo( + () => (stickyBarActive ? getChapterTickFractions(view, bookData?.bookDoc?.toc) : []), + [stickyBarActive, view, bookData?.bookDoc?.toc], + ); + // Same size-domain as the chapter ticks; falls back to the page fraction + // before the first relocate has populated progress.fraction. + const fillFraction = + progress?.fraction ?? + (pageInfo && pageInfo.total > 0 ? (pageInfo.current + 1) / pageInfo.total : 0); + const { page: current = 0, pages: total = 0 } = view?.renderer || {}; const pagesLeft = bookData?.isFixedLayout ? pageInfo @@ -113,106 +128,36 @@ const ProgressBar: React.FC = ({ }) : ''; - const [progressBarMode, setProgressBarMode] = useState(viewSettings.progressInfoMode); - const hasRemainingInfo = viewSettings.showRemainingTime || viewSettings.showRemainingPages; - const hasProgressInfo = viewSettings.showProgressInfo; const hasTimeInfo = viewSettings.showCurrentTime; const hasBatteryInfo = viewSettings.showCurrentBatteryStatus; - const cycleProgressInfoModes = () => { - if (!viewSettings.tapToToggleFooter) return; - const modeSequence: string[] = [ - 'all', - `${hasRemainingInfo ? 'remaining+' : ''}${hasProgressInfo ? 'progress' : ''}`, - `${hasRemainingInfo ? 'remaining' : ''}`, - `${hasProgressInfo ? 'progress' : ''}`, - `${hasBatteryInfo ? 'battery+' : ''}${hasTimeInfo ? 'time' : ''}`, - `${hasBatteryInfo ? 'battery' : ''}`, - `${hasTimeInfo ? 'time' : ''}`, - 'none', - ] - .map((mode) => mode.replace(/^\+|\+$/g, '')) - .filter((mode) => mode !== '') - .filter((mode, index, self) => self.indexOf(mode) === index); - - const currentMode = progressBarMode; - const currentIndex = modeSequence.indexOf(currentMode); - for (let i = 1; i <= modeSequence.length; i++) { - const nextIndex = (currentIndex + i) % modeSequence.length; - const nextMode = modeSequence[nextIndex]!; - - const currentRenders = { - remaining: - currentMode === 'all' || currentMode.includes('remaining') ? hasRemainingInfo : false, - progress: - currentMode === 'all' || currentMode.includes('progress') ? hasProgressInfo : false, - battery: currentMode === 'all' || currentMode.includes('battery') ? hasBatteryInfo : false, - time: currentMode === 'all' || currentMode.includes('time') ? hasTimeInfo : false, - none: currentMode === 'none', - }; - - const nextRenders = { - remaining: nextMode === 'all' || nextMode.includes('remaining') ? hasRemainingInfo : false, - progress: nextMode === 'all' || nextMode.includes('progress') ? hasProgressInfo : false, - battery: nextMode === 'all' || nextMode.includes('battery') ? hasBatteryInfo : false, - time: nextMode === 'all' || nextMode.includes('time') ? hasTimeInfo : false, - none: nextMode === 'none', - }; - - const isDifferent = - currentRenders.remaining !== nextRenders.remaining || - currentRenders.progress !== nextRenders.progress || - currentRenders.battery !== nextRenders.battery || - currentRenders.time !== nextRenders.time || - currentRenders.none !== nextRenders.none; - if (isDifferent) { - setProgressBarMode(nextMode); - return; - } - } - - const nextIndex = (currentIndex + 1) % modeSequence.length; - setProgressBarMode(modeSequence[nextIndex]!); - }; - - useEffect(() => { - saveViewSettings(envConfig, bookKey, 'progressInfoMode', progressBarMode as ProgressBarMode); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [progressBarMode]); - - // Self-heal a stuck "none" (or partial) mode left over from a prior - // tap-to-toggle session. Without this, dismissing the footer via tap - // and then disabling the toggle in settings would leave the footer - // permanently hidden — the user's only way back to a visible footer - // would be to re-enable the toggle and tap through the cycle. - useEffect(() => { - if (!viewSettings.tapToToggleFooter && progressBarMode !== 'all') { - setProgressBarMode('all'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewSettings.tapToToggleFooter]); - - const isMobile = appService?.isMobile || window.innerWidth < 640; - const showStatusInfo = - (progressBarMode === 'all' || - progressBarMode.includes('battery') || - progressBarMode.includes('time')) && - (hasTimeInfo || hasBatteryInfo); + // The footer is display-only: the full-width container stays + // pointer-events-none so it never intercepts taps or text selection over + // book content along the bottom of the page. To hide it, use Settings → + // Layout → Show Footer. + // + // Scrolled mode reserves no bottom band (footerReservesBand) — the info + // floats over the book text, so each segment carries its own shrink-wrapped + // pill backdrop to stay legible instead of a full-width bar. + const pillClass = + viewSettings.scrolled && + !isVertical && + !stickyBarActive && + 'progress-pill eink-bordered rounded-md bg-base-100/85 px-1.5'; + const showStatusInfo = hasTimeInfo || hasBatteryInfo; return (
{ - if (eventDispatcher.dispatchSync('iframe-single-click')) return; - cycleProgressInfoModes(); - }} aria-label={[ progress ? _('On {{current}} of {{total}} page', { @@ -244,84 +189,98 @@ const ProgressBar: React.FC = ({ >