From d762cb6f6b23cfea727bed12c4895c30fba199ed Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 18:02:20 +0200 Subject: [PATCH 01/12] build: streamline CI and add AUR development package --- .gitattributes | 2 + .github/workflows/aur-git.yml | 98 +++++++++++++++ .github/workflows/ci.yml | 100 ++++++++++++--- .github/workflows/release.yml | 209 ++++++++----------------------- README.md | 15 ++- THIRD_PARTY_NOTICES.md | 18 ++- _meta/docs/TESTING.md | 5 +- _meta/docs/UPDATING.md | 8 +- package.json | 1 + packaging/aur-git/PKGBUILD | 41 ++++++ packaging/aur-git/README.md | 15 +++ packaging/aur-git/legio-launcher | 3 + scripts/README.md | 7 +- scripts/ci_plan.py | 90 +++++++++++++ scripts/test_ci_plan.py | 98 +++++++++++++++ scripts/test_documentation.mjs | 42 +++++++ scripts/test_packaging.py | 35 ++++++ src-tauri/tauri.conf.json | 8 +- 18 files changed, 605 insertions(+), 190 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/aur-git.yml create mode 100644 packaging/aur-git/PKGBUILD create mode 100644 packaging/aur-git/README.md create mode 100644 packaging/aur-git/legio-launcher create mode 100644 scripts/ci_plan.py create mode 100644 scripts/test_ci_plan.py create mode 100644 scripts/test_documentation.mjs create mode 100644 scripts/test_packaging.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..58669a43 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +packaging/aur-git/PKGBUILD text eol=lf +packaging/aur-git/legio-launcher text eol=lf diff --git a/.github/workflows/aur-git.yml b/.github/workflows/aur-git.yml new file mode 100644 index 00000000..4930c86b --- /dev/null +++ b/.github/workflows/aur-git.yml @@ -0,0 +1,98 @@ +name: AUR development package + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - packaging/aur-git/PKGBUILD + - packaging/aur-git/legio-launcher + - .github/workflows/aur-git.yml + pull_request: + branches: [main] + paths: + - packaging/aur-git/PKGBUILD + - packaging/aur-git/legio-launcher + - .github/workflows/aur-git.yml + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: aur-git-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + package: + name: Build Arch source package + runs-on: ubuntu-latest + container: archlinux:base-devel + steps: + - name: Install packaging tools + run: | + printf '\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n' >> /etc/pacman.conf + pacman -Syu --noconfirm --needed git openssh sudo + useradd -m builder + printf 'builder ALL=(ALL:ALL) NOPASSWD: ALL\n' > /etc/sudoers.d/builder + + - name: Check out recipe + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Build and generate metadata + run: | + cp -r packaging/aur-git /tmp/aur-package + chown -R builder:builder /tmp/aur-package + bash -n /tmp/aur-package/PKGBUILD + su builder -c 'cd /tmp/aur-package && makepkg --syncdeps --noconfirm' + su builder -c 'cd /tmp/aur-package && makepkg --printsrcinfo > .SRCINFO' + mkdir aur-files + cp /tmp/aur-package/{PKGBUILD,.SRCINFO,legio-launcher} aur-files/ + + - name: Upload validated recipe + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: aur-git-recipe + path: aur-files/ + include-hidden-files: true + retention-days: 7 + if-no-files-found: error + + publish: + name: Publish AUR recipe + needs: package + if: github.event_name == 'workflow_dispatch' || (github.ref == 'refs/heads/main' && github.event_name == 'push') + runs-on: ubuntu-latest + steps: + - name: Download validated recipe + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: aur-git-recipe + path: aur-files + + - name: Create or update AUR package base + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + AUR_KNOWN_HOSTS: ${{ vars.AUR_KNOWN_HOSTS }} + run: | + test -n "$AUR_SSH_PRIVATE_KEY" || { echo "Missing AUR_SSH_PRIVATE_KEY"; exit 1; } + test -n "$AUR_KNOWN_HOSTS" || { echo "Missing verified AUR_KNOWN_HOSTS"; exit 1; } + install -d -m 700 ~/.ssh + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + printf '%s\n' "$AUR_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + git clone ssh://aur@aur.archlinux.org/legio-launcher-git.git aur-repo + cp aur-files/{PKGBUILD,.SRCINFO,legio-launcher} aur-repo/ + cd aur-repo + git config user.name 'Legio release bot' + git config user.email 'actions@users.noreply.github.com' + git add PKGBUILD .SRCINFO legio-launcher + if ! git diff --cached --quiet; then + git commit -m "Update development package recipe" + git push origin HEAD:master + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ac37a68..ca2c81c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,12 @@ name: CI on: - workflow_call: + workflow_dispatch: + inputs: + test_packages: + description: Build downloadable Linux and Windows test packages + type: boolean + default: false pull_request: branches: - main @@ -16,9 +21,51 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + plan: + name: Select checks and release + runs-on: ubuntu-latest + outputs: + code: ${{ steps.plan.outputs.code }} + release: ${{ steps.plan.outputs.release }} + version: ${{ steps.plan.outputs.version }} + tag: ${{ steps.plan.outputs.tag }} + prerelease: ${{ steps.plan.outputs.prerelease }} + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Select checks + id: plan + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python scripts/ci_plan.py + + docs: + name: Documentation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: 22 + + - name: Check local links and images + run: node --test scripts/test_documentation.mjs + frontend: name: Frontend + needs: plan + if: needs.plan.outputs.code == 'true' runs-on: ubuntu-latest steps: - name: Check out repository @@ -88,6 +135,8 @@ jobs: rust: name: Rust + needs: plan + if: needs.plan.outputs.code == 'true' runs-on: ubuntu-latest steps: - name: Check out repository @@ -128,7 +177,9 @@ jobs: run: cargo test --manifest-path src-tauri/Cargo.toml --all-features --locked tauri-smoke: - name: Tauri smoke (${{ matrix.name }}) + name: Native checks (${{ matrix.name }}) + needs: plan + if: needs.plan.outputs.code == 'true' runs-on: ${{ matrix.os }} env: VCPKGRS_TRIPLET: x64-windows-static-md @@ -321,31 +372,31 @@ jobs: run: cargo test --manifest-path src-tauri/Cargo.toml --locked --lib finalize_install::tests::recovers_after_database_commit_fails_and_database_reopens - name: Build Tauri application - if: github.event_name != 'pull_request' + if: needs.plan.outputs.release != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.test_packages) shell: bash run: corepack pnpm tauri build --debug --no-bundle - name: Build Linux test AppImage - if: runner.os == 'Linux' && github.event_name == 'pull_request' + if: runner.os == 'Linux' && github.event_name == 'workflow_dispatch' && inputs.test_packages shell: bash run: 'corepack pnpm tauri build --bundles appimage --config ''{"bundle":{"createUpdaterArtifacts":false}}''' - name: Record Linux AppImage checksums and revision - if: runner.os == 'Linux' && github.event_name == 'pull_request' + if: runner.os == 'Linux' && github.event_name == 'workflow_dispatch' && inputs.test_packages shell: bash env: - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SOURCE_SHA: ${{ github.sha }} run: | directory="src-tauri/target/release/bundle/appimage" (cd "$directory" && sha256sum -- *.AppImage) > "$directory/SHA256SUMS.txt" - printf 'Built revision: %s\nPR head: %s\nTest AppImage. Updater signing is disabled; this is not a published release.\n' \ - "$(git rev-parse HEAD)" "$PR_HEAD_SHA" > "$directory/build-info.txt" + printf 'Built revision: %s\nSource revision: %s\nTest AppImage. Updater signing is disabled; this is not a published release.\n' \ + "$(git rev-parse HEAD)" "$SOURCE_SHA" > "$directory/build-info.txt" - name: Upload Linux test AppImage - if: runner.os == 'Linux' && github.event_name == 'pull_request' + if: runner.os == 'Linux' && github.event_name == 'workflow_dispatch' && inputs.test_packages uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: legio-linux-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + name: legio-linux-test-${{ github.run_id }}-${{ github.sha }} path: | src-tauri/target/release/bundle/appimage/*.AppImage src-tauri/target/release/bundle/appimage/SHA256SUMS.txt @@ -354,17 +405,17 @@ jobs: retention-days: 14 - name: Build Windows test installer - if: runner.os == 'Windows' && github.event_name == 'pull_request' + if: runner.os == 'Windows' && github.event_name == 'workflow_dispatch' && inputs.test_packages shell: bash env: CARGO_PROFILE_RELEASE_DEBUG: "1" run: 'corepack pnpm tauri build --bundles nsis --config ''{"bundle":{"createUpdaterArtifacts":false}}''' - name: Record Windows installer checksums and revision - if: runner.os == 'Windows' && github.event_name == 'pull_request' + if: runner.os == 'Windows' && github.event_name == 'workflow_dispatch' && inputs.test_packages shell: pwsh env: - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SOURCE_SHA: ${{ github.sha }} run: | $directory = "src-tauri/target/release/bundle/nsis" $installers = @(Get-ChildItem "$directory/*.exe") @@ -375,14 +426,14 @@ jobs: } | Set-Content "$directory/SHA256SUMS.txt" -Encoding utf8 $builtRevision = git rev-parse HEAD if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - @("Built revision: $builtRevision", "PR head: $env:PR_HEAD_SHA", "Test installer. Updater signing is disabled; this is not a published release.") | + @("Built revision: $builtRevision", "Source revision: $env:SOURCE_SHA", "Test installer. Updater signing is disabled; this is not a published release.") | Set-Content "$directory/build-info.txt" -Encoding utf8 - name: Upload Windows test installer - if: runner.os == 'Windows' && github.event_name == 'pull_request' + if: runner.os == 'Windows' && github.event_name == 'workflow_dispatch' && inputs.test_packages uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: legio-windows-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + name: legio-windows-test-${{ github.run_id }}-${{ github.sha }} path: | src-tauri/target/release/bundle/nsis/*.exe src-tauri/target/release/bundle/nsis/SHA256SUMS.txt @@ -391,10 +442,23 @@ jobs: retention-days: 14 - name: Upload matching Windows native symbols - if: runner.os == 'Windows' && github.event_name == 'pull_request' + if: runner.os == 'Windows' && github.event_name == 'workflow_dispatch' && inputs.test_packages uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: legio-windows-symbols-pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + name: legio-windows-symbols-test-${{ github.run_id }}-${{ github.sha }} path: src-tauri/target/release/legio.pdb if-no-files-found: error retention-days: 14 + + release: + name: Release + needs: [plan, docs, frontend, rust, tauri-smoke] + if: needs.plan.outputs.release == 'true' + permissions: + contents: write + uses: ./.github/workflows/release.yml + with: + version: ${{ needs.plan.outputs.version }} + tag: ${{ needs.plan.outputs.tag }} + prerelease: ${{ needs.plan.outputs.prerelease == 'true' }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1706bfdd..87c4d10b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,22 @@ -name: Release +name: Release packages on: - push: - branches: - - main + workflow_call: + inputs: + version: + required: true + type: string + tag: + required: true + type: string + prerelease: + required: true + type: boolean + secrets: + TAURI_SIGNING_PRIVATE_KEY: + required: true + AUR_SSH_PRIVATE_KEY: + required: true permissions: contents: read @@ -13,149 +26,8 @@ concurrency: cancel-in-progress: false jobs: - detect: - name: Detect version change - runs-on: ubuntu-latest - outputs: - release: ${{ steps.version.outputs.release }} - version: ${{ steps.version.outputs.version }} - tag: ${{ steps.version.outputs.tag }} - prerelease: ${{ steps.version.outputs.prerelease }} - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Detect Cargo version change - id: version - env: - BEFORE_SHA: ${{ github.event.before }} - shell: bash - run: | - python <<'PY' - import os - import re - import subprocess - import tomllib - - output = os.environ["GITHUB_OUTPUT"] - before = os.environ["BEFORE_SHA"] - - def write(name, value): - with open(output, "a", encoding="utf-8") as handle: - handle.write(f"{name}={value}\n") - - manifest = "src-tauri/Cargo.toml" - if not os.path.isfile(manifest): - write("release", "false") - raise SystemExit(0) - - with open(manifest, "rb") as handle: - current_data = tomllib.load(handle) - - try: - current = current_data["package"]["version"] - except KeyError as exc: - raise SystemExit("src-tauri/Cargo.toml must define package.version") from exc - - try: - previous_raw = subprocess.check_output( - ["git", "show", f"{before}:{manifest}"], - stderr=subprocess.DEVNULL, - ) - except subprocess.CalledProcessError: - write("release", "false") - raise SystemExit(0) - - previous_data = tomllib.loads(previous_raw.decode("utf-8")) - try: - previous = previous_data["package"]["version"] - except KeyError as exc: - raise SystemExit("Previous src-tauri/Cargo.toml does not define package.version") from exc - - if current == previous: - write("release", "false") - raise SystemExit(0) - - semver = re.compile( - r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" - r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" - ) - - def parse(value): - match = semver.fullmatch(value) - if not match: - raise SystemExit(f"Invalid Semantic Version: {value}") - major, minor, patch, prerelease, _build = match.groups() - identifiers = prerelease.split(".") if prerelease else None - return int(major), int(minor), int(patch), identifiers - - def compare_prerelease(left, right): - if left is None and right is None: - return 0 - if left is None: - return 1 - if right is None: - return -1 - - for a, b in zip(left, right): - if a == b: - continue - a_numeric = a.isdigit() - b_numeric = b.isdigit() - if a_numeric and b_numeric: - return 1 if int(a) > int(b) else -1 - if a_numeric != b_numeric: - return -1 if a_numeric else 1 - return 1 if a > b else -1 - - if len(left) == len(right): - return 0 - return 1 if len(left) > len(right) else -1 - - def compare(left, right): - left_parsed = parse(left) - right_parsed = parse(right) - - if left_parsed[:3] != right_parsed[:3]: - return 1 if left_parsed[:3] > right_parsed[:3] else -1 - - return compare_prerelease(left_parsed[3], right_parsed[3]) - - if compare(current, previous) <= 0: - raise SystemExit( - f"Version must increase: {previous} -> {current}" - ) - - tag = f"v{current}" - tag_check = subprocess.run( - ["git", "rev-parse", "--quiet", "--verify", f"refs/tags/{tag}"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - if tag_check.returncode == 0: - raise SystemExit(f"Tag already exists: {tag}") - - prerelease = parse(current)[3] is not None - - write("release", "true") - write("version", current) - write("tag", tag) - write("prerelease", str(prerelease).lower()) - PY - - quality: - name: Verify release commit - needs: detect - if: needs.detect.outputs.release == 'true' - uses: ./.github/workflows/ci.yml - build: name: Build ${{ matrix.name }} - needs: [detect, quality] - if: needs.detect.outputs.release == 'true' strategy: fail-fast: false matrix: @@ -170,6 +42,7 @@ jobs: runs-on: ${{ matrix.os }} env: VCPKGRS_TRIPLET: x64-windows-static-md + VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache steps: - name: Check out repository @@ -198,6 +71,32 @@ jobs: rustup toolchain install 1.98.1 --profile minimal rustup default 1.98.1 + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: src-tauri -> target + key: ${{ matrix.os }} + + - name: Configure Windows archive cache + if: runner.os == 'Windows' + id: vcpkg-cache + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + $revision = git -C $env:VCPKG_INSTALLATION_ROOT rev-parse HEAD + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "revision=$revision" >> $env:GITHUB_OUTPUT + "image=$env:ImageVersion" >> $env:GITHUB_OUTPUT + + - name: Cache Windows archive binaries + if: runner.os == 'Windows' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ runner.os }}-${{ runner.arch }}-${{ env.VCPKGRS_TRIPLET }}-libarchive-${{ steps.vcpkg-cache.outputs.revision }}-${{ steps.vcpkg-cache.outputs.image }} + restore-keys: | + vcpkg-${{ runner.os }}-${{ runner.arch }}-${{ env.VCPKGRS_TRIPLET }}-libarchive-${{ steps.vcpkg-cache.outputs.revision }}- + - name: Install Windows archive library if: runner.os == 'Windows' shell: pwsh @@ -251,9 +150,7 @@ jobs: permissions: contents: write needs: - - detect - build - if: needs.detect.outputs.release == 'true' runs-on: ubuntu-latest steps: @@ -268,9 +165,9 @@ jobs: - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.detect.outputs.tag }} - VERSION: ${{ needs.detect.outputs.version }} - PRERELEASE: ${{ needs.detect.outputs.prerelease }} + TAG: ${{ inputs.tag }} + VERSION: ${{ inputs.version }} + PRERELEASE: ${{ inputs.prerelease }} TARGET_SHA: ${{ github.sha }} REPOSITORY: ${{ github.repository }} shell: bash @@ -324,10 +221,8 @@ jobs: aur: name: Update AUR package - needs: - - detect - - publish - if: needs.detect.outputs.release == 'true' && needs.detect.outputs.prerelease == 'false' + needs: publish + if: ${{ !inputs.prerelease }} runs-on: ubuntu-latest steps: - name: Check out repository @@ -336,8 +231,8 @@ jobs: - name: Prepare AUR package env: GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.detect.outputs.tag }} - VERSION: ${{ needs.detect.outputs.version }} + TAG: ${{ inputs.tag }} + VERSION: ${{ inputs.version }} shell: bash run: | mkdir aur-files @@ -351,7 +246,7 @@ jobs: env: AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_KNOWN_HOSTS: ${{ vars.AUR_KNOWN_HOSTS }} - VERSION: ${{ needs.detect.outputs.version }} + VERSION: ${{ inputs.version }} shell: bash run: | test -n "$AUR_SSH_PRIVATE_KEY" || { echo "Missing AUR_SSH_PRIVATE_KEY"; exit 1; } diff --git a/README.md b/README.md index 7101ac3e..ec295006 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,13 @@ Legio does not support or promote piracy. Use this option only with a legitimate ## Download -Get the latest version from [Releases](https://github.com/fraa2a/Legio/releases). Packages are available for Windows and Linux. To run Windows games on Linux, install a compatible runner such as Proton or Wine first. +### [Download the latest release](https://github.com/fraa2a/Legio/releases/latest) -Steam must be installed for Steam integration and for games that require it. +- **Windows:** `.exe` installer. +- **Linux:** AppImage, `.deb` or `.rpm`. +- **Arch Linux / AUR:** `legio-launcher-bin` for stable binaries, or `legio-launcher-git` to compile the latest `main` sources. -Linux uses WebKitGTK's Skia compositor on runtime versions 2.54 and newer. -See [Linux rendering diagnostics](_meta/docs/LINUX-RENDERING.md) -for backend comparisons and runtime packaging requirements. - -See [Linux runners and performance controls](_meta/docs/LINUX.md) for umu setup, -Proton/Wine requirements, prefixes, GameMode and gamescope. +Steam is required for Steam integration. For Windows games on Linux, see [runner setup](_meta/docs/LINUX.md). ## Download sources @@ -43,6 +40,8 @@ Add an HTTPS catalog in **Settings > Sources**. You can install multiple sources refresh their cached releases, and remove them individually. No download source is configured by default. +**Hydra sources are also supported.** Some source URLs are protected by Cloudflare and cannot be fetched directly by Legio. Download the actual JSON file in your browser, then use **Choose JSON file** in Sources to select its local path. Use a JSON file, not a saved challenge page. Local refresh rereads that file; it does not download a newer copy. See [Hydra compatibility](_meta/docs/HYDRA.md) for supported downloads and limitations. + After starting Legio once, opening a link such as `legio://add-source?url=https%3A%2F%2Fcatalogo.example%2Fgames.json` opens the launcher and adds the catalog. The browser may ask to open Legio. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6cc8cccf..228c9029 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,21 @@ # Third-party notices +Third-party code and assets retain their own license terms. Legio's SA-NC-PD license does not replace those terms. MIT-licensed adaptations may be included in a differently licensed application, but their copyright and permission notices must be preserved. + +## Inter + +Legio bundles the unmodified Inter variable fonts, including italics. + +- Source: https://github.com/rsms/inter +- Copyright (c) 2016 The Inter Project Authors. +- License: SIL Open Font License 1.1. The full text is in `src/assets/fonts/OFL.txt`, distributed as `licenses/Inter-OFL.txt` in application bundles. + +## Glow icons + +- Source: https://github.com/glow-ui/glow-icons +- Copyright (c) 2025 glow-ui. +- License: MIT. The full notice is in `_meta/licenses/glow-icons.txt`, distributed as `licenses/glow-icons.txt` in application bundles. + ## Hydra Launcher The free-host resolution behavior in `src-tauri/src/download_resolver.rs`, `src-tauri/src/gofile.rs`, `src-tauri/src/gofile_probe.js` and `src-tauri/src/gofile_capture.js` is adapted from the Hydra Launcher hoster implementations at commit `a22ec02201e73db8ca2ee214c2b37cd2d6a27374`. @@ -93,5 +109,5 @@ Source title indexing embeds the upstream FlexSearch distribution from the `0.7. - Source: https://github.com/nextapps-de/flexsearch - Author and Copyright: Thomas Wilkerling, hosted by Nextapps GmbH. -- License: Apache-2.0. The complete license is in `src-tauri/vendor/flexsearch.LICENSE` and is included in application bundles. +- License: Apache-2.0. The complete license is in `src-tauri/vendor/flexsearch.LICENSE`, distributed as `licenses/flexsearch.LICENSE` in application bundles. - The upstream distribution banner says `v0.7.41`; the package metadata at the pinned commit says `0.7.43`. The distribution file is vendored unchanged. diff --git a/_meta/docs/TESTING.md b/_meta/docs/TESTING.md index bf09f6da..bbaf41e3 100644 --- a/_meta/docs/TESTING.md +++ b/_meta/docs/TESTING.md @@ -19,13 +19,14 @@ corepack pnpm tauri build --debug --no-bundle Also run the relevant `test:*` suites in [package.json](../../package.json), for example: ```sh +corepack pnpm test:docs corepack pnpm test:regressions corepack pnpm test:review corepack pnpm test:feedback corepack pnpm test:sources ``` -The [CI workflow](../../.github/workflows/ci.yml) lists required suites. [Scripts](../../scripts/README.md) describes fixtures and test support. JSDOM and mock graphics tests do not validate native rendering. +The [CI workflow](../../.github/workflows/ci.yml) checks documentation on every run. Documentation-only changes skip native builds and application tests; code changes run frontend, Rust and Linux/Windows native checks. New PR pushes cancel superseded runs. Release commits use the production build instead of an additional smoke build. [Scripts](../../scripts/README.md) describes fixtures and test support. JSDOM and mock graphics tests do not validate native rendering. Native tests need platform dependencies, localhost sockets and access to their child processes. Run relevant ignored or interactive tests explicitly; a passing subset does not replace required CI checks. @@ -45,7 +46,7 @@ Record reproduction details and results in the issue or PR, including build, OS, ## PR test packages -CI publishes Linux and Windows packages for pull requests. Check `build-info.txt` against the PR revision and verify `SHA256SUMS.txt`. Windows symbols are a separate artifact. +Run **CI** manually in Actions with **Build downloadable Linux and Windows test packages** enabled. Select the branch to test. Check `build-info.txt` against that revision and verify `SHA256SUMS.txt`. Windows symbols are a separate artifact. **These are unsigned-updater test packages, not releases. Installers use production data; back it up first.** Development runs use a separate profile. diff --git a/_meta/docs/UPDATING.md b/_meta/docs/UPDATING.md index db1b93b1..d24135af 100644 --- a/_meta/docs/UPDATING.md +++ b/_meta/docs/UPDATING.md @@ -1,6 +1,6 @@ # Releases and updates -Increasing the version in `src-tauri/Cargo.toml` on `main` triggers a release. The pipeline builds and signs Ubuntu (`.deb`), Fedora (`.rpm`), Linux (`.AppImage`), and Windows (`.exe`) packages, then publishes their `.sig` files, `SHA256SUMS.txt`, and `latest.json` in the GitHub Release. Preliminary versions are published as prereleases and do not replace the stable `releases/latest` channel. +Increasing the version in `src-tauri/Cargo.toml` on `main` triggers a release after CI passes. CI calls the release workflow once; no second full CI run or redundant smoke build is started. The pipeline builds and signs Ubuntu (`.deb`), Fedora (`.rpm`), Linux (`.AppImage`), and Windows (`.exe`) packages, then publishes their `.sig` files, `SHA256SUMS.txt`, and `latest.json` in the GitHub Release. Preliminary versions are published as prereleases and do not replace the stable `releases/latest` channel. Legio checks for updates at startup and from Settings. The manifest distinguishes the installed package format. Tauri verifies the signature before installation. On Ubuntu and Fedora, package installation may require administrator authorization. On Windows, the NSIS installer is launched. Legio restarts after installation. @@ -8,6 +8,12 @@ The Arch package `legio-launcher-bin` uses the AppImage published on GitHub. The The AUR package declares `umu-launcher` as a runtime dependency, so AUR helpers install it alongside Legio. Arch users need the `multilib` repository enabled. Standalone AppImage, `.deb` and `.rpm` downloads do not bundle umu; install it separately to launch manually imported games through Proton or GE-Proton. +## AUR development package + +`legio-launcher-git` compiles current `main` sources on the user's system. It is separate from the stable binary package and does not need a Nightly release or an AUR commit for each source change. The [AUR workflow](../../.github/workflows/aur-git.yml) builds and publishes recipe changes, generating `.SRCINFO` with `makepkg`. See [package maintenance](../../packaging/aur-git/README.md). + +The two packages conflict and share the installed profile. Back up data before switching from development code to an older stable version. Both must be updated through the package manager. + ## Credentials - `TAURI_SIGNING_PRIVATE_KEY` contains the private Tauri key used to sign installers. The public key is in `src-tauri/tauri.conf.json`. Keep a secure private backup: losing it prevents updates to existing installations. diff --git a/package.json b/package.json index d3cd5ee7..5b96627d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:sources": "node scripts/test_source_search.mjs && node scripts/test_gofile_token.mjs && node --conditions=browser --test scripts/test_sources.mjs scripts/test_hydra_sources.mjs scripts/test_download_mirrors.mjs", "test:catalog": "node --conditions=browser --test scripts/test_catalog.mjs scripts/test_store_filters.mjs", "test:acl": "node --test scripts/test_command_acl.mjs", + "test:docs": "node --test scripts/test_documentation.mjs", "test:review": "node --conditions=browser --test scripts/test_review_frontend.mjs scripts/test_updater_review.mjs scripts/test_maintenance_review.mjs", "test:select": "node --conditions=browser --test scripts/test_select.mjs", "test:artwork-cache": "node --conditions=browser --test scripts/test_steam_image_cache.mjs", diff --git a/packaging/aur-git/PKGBUILD b/packaging/aur-git/PKGBUILD new file mode 100644 index 00000000..f2279cb2 --- /dev/null +++ b/packaging/aur-git/PKGBUILD @@ -0,0 +1,41 @@ +pkgname=legio-launcher-git +pkgver=0.2.2.r0.g0000000 +pkgrel=1 +epoch=1 +pkgdesc='Legio desktop game launcher (development sources)' +arch=('x86_64') +url='https://github.com/fraa2a/Legio' +license=('LicenseRef-SA-NC-PD-1.0') +depends=('glibc' 'gcc-libs' 'gtk3' 'webkit2gtk-4.1' 'libayatana-appindicator' 'libarchive' 'librsvg' 'libxdo' 'umu-launcher' 'xdg-utils' 'desktop-file-utils') +makedepends=('git' 'rust>=1.98.1' 'nodejs>=22' 'pnpm' 'python' 'pkgconf') +provides=('legio-launcher') +conflicts=('legio-launcher') +source=('legio::git+https://github.com/fraa2a/Legio.git#branch=main' 'legio-launcher') +sha256sums=('SKIP' 'e4749475518da35cf046c273c7802802d263f3018760c565c262cc838b9361df') + +pkgver() { + cd "$srcdir/legio" + local version + version=$(python -c 'import tomllib; print(tomllib.load(open("src-tauri/Cargo.toml", "rb"))["package"]["version"].replace("-", "."))') + printf '%s.r%s.g%s' "$version" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" +} + +build() { + cd "$srcdir/legio" + export CARGO_TARGET_DIR="$srcdir/target" + pnpm install --frozen-lockfile + pnpm tauri build --no-bundle --config '{"build":{"beforeBuildCommand":"pnpm build"},"bundle":{"createUpdaterArtifacts":false}}' +} + +package() { + cd "$srcdir/legio" + install -Dm755 "$srcdir/target/release/legio" "$pkgdir/usr/lib/legio/legio" + install -Dm755 "$srcdir/legio-launcher" "$pkgdir/usr/bin/legio-launcher" + install -Dm644 packaging/aur/legio-launcher.desktop "$pkgdir/usr/share/applications/legio-launcher.desktop" + install -Dm644 src-tauri/icons/icon.png "$pkgdir/usr/share/pixmaps/legio-launcher.png" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 THIRD_PARTY_NOTICES.md "$pkgdir/usr/share/licenses/$pkgname/THIRD_PARTY_NOTICES.md" + install -Dm644 src/assets/fonts/OFL.txt "$pkgdir/usr/share/licenses/$pkgname/Inter-OFL.txt" + install -Dm644 _meta/licenses/glow-icons.txt "$pkgdir/usr/share/licenses/$pkgname/glow-icons.txt" + install -Dm644 src-tauri/vendor/flexsearch.LICENSE "$pkgdir/usr/share/licenses/$pkgname/flexsearch.LICENSE" +} diff --git a/packaging/aur-git/README.md b/packaging/aur-git/README.md new file mode 100644 index 00000000..37f01686 --- /dev/null +++ b/packaging/aur-git/README.md @@ -0,0 +1,15 @@ +# AUR development package + +`legio-launcher-git` builds the latest `main` sources locally. It conflicts with the stable `legio-launcher-bin`; both use the same application profile. Back up your data before switching to an older version. + +- Git sources use `SKIP` checksums because the branch moves. +- The local launcher wrapper has a fixed checksum. After editing it, run `updpkgsums` from `pacman-contrib`; the packaging test detects stale hashes. +- `pkgver()` reads Cargo's version and appends the Git revision count and commit. +- Licenses are installed in `/usr/share/licenses/legio-launcher-git`, the standard Arch location. `$pkgdir` is makepkg's staging directory, not the user's home. +- The wrapper marks the installation as package-managed, preventing the app updater from replacing pacman-owned files. + +## Publish + +The **AUR development package** workflow builds with `makepkg` on Arch, generates `.SRCINFO`, then pushes the recipe over SSH. The first push creates the AUR package base. It runs when the recipe changes on `main`, or manually through Actions; PR runs build but never publish. + +The workflow uses `AUR_SSH_PRIVATE_KEY` and verified `AUR_KNOWN_HOSTS`. Review the selected ref before manually publishing. Do not update AUR for each application commit: users rebuild the VCS package through their AUR helper's development-package update option. diff --git a/packaging/aur-git/legio-launcher b/packaging/aur-git/legio-launcher new file mode 100644 index 00000000..d36646c4 --- /dev/null +++ b/packaging/aur-git/legio-launcher @@ -0,0 +1,3 @@ +#!/bin/sh +export LEGIO_AUR_PACKAGE=1 +exec /usr/lib/legio/legio "$@" diff --git a/scripts/README.md b/scripts/README.md index ffabd0bf..9ee7ff53 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -7,15 +7,18 @@ Run from the repository root. [package.json](../package.json) defines `test:*` c | Files | Purpose | | --- | --- | | `test_*.mjs` | Regression suites; use package commands for the required Node flags | +| `test_documentation.mjs` | Local Markdown links and image paths (`test:docs`) | | `test_module_loader.mjs` | Svelte/TypeScript loading with mocks; not a standalone suite | | `fixtures/gofile-wt.js` | Protocol fixture shared with Rust via `include_str!` | Reuse the runner and name tests by behavior. Moving files requires updating package commands, fixture references and CI. -## Python release tools +## Python automation +- `ci_plan.py`: selects documentation/code checks and detects release version changes. +- `test_ci_plan.py` and `test_packaging.py`: check CI selection, versions, wrapper checksums and bundled licenses. - `release_manifest.py`: creates and validates updater metadata. - `prepare_aur.py`: generates AUR package files. -- `test_release_manifest.py`: run with `python -B -m unittest discover -s scripts`. +Run all Python tests with `python -B -m unittest discover -s scripts`. These use the standard library. Keep signing credentials private; see [Releases and updates](../_meta/docs/UPDATING.md). diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py new file mode 100644 index 00000000..bfe1f16c --- /dev/null +++ b/scripts/ci_plan.py @@ -0,0 +1,90 @@ +"""Select checks and releases for one GitHub Actions event.""" + +import os +import re +import subprocess +import tomllib +from pathlib import Path + + +VERSION_PATTERN = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) +MANIFEST = "src-tauri/Cargo.toml" +DOCUMENTS = { + "README.md", "CONTRIBUTING.md", "SECURITY.md", "AGENTS.md", + "scripts/README.md", "tools/linux-rendering/README.md", +} + + +def parse_version(value): + match = VERSION_PATTERN.fullmatch(value) + if not match: + raise ValueError(f"Invalid Semantic Version: {value}") + major, minor, patch, prerelease, _build = match.groups() + identifiers = prerelease.split(".") if prerelease else None + if identifiers and any(item.isdigit() and len(item) > 1 and item[0] == "0" for item in identifiers): + raise ValueError(f"Invalid numeric prerelease identifier: {value}") + return (int(major), int(minor), int(patch)), identifiers + + +def compare_versions(left, right): + left_core, left_pre = parse_version(left) + right_core, right_pre = parse_version(right) + if left_core != right_core: + return 1 if left_core > right_core else -1 + if left_pre is None or right_pre is None: + return (left_pre is None) - (right_pre is None) + for a, b in zip(left_pre, right_pre): + if a == b: + continue + if a.isdigit() and b.isdigit(): + return 1 if int(a) > int(b) else -1 + if a.isdigit() != b.isdigit(): + return -1 if a.isdigit() else 1 + return 1 if a > b else -1 + return (len(left_pre) > len(right_pre)) - (len(left_pre) < len(right_pre)) + + +def is_documentation(path): + return path in DOCUMENTS or path.endswith("/README.md") or path.startswith(("_meta/docs/", "_meta/screenshots/")) + + +def git(*args): + return subprocess.check_output(["git", *args], stderr=subprocess.DEVNULL).decode("utf-8") + + +def plan(base, event): + current = tomllib.loads(Path(MANIFEST).read_text(encoding="utf-8"))["package"]["version"] + parse_version(current) + try: + previous = tomllib.loads(git("show", f"{base}:{MANIFEST}"))["package"]["version"] + paths = git("diff", "--name-only", "--no-renames", base, "HEAD").splitlines() + except subprocess.CalledProcessError: + previous = None + paths = git("ls-tree", "-r", "--name-only", "HEAD").splitlines() + + changed = previous is not None and current != previous + if changed: + if compare_versions(current, previous) <= 0: + raise ValueError(f"Version must increase: {previous} -> {current}") + if git("tag", "--list", f"v{current}").strip(): + raise ValueError(f"Tag already exists: v{current}") + + release = changed and event == "push" and os.environ.get("GITHUB_REF") == "refs/heads/main" + return { + "code": event == "workflow_dispatch" or any(not is_documentation(path) for path in paths), + "release": release, + "version": current, + "tag": f"v{current}", + "prerelease": parse_version(current)[1] is not None, + } + + +if __name__ == "__main__": + values = plan(os.environ.get("BASE_SHA", ""), os.environ["GITHUB_EVENT_NAME"]) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + for name, value in values.items(): + output.write(f"{name}={str(value).lower() if isinstance(value, bool) else value}\n") diff --git a/scripts/test_ci_plan.py b/scripts/test_ci_plan.py new file mode 100644 index 00000000..53d33a6d --- /dev/null +++ b/scripts/test_ci_plan.py @@ -0,0 +1,98 @@ +import os +import subprocess +import tempfile +import unittest +from contextlib import chdir +from pathlib import Path +from unittest.mock import patch + +from ci_plan import compare_versions, is_documentation, parse_version, plan + + +class VersionTests(unittest.TestCase): + def test_semver_order(self): + versions = ["1.0.0-alpha.1", "1.0.0-alpha.2", "1.0.0-beta.1", "1.0.0-rc.1", "1.0.0", "1.0.1"] + for earlier, later in zip(versions, versions[1:]): + self.assertLess(compare_versions(earlier, later), 0) + self.assertGreater(compare_versions(later, earlier), 0) + self.assertEqual(compare_versions("1.0.0+abc", "1.0.0+def"), 0) + + def test_invalid_versions(self): + for value in ["01.0.0", "1.2", "1.0.0-alpha.01", "1.0.0-", "1.0.0\n"]: + with self.subTest(value=value), self.assertRaises(ValueError): + parse_version(value) + + def test_only_documentation_paths_are_lightweight(self): + for path in ["README.md", "_meta/docs/LINUX.md", "_meta/screenshots/home.png", "packaging/aur-git/README.md"]: + self.assertTrue(is_documentation(path)) + for path in ["LICENSE", "THIRD_PARTY_NOTICES.md", ".github/workflows/ci.yml", "src/main.ts", "packaging/aur-git/PKGBUILD"]: + self.assertFalse(is_documentation(path)) + + +class PlanTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.context = chdir(self.directory.name) + self.context.__enter__() + self.addCleanup(self.context.__exit__, None, None, None) + self.git("init", "-q") + Path("src-tauri").mkdir() + self.version("1.0.0") + self.commit() + self.base = self.git("rev-parse", "HEAD").strip() + + def git(self, *args): + return subprocess.check_output(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", *args], stderr=subprocess.DEVNULL).decode() + + def version(self, value): + Path("src-tauri/Cargo.toml").write_text(f'[package]\nversion = "{value}"\n', encoding="utf-8") + + def commit(self): + self.git("add", ".") + self.git("commit", "-qm", "test") + + def test_docs_skip_native_builds(self): + Path("README.md").write_text("docs", encoding="utf-8") + self.commit() + result = plan(self.base, "push") + self.assertFalse(result["code"]) + self.assertFalse(result["release"]) + + def test_code_and_manual_events_run_checks_without_release(self): + Path("source.rs").write_text("code", encoding="utf-8") + self.commit() + self.assertTrue(plan(self.base, "pull_request")["code"]) + self.assertTrue(plan(self.base, "workflow_dispatch")["code"]) + self.assertFalse(plan(self.base, "workflow_dispatch")["release"]) + + def test_release_only_for_version_change_on_main_push(self): + self.version("1.1.0-beta.1") + self.commit() + with patch.dict(os.environ, GITHUB_REF="refs/heads/main"): + result = plan(self.base, "push") + self.assertTrue(result["release"]) + self.assertTrue(result["code"]) + self.assertTrue(result["prerelease"]) + self.assertEqual(result["tag"], "v1.1.0-beta.1") + self.assertFalse(plan(self.base, "pull_request")["release"]) + with patch.dict(os.environ, GITHUB_REF="refs/heads/feature"): + self.assertFalse(plan(self.base, "push")["release"]) + + def test_rejects_decrease_and_existing_tag(self): + self.version("0.9.0") + with self.assertRaisesRegex(ValueError, "must increase"): + plan(self.base, "push") + self.version("1.1.0") + self.git("tag", "v1.1.0") + with self.assertRaisesRegex(ValueError, "Tag already exists"): + plan(self.base, "push") + + def test_unknown_base_is_conservative_without_releasing(self): + result = plan("0" * 40, "push") + self.assertTrue(result["code"]) + self.assertFalse(result["release"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_documentation.mjs b/scripts/test_documentation.mjs new file mode 100644 index 00000000..9d7780f6 --- /dev/null +++ b/scripts/test_documentation.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readdir, readFile, access } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const root = fileURLToPath(new URL("../", import.meta.url)); + +async function markdownFiles(directory) { + const files = []; + for (const entry of await readdir(join(root, directory), { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await markdownFiles(path)); + else if (entry.name.endsWith(".md")) files.push(path); + } + return files; +} + +test("documentation links and images reference existing local paths", async () => { + const files = [ + "README.md", "CONTRIBUTING.md", "SECURITY.md", "THIRD_PARTY_NOTICES.md", + "scripts/README.md", "tools/linux-rendering/README.md", + ...await markdownFiles("_meta"), + ...await markdownFiles("packaging"), + ]; + const missing = []; + for (const path of files) { + const markdown = await readFile(join(root, path), "utf8"); + for (const match of markdown.matchAll(/\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g)) { + const target = match[1]; + if (/^(?:[a-z][a-z\d+.-]*:|#|\/\/)/i.test(target)) continue; + const local = decodeURIComponent(target.split(/[?#]/)[0]); + if (!local) continue; + try { + await access(resolve(root, dirname(path), local)); + } catch { + missing.push(`${path}: ${target}`); + } + } + } + assert.deepEqual(missing, [], `Missing documentation targets:\n${missing.join("\n")}`); +}); diff --git a/scripts/test_packaging.py b/scripts/test_packaging.py new file mode 100644 index 00000000..b8f8fba4 --- /dev/null +++ b/scripts/test_packaging.py @@ -0,0 +1,35 @@ +import hashlib +import json +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +class PackagingTests(unittest.TestCase): + def test_git_package_verifies_wrapper_but_not_moving_git_source(self): + recipe = ROOT / "packaging/aur-git" + text = (recipe / "PKGBUILD").read_text(encoding="utf-8") + match = re.search(r"sha256sums=\('SKIP' '([a-f0-9]{64})'\)", text) + self.assertIsNotNone(match, "Keep the Git source unpinned and the local wrapper checksummed") + expected = hashlib.sha256((recipe / "legio-launcher").read_bytes()).hexdigest() + self.assertEqual(match[1], expected, "Update the wrapper checksum in PKGBUILD when changing the wrapper") + + def test_bundles_include_required_license_notices(self): + manifest = ROOT / "src-tauri/tauri.conf.json" + resources = json.loads(manifest.read_text(encoding="utf-8"))["bundle"]["resources"] + required = { + "../LICENSE": "LICENSE", + "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", + "../src/assets/fonts/OFL.txt": "licenses/Inter-OFL.txt", + "../_meta/licenses/glow-icons.txt": "licenses/glow-icons.txt", + "vendor/flexsearch.LICENSE": "licenses/flexsearch.LICENSE", + } + for source, destination in required.items(): + self.assertEqual(resources.get(source), destination) + self.assertTrue((manifest.parent / source).is_file(), source) + + +if __name__ == "__main__": + unittest.main() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 47ab8555..b22e3c34 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -31,7 +31,13 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, - "resources": ["../src/assets/fonts/OFL.txt", "vendor/flexsearch.LICENSE"], + "resources": { + "../LICENSE": "LICENSE", + "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", + "../src/assets/fonts/OFL.txt": "licenses/Inter-OFL.txt", + "../_meta/licenses/glow-icons.txt": "licenses/glow-icons.txt", + "vendor/flexsearch.LICENSE": "licenses/flexsearch.LICENSE" + }, "icon": [ "icons/icon.png", "icons/icon.ico" From badfbe3f81c0cb070d90a7d17d4d70a1f3879f3a Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 18:03:48 +0200 Subject: [PATCH 02/12] fix: use Arch package name for libxdo dependency --- packaging/aur-git/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/aur-git/PKGBUILD b/packaging/aur-git/PKGBUILD index f2279cb2..c9edf98e 100644 --- a/packaging/aur-git/PKGBUILD +++ b/packaging/aur-git/PKGBUILD @@ -6,7 +6,7 @@ pkgdesc='Legio desktop game launcher (development sources)' arch=('x86_64') url='https://github.com/fraa2a/Legio' license=('LicenseRef-SA-NC-PD-1.0') -depends=('glibc' 'gcc-libs' 'gtk3' 'webkit2gtk-4.1' 'libayatana-appindicator' 'libarchive' 'librsvg' 'libxdo' 'umu-launcher' 'xdg-utils' 'desktop-file-utils') +depends=('glibc' 'gcc-libs' 'gtk3' 'webkit2gtk-4.1' 'libayatana-appindicator' 'libarchive' 'librsvg' 'xdotool' 'umu-launcher' 'xdg-utils' 'desktop-file-utils') makedepends=('git' 'rust>=1.98.1' 'nodejs>=22' 'pnpm' 'python' 'pkgconf') provides=('legio-launcher') conflicts=('legio-launcher') From ef71da7c9fb49560cfff134687df17017658e48a Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 18:04:58 +0200 Subject: [PATCH 03/12] docs: preserve frontend runtime license notices --- THIRD_PARTY_NOTICES.md | 43 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 228c9029..dffccddb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -16,6 +16,49 @@ Legio bundles the unmodified Inter variable fonts, including italics. - Copyright (c) 2025 glow-ui. - License: MIT. The full notice is in `_meta/licenses/glow-icons.txt`, distributed as `licenses/glow-icons.txt` in application bundles. +## Svelte + +Frontend runtime: https://github.com/sveltejs/svelte + +MIT License + +Copyright (c) 2016-2025 [Svelte Contributors](https://github.com/sveltejs/svelte/graphs/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## Tauri API and plugins + +Frontend bindings: `@tauri-apps/api`, `@tauri-apps/plugin-dialog`, `@tauri-apps/plugin-process` and `@tauri-apps/plugin-updater`. + +Source: https://github.com/tauri-apps/tauri and https://github.com/tauri-apps/plugins-workspace + +MIT License + +Copyright (c) 2017 - Present Tauri Apps Contributors +Copyright (c) 2019-2022, The Tauri Programme in the Commons Conservancy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## Hydra Launcher The free-host resolution behavior in `src-tauri/src/download_resolver.rs`, `src-tauri/src/gofile.rs`, `src-tauri/src/gofile_probe.js` and `src-tauri/src/gofile_capture.js` is adapted from the Hydra Launcher hoster implementations at commit `a22ec02201e73db8ca2ee214c2b37cd2d6a27374`. From f06bf474e21d7d9babc748ccf13abece9ae8a759 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 18:16:34 +0200 Subject: [PATCH 04/12] fix: disable incompatible GCC LTO in Arch builds --- packaging/aur-git/PKGBUILD | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/aur-git/PKGBUILD b/packaging/aur-git/PKGBUILD index c9edf98e..dcdcb625 100644 --- a/packaging/aur-git/PKGBUILD +++ b/packaging/aur-git/PKGBUILD @@ -10,6 +10,8 @@ depends=('glibc' 'gcc-libs' 'gtk3' 'webkit2gtk-4.1' 'libayatana-appindicator' 'l makedepends=('git' 'rust>=1.98.1' 'nodejs>=22' 'pnpm' 'python' 'pkgconf') provides=('legio-launcher') conflicts=('legio-launcher') +# GCC LTO objects from bundled C libraries cannot be linked by Rust's LLD. +options=('!lto') source=('legio::git+https://github.com/fraa2a/Legio.git#branch=main' 'legio-launcher') sha256sums=('SKIP' 'e4749475518da35cf046c273c7802802d263f3018760c565c262cc838b9361df') From 9b97cc95d245e5529a2d9271a9c6c7a724a0a6ab Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 18:47:09 +0200 Subject: [PATCH 05/12] test: prune redundant cases and reduce fixture cost --- .github/workflows/ci.yml | 15 ---- package.json | 2 +- scripts/test_appearance.mjs | 7 -- scripts/test_command_acl.mjs | 8 -- scripts/test_dialog.mjs | 53 +++++-------- scripts/test_dither_renderer.mjs | 14 +++- scripts/test_gofile_token.mjs | 16 ---- scripts/test_library_artwork.mjs | 30 +------- scripts/test_regressions.mjs | 34 ++------- scripts/test_review_frontend.mjs | 11 --- scripts/test_source_search.mjs | 15 ---- scripts/test_steam_image_cache.mjs | 28 ------- scripts/test_visual_rendering.mjs | 34 --------- scripts/test_window_activity.mjs | 57 -------------- src-tauri/src/appearance.rs | 11 --- src-tauri/src/database.rs | 116 ++++++----------------------- src-tauri/src/image_trim.rs | 24 +++--- src-tauri/src/source_catalog.rs | 34 ++------- src-tauri/src/source_matching.rs | 35 +++------ src-tauri/src/source_search.rs | 7 +- src-tauri/src/steam_assets.rs | 12 +-- 21 files changed, 99 insertions(+), 464 deletions(-) delete mode 100644 scripts/test_gofile_token.mjs delete mode 100644 scripts/test_source_search.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca2c81c1..52e226f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,7 +288,6 @@ jobs: cargo test --manifest-path src-tauri/Cargo.toml --locked --lib overlay_update::tests cargo test --manifest-path src-tauri/Cargo.toml --locked --lib game_updates::tests cargo test --manifest-path src-tauri/Cargo.toml --locked --lib update_policy - cargo test --manifest-path src-tauri/Cargo.toml --locked --lib automatically_selects_and_installs_the_executable - name: Test Windows development data isolation if: runner.os == 'Windows' @@ -357,20 +356,6 @@ jobs: LEGIO_WINDOWS_TESTS: "1" run: cargo test --manifest-path src-tauri/Cargo.toml --locked --lib game_lifecycle::tests::native_manager_lifecycle_tracks_a_controlled_process - - name: Test Windows staged executable finalization - if: runner.os == 'Windows' - shell: bash - env: - LEGIO_WINDOWS_TESTS: "1" - run: cargo test --manifest-path src-tauri/Cargo.toml --locked --lib finalize_install::tests::finalizes_and_commits_game_and_download - - - name: Test Windows finalization recovery after database reopen - if: runner.os == 'Windows' - shell: bash - env: - LEGIO_WINDOWS_TESTS: "1" - run: cargo test --manifest-path src-tauri/Cargo.toml --locked --lib finalize_install::tests::recovers_after_database_commit_fails_and_database_reopens - - name: Build Tauri application if: needs.plan.outputs.release != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.test_packages) shell: bash diff --git a/package.json b/package.json index 5b96627d..905ea75f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:dialog": "node --conditions=browser --test scripts/test_dialog.mjs", "test:feedback": "node --conditions=browser --test scripts/test_feedback.mjs scripts/test_game_identity.mjs scripts/test_game_library.mjs", "test:onboarding": "node --test scripts/test_onboarding_news.mjs", - "test:sources": "node scripts/test_source_search.mjs && node scripts/test_gofile_token.mjs && node --conditions=browser --test scripts/test_sources.mjs scripts/test_hydra_sources.mjs scripts/test_download_mirrors.mjs", + "test:sources": "node --conditions=browser --test scripts/test_sources.mjs scripts/test_hydra_sources.mjs scripts/test_download_mirrors.mjs", "test:catalog": "node --conditions=browser --test scripts/test_catalog.mjs scripts/test_store_filters.mjs", "test:acl": "node --test scripts/test_command_acl.mjs", "test:docs": "node --test scripts/test_documentation.mjs", diff --git a/scripts/test_appearance.mjs b/scripts/test_appearance.mjs index 842aed7a..68d8c6cd 100644 --- a/scripts/test_appearance.mjs +++ b/scripts/test_appearance.mjs @@ -36,13 +36,6 @@ const mocks = { const { load } = createModuleLoader(mocks); const appearance = await import(await load("src/lib/services/appearance.ts")); -test("default panels and dialogs are transparent with 40px blur", () => { - const defaults = appearance.defaultAppearance(); - assert.equal(defaults.surfaceOpacity, 0); - assert.equal(defaults.dialogOpacity, 0); - assert.equal(defaults.surfaceBlur, 40); - assert.equal(defaults.dialogBlur, 40); -}); const settle = async () => { for (let index = 0; index < 6; index++) { await new Promise((resolve) => setImmediate(resolve)); flushSync(); } }; diff --git a/scripts/test_command_acl.mjs b/scripts/test_command_acl.mjs index 74d3a4f2..65c5e50f 100644 --- a/scripts/test_command_acl.mjs +++ b/scripts/test_command_acl.mjs @@ -16,11 +16,3 @@ test("every registered Tauri command has manifest and main window permission ent assert.deepEqual([...manifest].filter(command => !registered.has(command)), []); assert.deepEqual([...allowed].filter(command => !registered.has(command)), []); }); - -test("manual source binding commands are not exposed to the webview", () => { - for (const command of ["bind_game_source", "get_game_source_binding"]) { - assert.equal(registered.has(command), false); - assert.equal(manifest.has(command), false); - assert.equal(allowed.has(command), false); - } -}); diff --git a/scripts/test_dialog.mjs b/scripts/test_dialog.mjs index 89131d82..0ecc3e89 100644 --- a/scripts/test_dialog.mjs +++ b/scripts/test_dialog.mjs @@ -126,11 +126,16 @@ test("closing waits for the content fade and hides the dialog before leaving the advanceFrame(); const dialog = document.querySelector("dialog"); const content = dialog.querySelector(".legio-dialog-content"); - [...dialog.querySelectorAll("button")].find(button => button.textContent === "Close").click(); + const cancel = new Event("cancel", { cancelable: true }); + dialog.dispatchEvent(cancel); + assert.equal(cancel.defaultPrevented, true); flushSync(); - finishOpacityTransition(content.querySelector("button")); - assert.equal(dialog.open, true, "nested transitions must not finish the dialog exit"); - assert.equal(closed, 0); + const surface = dialog.querySelector(".legio-dialog-surface"); + for (const other of [content.querySelector("button"), surface]) { + finishOpacityTransition(other); + assert.equal(dialog.open, true, "only the content transition finishes the dialog exit"); + assert.equal(closed, 0); + } finishOpacityTransition(content); assert.equal(dialog.open, false); assert.equal(dialog.style.visibility, "hidden", "the closed dialog must remain hidden if the native top layer paints another frame"); @@ -140,6 +145,9 @@ test("closing waits for the content fade and hides the dialog before leaving the flushSync(); assert.equal(dialog.open, false, "an opening frame must not bring the closed dialog back"); assert.equal(dialog.dataset.visible, "false"); + finishOpacityTransition(surface); + await new Promise(resolve => setTimeout(resolve, 260)); + assert.equal(closed, 1, "late transitions and the close timeout must not notify twice"); } finally { await unmount(instance); } }); @@ -174,36 +182,11 @@ test("live_blur_targets_surface_not_foreground", async () => { assert.ok(surface, "the blur needs an independent decorative surface"); const content = dialog.querySelector(".legio-dialog-content"); assert.equal(surface.getAttribute("aria-hidden"), "true"); - for (const blur of ["blur(2px)", "blur(10px)", "blur(0px)"]) { - document.documentElement.style.setProperty("--legio-dialog-blur", blur); - const foreground = dom.window.getComputedStyle(content); - assert.ok(!foreground.getPropertyValue("backdrop-filter") || foreground.getPropertyValue("backdrop-filter") === "none"); - assert.notEqual(foreground.willChange, "opacity"); - assert.equal(dom.window.getComputedStyle(dialog).getPropertyValue("backdrop-filter"), "none"); - assert.equal(dom.window.getComputedStyle(surface).getPropertyValue("backdrop-filter"), "var(--legio-dialog-blur)"); - assert.equal(dom.window.getComputedStyle(surface).pointerEvents, "none"); - } - } finally { await unmount(instance); } -}); - -test("dialog_close_waits_only_for_content_transition and blur_drag_during_close_notifies_once", async () => { - let closed = 0; - const instance = mount(component, { target: document.body, props: { onClosed: () => closed++, persistent: true } }); - try { - flushSync(); advanceFrame(); advanceFrame(); flushSync(); - const dialog = document.querySelector("dialog"); - dialog.dispatchEvent(new Event("cancel", { cancelable: true })); flushSync(); - const surface = dialog.querySelector(".legio-dialog-surface"); - assert.ok(surface); - for (const blur of ["blur(4px)", "blur(8px)"]) { - document.documentElement.style.setProperty("--legio-dialog-blur", blur); - finishOpacityTransition(surface); - assert.equal(closed, 0); assert.equal(dialog.open, true); - } - finishOpacityTransition(dialog.querySelector(".legio-dialog-content")); - assert.equal(closed, 1); assert.equal(dialog.open, false); - finishOpacityTransition(surface); - await new Promise(resolve => setTimeout(resolve, 260)); - assert.equal(closed, 1); + const foreground = dom.window.getComputedStyle(content); + assert.ok(!foreground.getPropertyValue("backdrop-filter") || foreground.getPropertyValue("backdrop-filter") === "none"); + assert.notEqual(foreground.willChange, "opacity"); + assert.equal(dom.window.getComputedStyle(dialog).getPropertyValue("backdrop-filter"), "none"); + assert.equal(dom.window.getComputedStyle(surface).getPropertyValue("backdrop-filter"), "var(--legio-dialog-blur)"); + assert.equal(dom.window.getComputedStyle(surface).pointerEvents, "none"); } finally { await unmount(instance); } }); diff --git a/scripts/test_dither_renderer.mjs b/scripts/test_dither_renderer.mjs index 2faa8e94..f706a65d 100644 --- a/scripts/test_dither_renderer.mjs +++ b/scripts/test_dither_renderer.mjs @@ -43,7 +43,7 @@ async function fixture(suppliedGl) { const frame = { time: 0, settings: configuration, accent: '#ff0000', mouse: [0,0], ratio: 1 }; return { renderer, canvas, gl, diagnostics, draw: () => renderer.draw(frame), get attributes() { return attributes; }, resize() { width++; renderer.resize(width, 480); } }; } -test('unrelated_blur_preview_does_not_redraw_frozen_dither and hidden_resize_recovers_without_unbounded_frame_loop', async () => { +test('dither redraws changed settings, stays idle for unrelated previews and suspends when hidden', async () => { const gl = fakeGl(); let attributes; window.HTMLCanvasElement.prototype.getContext = (_type, options) => { attributes = options; return gl; }; const source = ` - `; + + + `; const Component = (await import(dataModule(await resolveImports(compile(source, { generate: 'client' }).js.code, 'scripts/dither_wrapper.svelte')))).default; const { mount, unmount, flushSync } = await import('svelte'); const target = document.createElement('div'); document.body.append(target); @@ -65,6 +67,14 @@ test('unrelated_blur_preview_does_not_redraw_frozen_dither and hidden_resize_rec target.querySelectorAll('button')[1].click(); flushSync(); assert.equal(frames.size, 1); advance(); assert.equal(gl.draws, 4); assert.equal(frames.size, 0); assert.deepEqual(attributes, { alpha: false, antialias: false, preserveDrawingBuffer: true }); + const previousDraws = gl.draws; + target.querySelectorAll("button")[2].click(); flushSync(); advance(); + assert.ok(gl.draws > previousDraws, "changing the background must redraw the frozen image"); + assert.equal(frames.size, 0); + target.querySelectorAll("button")[3].click(); flushSync(); advance(); + assert.equal(frames.size, 1, "animated backgrounds keep rendering"); + target.querySelectorAll("button")[1].click(); flushSync(); + assert.equal(frames.size, 0, "hidden backgrounds stop rendering"); } finally { await unmount(instance); target.remove(); } }); test('context_restoration_rebuilds_resources_and_draws_valid_frame', async () => { diff --git a/scripts/test_gofile_token.mjs b/scripts/test_gofile_token.mjs deleted file mode 100644 index 4986eee8..00000000 --- a/scripts/test_gofile_token.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import vm from 'node:vm'; -const root = new URL('../', import.meta.url); -const prelude = fs.readFileSync(new URL('src-tauri/src/gofile_probe.js', root), 'utf8'); -const capture = fs.readFileSync(new URL('src-tauri/src/gofile_capture.js', root), 'utf8'); -const fixture = fs.readFileSync(new URL('scripts/fixtures/gofile-wt.js', root), 'utf8'); -function probe(script) { - const context = vm.createContext({}); - for (const source of [prelude, script]) vm.runInContext(source, context, { timeout: 1000 }); - return vm.runInContext(capture, context, { timeout: 1000 }); -} -assert.match(probe(fixture), /^HydraGofileUserAgent::HydraGofileLanguage::HydraGofileToken::\d+::secret::suffix$/); -assert.throws(() => probe('while(true) {}'), /timed out/); -assert.throws(() => probe("require('node:fs')"), /require is not defined/); -console.log('GoFile website-token probe: fixture, timeout and absent host APIs passed.'); diff --git a/scripts/test_library_artwork.mjs b/scripts/test_library_artwork.mjs index c6c9e5e1..f6f55c0a 100644 --- a/scripts/test_library_artwork.mjs +++ b/scripts/test_library_artwork.mjs @@ -83,36 +83,7 @@ test("visible library cover loads without requesting textual metadata", async () } }); -test("standalone game logo loads without opening game details", async () => { - requests.length = 0; - const { default: GameLogo } = await import(await moduleUrl("src/lib/features/library/GameLogo.svelte")); - const target = document.createElement("div"); - document.body.append(target); - const component = mount(GameLogo, { target, props: { game: { id: "portal", name: "Portal", steamAppId: 401 } } }); - try { - await settle(); - assert.ok(requests.some(([command, args]) => command === "get_steam_asset" && args.steamAppId === 401 && args.asset === "logo")); - assert.match(target.querySelector("img")?.src ?? "", /^blob:/); - assert.equal(requests.filter(([command]) => command === "get_steam_details").length, 0); - } finally { - await unmount(component); - target.remove(); - } -}); -test("manual game keeps its name without requesting Steam artwork", async () => { - requests.length = 0; - const { default: GameLogo } = await import(await moduleUrl("src/lib/features/library/GameLogo.svelte")); - const target = document.createElement("div"); - const component = mount(GameLogo, { target, props: { game: { id: "manual", name: "Manual game", steamAppId: null } } }); - try { - await settle(); - assert.equal(target.textContent, "Manual game"); - assert.equal(requests.length, 0); - } finally { - await unmount(component); - } -}); test("stale artwork stays visible during refresh and is replaced when ready", async () => { const { default: SteamArtwork } = await import(await moduleUrl("src/lib/features/library/SteamArtwork.svelte")); @@ -246,6 +217,7 @@ test("library cards use the branding logo instead of a small icon", async () => await settle(); assert.equal(calls.filter(([command, args]) => command === "get_steam_asset" && args.asset === "logo").length, 1); assert.equal(target.querySelectorAll("img").length, 2); + assert.equal(calls.filter(([command]) => command === "get_steam_details").length, 0); } finally { await unmount(component); } }); diff --git a/scripts/test_regressions.mjs b/scripts/test_regressions.mjs index e5a24c49..9a05d6e1 100644 --- a/scripts/test_regressions.mjs +++ b/scripts/test_regressions.mjs @@ -32,6 +32,8 @@ test("resource reload invalidates an older read and coalesces mutation refreshes const reads = []; const resource = createResource([], () => { const request = deferred(); reads.push(request); return request.promise; }); const initial = resource.load(); + const duplicate = resource.load(); + assert.equal(reads.length, 1); const refresh = resource.reload(); const joined = resource.reload(); reads[0].resolve(["removed"]); @@ -39,7 +41,8 @@ test("resource reload invalidates an older read and coalesces mutation refreshes assert.equal(reads.length, 2); assert.deepEqual(get(resource).data, []); reads[1].resolve(["current"]); - await Promise.all([initial, refresh, joined]); + await Promise.all([initial, duplicate, refresh, joined]); + assert.equal(get(resource).status, "ready"); assert.deepEqual(get(resource).data, ["current"]); }); @@ -55,32 +58,7 @@ test("resource set supersedes an invalidated pending read", async () => { assert.deepEqual(get(resource).data, ["saved"]); }); -test("release review labels have English translations", async () => { - const { t } = await import(localeUrl); - assert.equal(t("Aggiornamento disponibile", "en"), "Update available"); - assert.equal(t("Consenti download mentre un gioco è aperto", "en"), "Allow downloads while a game is running"); -}); -test("resource deduplicates slow reads and invalidates responses after set", async () => { - const { createResource } = await loadModule("../src/lib/stores/resource.ts", { "../utils/errors": errors }); - const first = deferred(); - let calls = 0; - const resource = createResource([], () => { calls++; return first.promise; }); - const a = resource.load(); - const b = resource.load(); - assert.equal(calls, 1); - first.resolve([1]); - await Promise.all([a, b]); - assert.equal(get(resource).status, "ready"); - assert.deepEqual(get(resource).data, [1]); - const late = deferred(); - const other = createResource([], () => late.promise); - const loading = other.load(); - other.set([2]); - late.resolve([0]); - await loading; - assert.deepEqual(get(other).data, [2]); -}); test("Steam refresh survives older success and failure without losing cached data", async () => { const pending = []; @@ -181,6 +159,10 @@ test("Steam image cache preserves artwork identity across metadata timestamps an const unchangedHeader = await loadSteamImage({ ...request, asset: "header", displayWidth: 2560 }); assert.equal(unchangedHeader.url, firstHeader.url); assert.equal(requests, 3, "monitor resolution only separates hero variants"); + const screenshot = { ...request, asset: "screenshot", index: 0, screenshotIdentity: "https://cdn.steamstatic.com/a.jpg" }; + await loadSteamImage(screenshot); + await loadSteamImage({ ...screenshot, screenshotIdentity: "https://cdn.steamstatic.com/b.jpg" }); + assert.equal(requests, 5, "different screenshot sources must not share a cache entry"); URL.revokeObjectURL(qhd.url); URL.revokeObjectURL(oldImage.url); URL.revokeObjectURL(newImage.url); diff --git a/scripts/test_review_frontend.mjs b/scripts/test_review_frontend.mjs index 0de164a1..97d3baaf 100644 --- a/scripts/test_review_frontend.mjs +++ b/scripts/test_review_frontend.mjs @@ -4,7 +4,6 @@ import { createRequire } from 'node:module'; import { createModuleLoader, dataModule } from './test_module_loader.mjs'; const require = createRequire(new URL('../package.json', import.meta.url)); const { JSDOM } = require('jsdom'); -const { compile } = require('svelte/compiler'); const dom = new JSDOM('', { pretendToBeVisual: true }); for (const key of ['window','document','navigator','HTMLElement','HTMLDialogElement','HTMLMediaElement','HTMLInputElement','Element','Node','Text','Comment','Event','MouseEvent','CustomEvent','MutationObserver','getComputedStyle']) Object.defineProperty(globalThis,key,{ configurable:true,value:dom.window[key] }); globalThis.requestAnimationFrame = cb => setTimeout(cb, 0); @@ -57,7 +56,6 @@ const defer=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {pro const settingsModule=await import(await loader.load('src/lib/stores/settings.ts')); const {settings,defaultSettings}=settingsModule; settings.set(structuredClone(defaultSettings)); -const navigation=await import(await loader.load('src/lib/stores/navigation.ts')); const downloads=await import(await loader.load('src/lib/stores/downloads.ts')); @@ -65,15 +63,6 @@ const DownloadSettings=(await import(await loader.load('src/lib/features/setting const LibrarySourcesSettings=(await import(await loader.load('src/lib/features/settings/LibrarySourcesSettings.svelte'))).default; const manual=await import(await loader.load('src/lib/stores/manual-import.ts')); -test("sidebar opens the general settings category", async () => { -// Actual SidebarButton and actual navigation callback, using the same binding as Sidebar. -const wrapper=compile(``,{generate:'client',filename:'scripts/repro-wrapper.svelte'}).js.code; -const Wrapper=(await import(d(await loader.resolveImports(wrapper,'scripts/repro-wrapper.svelte')))).default; -let target=document.createElement('div');document.body.append(target); -let instance=mount(Wrapper,{target});await settle();target.querySelector('button').click();await settle(); -assert.equal(get(navigation.settingsOpen),true);assert.equal(get(navigation.settingsCategory),'general'); -await unmount(instance);target.remove(); -}); test("display rounding does not save a bandwidth limit", async () => { let target, instance; diff --git a/scripts/test_source_search.mjs b/scripts/test_source_search.mjs deleted file mode 100644 index 64caadac..00000000 --- a/scripts/test_source_search.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import vm from 'node:vm'; -const root = new URL('../', import.meta.url); -const context = vm.createContext({}); -for (const path of ['src-tauri/vendor/flexsearch.bundle.min.js', 'src-tauri/src/source_search.js']) { - vm.runInContext(fs.readFileSync(new URL(path, root), 'utf8'), context, { timeout: 1000 }); -} -context.legioBuildReleaseIndex(['human resource machine steamgg repack', 'machine human resource', 'free download simulacra', 'portal stories mel', 'portal 2']); -assert.deepEqual(JSON.parse(context.legioSearchReleases('human resource machine')).sort(), [0, 1]); -assert.deepEqual(JSON.parse(context.legioSearchReleases('simulacra')), [2]); -assert.deepEqual(JSON.parse(context.legioSearchReleases('unrelated title')), []); -context.legioBuildReleaseIndex(Array.from({ length: 160 }, () => 'same game release')); -assert.equal(JSON.parse(context.legioSearchReleases('same game')).length, 160); -console.log('Vendored FlexSearch: release text, word order, missing titles and uncapped source results passed.'); diff --git a/scripts/test_steam_image_cache.mjs b/scripts/test_steam_image_cache.mjs index d96b1844..3625a109 100644 --- a/scripts/test_steam_image_cache.mjs +++ b/scripts/test_steam_image_cache.mjs @@ -139,26 +139,6 @@ test("releasing a recovered primary does not evict a fallback still used by anot } finally { URL.revokeObjectURL = revoke; } }); -test("refreshing a shared header cannot postpone another consumer's primary recovery", async () => { - let missing = true; - const calls = []; - const now = Date.now; - globalThis.cacheInvoke = (_command, args) => { - if (_command === "cancel_artwork_request") return Promise.resolve(); - calls.push(args.asset); - if (args.asset === "hero" && missing) return Promise.reject(new Error("missing hero")); - return Promise.resolve(packet()); - }; - const heroRequest = request(911, "hero", "header"); - await images.loadSteamImage(heroRequest); - missing = false; - Date.now = () => now() + 61_000; - try { - await images.refreshSteamImagesOnOpen(911, { requestId: "open-911", origin: "visible", release() {} }); - assert.ok(images.peekSteamImage(request(911, "hero"))); - assert.ok(calls.slice(2).includes("hero")); - } finally { Date.now = now; } -}); test("failed page-open refresh preserves the fallback successful-fetch age", async () => { let offline = false; @@ -221,14 +201,6 @@ test("releasing_old_generation_does_not_revoke_another_consumer", async () => { b.release(); assert.ok(revoked.includes(old.url)); } finally { URL.revokeObjectURL = original; } }); -test("details_timestamp_change_preserves_artwork_identity", async () => { - let calls = 0; globalThis.cacheInvoke = async command => { if (command === "get_steam_asset") { calls++; return packet(); } }; - const first = await images.loadSteamImage({ ...request(933, "header"), version: 1 }); - const second = await images.loadSteamImage({ ...request(933, "header"), version: 2 }); - assert.equal(first.url, second.url); assert.equal(calls, 1); - const screenshot = { ...request(934, "screenshot"), index: 0, screenshotIdentity: "https://cdn.steamstatic.com/a.jpg" }; - await images.loadSteamImage(screenshot); await images.loadSteamImage({ ...screenshot, screenshotIdentity: "https://cdn.steamstatic.com/b.jpg" }); assert.equal(calls, 3); -}); test("consumerless_request_cancels_native_work", async () => { const calls = []; let complete; globalThis.cacheInvoke = (command, args) => { calls.push([command, args]); if (command === "get_steam_asset") return new Promise(resolve => { complete = resolve; }); return Promise.resolve({ cacheWarning: null }); }; diff --git a/scripts/test_visual_rendering.mjs b/scripts/test_visual_rendering.mjs index 64757112..143874c8 100644 --- a/scripts/test_visual_rendering.mjs +++ b/scripts/test_visual_rendering.mjs @@ -68,40 +68,6 @@ test("paint-frame cancellation restores opacity and stops future callbacks", asy } finally { node.remove(); frames.clear(); } }); -test("frozen dither requests a persistent drawing buffer without a continuous frame loop", async () => { - let attributes; - let draws = 0; - dom.window.HTMLCanvasElement.prototype.getContext = (_type, configuration) => { - attributes = configuration; - return new Proxy({}, { get: (_, name) => { - if (name === "drawArrays") return () => { draws++; }; - if (name === "getExtension") return () => null; - if (name === "isContextLost") return () => false; - if (name === "checkFramebufferStatus") return () => 1; - if (String(name).toUpperCase() === name) return 1; - return () => true; - } }); - }; - const Background = (await import(await load("src/lib/components/layout/DitherBackground.svelte"))).default; - const target = document.createElement("div"); - document.body.append(target); - const instance = mount(Background, { target, props: { - opacity: 50, accent: "#ffffff", active: true, - settings: { disableAnimation: true, backgroundColor: "#000000", enableMouseInteraction: false, waveSpeed: 0.05, waveFrequency: 3, waveAmplitude: 0.3, mouseRadius: 1, colorNum: 4, pixelSize: 2 }, - } }); - try { - flushSync(); - await new Promise(resolve => setImmediate(resolve)); - flushSync(); - for (const [id, callback] of [...frames]) { frames.delete(id); callback(100); } - assert.ok(draws > 0, "the static background must draw its initial frame"); - assert.equal(attributes.preserveDrawingBuffer, true, "the drawing buffer must survive presentation"); - assert.equal(frames.size, 0, "static backgrounds must not need a continuous loop"); - } finally { - await unmount(instance); - target.remove(); - } -}); test("navigation removes previous page content before the new page enters", async () => { diff --git a/scripts/test_window_activity.mjs b/scripts/test_window_activity.mjs index 912f037f..e7372f0b 100644 --- a/scripts/test_window_activity.mjs +++ b/scripts/test_window_activity.mjs @@ -189,63 +189,6 @@ test("particles cancel frames while inactive and resume with the retained canvas target.remove(); }); -test("static dither redraws on changes without keeping a frame loop alive", async () => { - const { writable } = await import("svelte/store"); - const { mount, unmount, flushSync } = await import("svelte"); - const { compile } = await import("svelte/compiler"); - let draws = 0; - dom.window.HTMLCanvasElement.prototype.getContext = () => new Proxy({}, { get: (_, name) => { - if (name === "drawArrays") return () => draws++; - if (name === "getExtension") return () => null; - if (name === "isContextLost") return () => false; - if (name === "checkFramebufferStatus") return () => 1; - if (String(name).toUpperCase() === name) return 1; - return () => true; - } }); - const frames = new Map(); - let sequence = 0; - globalThis.requestAnimationFrame = (callback) => { frames.set(++sequence, callback); return sequence; }; - globalThis.cancelAnimationFrame = (id) => frames.delete(id); - const render = () => { - const entries = [...frames]; - frames.clear(); - for (const [, callback] of entries) callback(100); - }; - const source = ''; - const code = await createModuleLoader({}).resolveImports(compile(source, { generate: "client" }).js.code, "wrapper.svelte"); - const wrapper = (await import(dataModule(code))).default; - const configuration = { disableAnimation: true, backgroundColor: "#000000", enableMouseInteraction: false }; - const state = writable({ active: true, settings: configuration }); - const target = document.createElement("div"); - document.body.append(target); - const instance = mount(wrapper, { target, props: { state } }); - flushSync(); - await settle(); - flushSync(); - render(); - assert.ok(draws > 0, "static background must draw initially"); - assert.equal(frames.size, 0); - const initialDraws = draws; - render(); - assert.equal(draws, initialDraws, "static background must stay idle between changes"); - state.set({ active: true, settings: { ...configuration, backgroundColor: "#111111" } }); - flushSync(); - render(); - assert.ok(draws > initialDraws, "changing the background must redraw it"); - assert.equal(frames.size, 0); - const changedDraws = draws; - render(); - assert.equal(draws, changedDraws, "redrawing must not start a persistent frame loop"); - state.set({ active: true, settings: { ...configuration, disableAnimation: false } }); - flushSync(); - render(); - assert.equal(frames.size, 1); - state.set({ active: false, settings: configuration }); - flushSync(); - assert.equal(frames.size, 0); - await unmount(instance); - target.remove(); -}); test("visible refresh has no timer while inactive and refreshes immediately on focus restore", async () => { focused = false; diff --git a/src-tauri/src/appearance.rs b/src-tauri/src/appearance.rs index f4287314..a5831f41 100644 --- a/src-tauri/src/appearance.rs +++ b/src-tauri/src/appearance.rs @@ -344,17 +344,6 @@ pub async fn cleanup_theme_backgrounds(app: AppHandle) -> Result<(), String> { mod tests { use super::*; - #[test] - fn default_panels_and_dialogs_are_transparent_and_blurred() { - let defaults = Appearance::default(); - assert_eq!(defaults.surface_opacity, 0); - assert_eq!(defaults.dialog_opacity, 0); - assert_eq!(defaults.surface_blur, 40); - assert_eq!(defaults.dialog_blur, 40); - let missing: Appearance = serde_json::from_str("{}").unwrap(); - assert_eq!(missing, defaults); - } - #[test] fn legacy_appearance_fields_are_tolerated() { let appearance: Appearance = serde_json::from_str( diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 3719116b..80903439 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -1505,11 +1505,17 @@ mod tests { } #[test] - fn new_install_requires_onboarding_and_existing_preferences_skip_it() { + fn new_install_and_legacy_preferences_have_compatible_defaults() { assert!(!Settings::default().onboarding_completed); let existing: Settings = serde_json::from_value(serde_json::json!({"theme":"system"})).unwrap(); - assert!(existing.onboarding_completed); + assert_eq!( + existing, + Settings { + onboarding_completed: true, + ..Settings::default() + } + ); } #[test] @@ -1626,45 +1632,6 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } - #[test] - fn library_portrait_defaults_to_false_and_survives_reopen() { - let defaults: Settings = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap(); - assert!(!defaults.library_portrait); - let directory = temporary_directory(); - let database = Database::open(&directory).unwrap(); - for portrait in [true, false] { - database - .save_settings(Settings { - library_portrait: portrait, - ..Settings::default() - }) - .unwrap(); - let reopened = Database::open(&directory).unwrap(); - assert_eq!(reopened.settings().unwrap().library_portrait, portrait); - } - drop(database); - fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn sidebar_collapsed_defaults_to_true_and_survives_reopen() { - let defaults: Settings = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap(); - assert!(defaults.sidebar_collapsed); - let directory = temporary_directory(); - let database = Database::open(&directory).unwrap(); - database - .save_settings(Settings { - sidebar_collapsed: false, - ..Settings::default() - }) - .unwrap(); - drop(database); - let reopened = Database::open(&directory).unwrap(); - assert!(!reopened.settings().unwrap().sidebar_collapsed); - drop(reopened); - fs::remove_dir_all(directory).unwrap(); - } - #[test] fn manual_steam_launch_does_not_require_a_catalog_app_id() { let directory = temporary_directory(); @@ -2206,7 +2173,8 @@ mod tests { } #[test] - fn verified_hash_check_defaults_on_and_persists_when_disabled() { + fn verified_hash_check_defaults_on_for_fresh_and_legacy_preferences() { + assert!(Settings::default().verify_verified_downloads); let mut preferences = serde_json::to_value(Settings::default()).unwrap(); preferences .as_object_mut() @@ -2217,20 +2185,6 @@ mod tests { .unwrap() .verify_verified_downloads ); - let directory = temporary_directory(); - let database = Database::open(&directory).unwrap(); - assert!(database.settings().unwrap().verify_verified_downloads); - database - .save_settings(Settings { - verify_verified_downloads: false, - ..Settings::default() - }) - .unwrap(); - drop(database); - let database = Database::open(&directory).unwrap(); - assert!(!database.settings().unwrap().verify_verified_downloads); - drop(database); - fs::remove_dir_all(directory).unwrap(); } #[test] @@ -2238,13 +2192,18 @@ mod tests { let directory = temporary_directory(); let database = Database::open(&directory).unwrap(); assert_eq!(database.settings().unwrap(), Settings::default()); - database - .save_settings(Settings { - theme: Theme::Light, - steam_library_poll_minutes: 15, - ..Settings::default() - }) - .unwrap(); + let settings = Settings { + theme: Theme::Light, + steam_library_poll_minutes: 15, + library_portrait: true, + sidebar_collapsed: false, + sync_steam_games: false, + store_sort: "name_desc".to_owned(), + store_availability: "verified".to_owned(), + verify_verified_downloads: false, + ..Settings::default() + }; + database.save_settings(settings.clone()).unwrap(); let game = database .create_game(CreateGameInput { name: "My manual title".to_owned(), @@ -2263,14 +2222,7 @@ mod tests { drop(database); let reopened = Database::open(&directory).unwrap(); - assert_eq!( - reopened.settings().unwrap(), - Settings { - theme: Theme::Light, - steam_library_poll_minutes: 15, - ..Settings::default() - } - ); + assert_eq!(reopened.settings().unwrap(), settings); assert_eq!(reopened.games().unwrap(), vec![enriched]); fs::remove_dir_all(directory).unwrap(); } @@ -2961,28 +2913,6 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } - #[test] - fn store_filters_and_steam_sync_survive_reopen() { - let legacy: Settings = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap(); - assert!(legacy.sync_steam_games); - assert_eq!(legacy.store_sort, "relevance"); - assert_eq!(legacy.store_availability, "all"); - let directory = temporary_directory(); - let database = Database::open(&directory).unwrap(); - assert!(database.settings().unwrap().sync_steam_games); - let mut settings = database.settings().unwrap(); - settings.store_sort = "name_desc".to_owned(); - settings.store_availability = "verified".to_owned(); - settings.sync_steam_games = false; - database.save_settings(settings.clone()).unwrap(); - drop(database); - - let reopened = Database::open(&directory).unwrap(); - assert_eq!(reopened.settings().unwrap(), settings); - drop(reopened); - fs::remove_dir_all(directory).unwrap(); - } - #[test] fn hidden_steam_games_stay_hidden_after_reopen_and_can_be_restored() { let directory = temporary_directory(); diff --git a/src-tauri/src/image_trim.rs b/src-tauri/src/image_trim.rs index ec9e28af..cf8b7166 100644 --- a/src-tauri/src/image_trim.rs +++ b/src-tauri/src/image_trim.rs @@ -273,16 +273,16 @@ mod tests { fn hero_cache_caps_resolution_and_cards_keep_65_percent_without_upscaling() { let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel( 3840, - 1240, + 124, image::Rgba([20, 40, 60, 255]), )); let mut input = Cursor::new(Vec::new()); source.write_to(&mut input, ImageFormat::Png).unwrap(); for (width, sharp_size, card_size) in [ - (1280, (1280, 413), (832, 268)), - (1920, (1920, 620), (1248, 403)), - (2560, (2560, 826), (1664, 536)), - (3840, (3840, 1240), (2496, 806)), + (1280, (1280, 41), (832, 26)), + (1920, (1920, 62), (1248, 40)), + (2560, (2560, 82), (1664, 53)), + (3840, (3840, 124), (2496, 80)), ] { let sharp = hero_webp(input.get_ref(), width, false).unwrap(); let image = image::load_from_memory(&sharp).unwrap(); @@ -297,7 +297,7 @@ mod tests { let unchanged = hero_webp(&small, 3840, false).unwrap(); assert_eq!( image::load_from_memory(&unchanged).unwrap().dimensions(), - (1280, 413) + (1280, 41) ); assert!(hero_webp(input.get_ref(), 0, false).is_err()); assert!(hero_webp(input.get_ref(), 5000, false).is_err()); @@ -305,7 +305,7 @@ mod tests { #[test] fn card_processing_blurs_pixels_and_keeps_the_sharp_banner_separate() { - let source = DynamicImage::ImageRgba8(RgbaImage::from_fn(1920, 620, |x, _| { + let source = DynamicImage::ImageRgba8(RgbaImage::from_fn(1920, 32, |x, _| { let value = if x < 960 { 0 } else { 255 }; image::Rgba([value, value, value, 255]) })); @@ -317,14 +317,14 @@ mod tests { let card = image::load_from_memory(&hero_webp(input.get_ref(), 1920, true).unwrap()) .unwrap() .to_rgba8(); - assert_eq!(sharp.dimensions(), (1920, 620)); - assert_eq!(card.dimensions(), (1248, 403)); - assert!(sharp.get_pixel(959, 310)[0] < 10); + assert_eq!(sharp.dimensions(), (1920, 32)); + assert_eq!(card.dimensions(), (1248, 20)); + assert!(sharp.get_pixel(959, 16)[0] < 10); assert!( - card.get_pixel(611, 201)[0] > 10, + card.get_pixel(611, 10)[0] > 10, "card blur must extend beyond a barely softened edge" ); - assert_eq!(card.get_pixel(623, 201)[3], 255); + assert_eq!(card.get_pixel(623, 10)[3], 255); } #[test] diff --git a/src-tauri/src/source_catalog.rs b/src-tauri/src/source_catalog.rs index 46e7a013..20a643fe 100644 --- a/src-tauri/src/source_catalog.rs +++ b/src-tauri/src/source_catalog.rs @@ -335,15 +335,6 @@ pub(crate) async fn validate_links( mod tests { use super::*; - #[test] - fn native_source_name_survives_cache_roundtrip() { - let bytes = br#"{"schemaVersion":2,"name":"Legio Community","generatedAt":"2026-10-19T00:00:00Z","verified":[],"unverified":[]}"#; - let catalog = parse_source(bytes, &[]).unwrap(); - assert_eq!(catalog.name.as_deref(), Some("Legio Community")); - let restored = decode_cache(&encode_cache(&catalog).unwrap()).unwrap(); - assert_eq!(restored.name, catalog.name); - } - #[tokio::test] async fn validated_links_hide_html_entries_preserve_json_order_and_survive_cache() { for native in [false, true] { @@ -410,8 +401,8 @@ mod tests { } #[test] - fn converted_manifest_preserves_mirror_order_and_unknown_size_in_cache() { - let bytes = br#"{"schemaVersion":2,"supportGameUpdates":true,"generatedAt":"2026-10-19T00:00:00Z","verified":[],"unverified":[{"id":"portal","steamAppId":400,"name":"Portal","release":{"version":"1","publishedAt":"2026-10-19T00:00:00Z"},"download":{"url":"https://gofile.io/d/primary","mirrors":["https://example.test/mirror.zip"],"sha256":null,"sizeBytes":null}}]}"#; + fn native_manifest_preserves_name_mirror_order_and_unknown_size_in_cache() { + let bytes = br#"{"schemaVersion":2,"name":"Legio Community","supportGameUpdates":true,"generatedAt":"2026-10-19T00:00:00Z","verified":[],"unverified":[{"id":"portal","steamAppId":400,"name":"Portal","release":{"version":"1","publishedAt":"2026-10-19T00:00:00Z"},"download":{"url":"https://gofile.io/d/primary","mirrors":["https://example.test/mirror.zip"],"sha256":null,"sizeBytes":null}}]}"#; let catalog = parse_source(bytes, &[]).unwrap(); assert_eq!(catalog.manifest.unverified[0].download.size_bytes, None); assert_eq!( @@ -422,6 +413,8 @@ mod tests { ] ); let restored = decode_cache(&encode_cache(&catalog).unwrap()).unwrap(); + assert_eq!(catalog.name.as_deref(), Some("Legio Community")); + assert_eq!(restored.name, catalog.name); assert_eq!(restored.candidates, catalog.candidates); } @@ -464,22 +457,6 @@ mod tests { assert_eq!(cached.manifest.unverified[0].steam_app_id, 1); } - #[test] - fn steamgg_titles_match_without_changing_release_display_or_trust() { - let title = "Beyond These Stars Free Download [v1.0.2244/Build-25972788]"; - let bytes = serde_json::to_vec(&serde_json::json!({ - "name": "SteamGG", "downloads": [{"title": title, "uris": ["https://gofile.io/d/example"]}] - })).unwrap(); - let source = parse_source(&bytes, &[(123, "Beyond These Stars".into())]).unwrap(); - assert!(source.manifest.verified.is_empty()); - assert_eq!(source.diagnostics.unresolved, 0); - assert_eq!(source.manifest.unverified[0].steam_app_id, 123); - assert_eq!(source.manifest.unverified[0].name, title); - let cached = decode_cache(&encode_cache(&source).unwrap()).unwrap(); - assert_eq!(cached.manifest.unverified[0].steam_app_id, 123); - assert_eq!(cached.manifest.unverified[0].name, title); - } - #[test] fn cache_roundtrip_preserves_unverified_origin_and_unknown_metadata() { let bytes = br#"{"name":"SteamGG","downloads":[{"title":"Portal v1.0","uris":["https://gofile.io/d/test","https://example.test/file.zip"]}]}"#; @@ -492,6 +469,9 @@ mod tests { assert_eq!(source.manifest.unverified[0].release.published_at, None); let cached = decode_cache(&encode_cache(&source).unwrap()).unwrap(); assert_eq!(cached.name.as_deref(), Some("SteamGG")); + assert_eq!(source.diagnostics.unresolved, 0); + assert_eq!(cached.manifest.unverified[0].steam_app_id, 400); + assert_eq!(cached.manifest.unverified[0].name, "Portal v1.0"); assert_eq!(cached.candidates.values().next().unwrap().len(), 2); assert!(cached.manifest.verified.is_empty()); } diff --git a/src-tauri/src/source_matching.rs b/src-tauri/src/source_matching.rs index d19f4a69..56781fdf 100644 --- a/src-tauri/src/source_matching.rs +++ b/src-tauri/src/source_matching.rs @@ -506,34 +506,17 @@ mod tests { #[test] fn preserves_real_title_words_and_edition_identity() { - for title in [ - "Free Download Simulator", - "Download", - "Build 2026", - "Version 2", - "V Rising", - "Portal [Remastered]", - "Game (VR)", + for (title, expected) in [ + ("Free Download Simulator", "free download simulator"), + ("Download", "download"), + ("Build 2026", "build 2026"), + ("Version 2", "version 2"), + ("V Rising", "v rising"), + ("Portal [Remastered]", "portal remastered"), + ("Game (VR)", "game vr"), ] { - assert!(!normalized_title(title).is_empty(), "{title}"); + assert_eq!(normalized_title(title), expected, "{title}"); } - assert_eq!( - normalized_title("Free Download Simulator"), - "free download simulator" - ); - let catalog = vec![(1, "Portal".into()), (2, "Portal 2".into())]; - assert_eq!( - match_title("Portal 2 Free Download [Build-12345]", &catalog), - MatchResult::Matched(2) - ); - assert_eq!( - match_title("Portal 3 Free Download [v1.0]", &catalog), - MatchResult::Unresolved - ); - assert_eq!( - match_title("Portal Remastered Free Download [v1.0]", &catalog), - MatchResult::Unresolved - ); } #[test] diff --git a/src-tauri/src/source_search.rs b/src-tauri/src/source_search.rs index 4d1b649c..25f62711 100644 --- a/src-tauri/src/source_search.rs +++ b/src-tauri/src/source_search.rs @@ -69,7 +69,7 @@ mod tests { use super::*; #[test] - fn searches_release_index_without_assigning_generic_title_subsets() { + fn searches_all_releases_without_assigning_generic_title_subsets() { let releases = vec![ "Machine Human Resource SteamGG Repack".into(), "Free Download SIMULACRA".into(), @@ -90,6 +90,11 @@ mod tests { MatchResult::Unresolved, ] ); + let repeated = vec!["Same Game Release".into(); 160]; + assert_eq!( + find_matches(&repeated, &[(5, "Same Game Release".into())]).unwrap(), + vec![MatchResult::Matched(5); 160] + ); } #[test] diff --git a/src-tauri/src/steam_assets.rs b/src-tauri/src/steam_assets.rs index 436f4dca..fb765837 100644 --- a/src-tauri/src/steam_assets.rs +++ b/src-tauri/src/steam_assets.rs @@ -2249,15 +2249,7 @@ mod tests { .manage(cache.clone()) .build(context) .unwrap(); - let mut png = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( - 1920, - 620, - image::Rgba([20, 40, 60, 255]), - )) - .write_to(&mut png, image::ImageFormat::Png) - .unwrap(); - let sharp = crate::image_trim::hero_webp(png.get_ref(), 1920, false).unwrap(); + let sharp = crate::image_trim::hero_webp(PNG, 1920, false).unwrap(); cache .write("400-hero-display-v1-1920", "test", &sharp, "image/webp") .unwrap(); @@ -2288,7 +2280,7 @@ mod tests { .await .unwrap(); let decoded = image::load_from_memory(&first.bytes).unwrap(); - assert_eq!((decoded.width(), decoded.height()), (1248, 403)); + assert_eq!((decoded.width(), decoded.height()), (1, 1)); assert_eq!( cache .read_cached("400-hero-blur-display-v2-1920", None) From 54b2a37f4aaca0c811ccb45584249b98d10e77f5 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 22:24:06 +0200 Subject: [PATCH 06/12] test: strengthen assertions and consolidate covered scenarios --- src-tauri/src/application_log.rs | 44 +++++----------- src-tauri/src/archive_install.rs | 18 ++++--- src-tauri/src/compatibility_options.rs | 17 ++++--- src-tauri/src/diagnostics.rs | 47 +++++++++-------- src-tauri/src/discord_presence.rs | 2 + src-tauri/src/discord_presence/ipc.rs | 21 ++++++-- src-tauri/src/download_resolver.rs | 27 ++++++++-- src-tauri/src/filesystem_copy.rs | 7 +-- src-tauri/src/finalize_install.rs | 70 ++++++++------------------ src-tauri/src/game_transfer.rs | 35 ++++++++----- src-tauri/src/game_uninstall.rs | 3 +- src-tauri/src/game_updates.rs | 23 +++++++++ src-tauri/src/hydra_source.rs | 4 +- src-tauri/src/image_trim.rs | 4 ++ src-tauri/src/launch_journal.rs | 41 +++++++++------ src-tauri/src/legio_source.rs | 4 +- src-tauri/src/legio_source_cache.rs | 31 ++++++++++-- src-tauri/src/manual_import.rs | 14 ++++++ src-tauri/src/native_rendering.rs | 35 +------------ src-tauri/src/news.rs | 28 ++++++++--- src-tauri/src/online_fix.rs | 2 + src-tauri/src/proton_runtime.rs | 2 +- src-tauri/src/runner_discovery.rs | 17 ++++--- src-tauri/src/source_links.rs | 7 ++- src-tauri/src/webview_diagnostics.rs | 12 +++-- 25 files changed, 299 insertions(+), 216 deletions(-) diff --git a/src-tauri/src/application_log.rs b/src-tauri/src/application_log.rs index 25ebab9a..dfb9ecfa 100644 --- a/src-tauri/src/application_log.rs +++ b/src-tauri/src/application_log.rs @@ -603,26 +603,6 @@ mod tests { std::env::temp_dir().join(format!("legio-app-log-{}", uuid::Uuid::new_v4())) } - #[test] - fn artwork_reports_are_opt_in_bounded_and_readable() { - let directory = directory(); - let log = ApplicationLog::new(directory.clone(), false); - let event: RendererEvent = serde_json::from_str("\"artwork\"").unwrap(); - let message = "resource=image queue_wait_ms=10 metadata_ms=20 transfer_ms=30 conversion_ms=40 outcome=completed"; - log.report(Level::Info, event, Some(message), None).unwrap(); - assert!(!directory.join("app.txt").exists()); - log.set_enabled(true); - log.report(Level::Info, event, Some(message), None).unwrap(); - let text = fs::read_to_string(directory.join("app.txt")).unwrap(); - assert!(text.contains("INFO artwork") && text.contains("queue_wait_ms=10")); - for _ in 1..REPORTS_PER_MINUTE { - log.report(Level::Info, event, Some(message), None).unwrap(); - } - assert!(log.report(Level::Info, event, Some(message), None).is_err()); - assert_eq!(log.status().dropped_records, 1); - fs::remove_dir_all(directory).unwrap(); - } - #[test] fn persisted_records_are_readable_text_with_utc_context_and_multiline_stack() { let directory = directory(); @@ -693,6 +673,13 @@ mod tests { let directory = directory(); let log = ApplicationLog::new(directory.clone(), false); log.record("error", "panic", Some("failure"), None, true); + log.report( + Level::Info, + RendererEvent::Artwork, + Some("conversion"), + None, + ) + .unwrap(); assert!(!directory.exists()); log.set_enabled(true); log.record("info", "startup", None, None, false); @@ -879,16 +866,6 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } - #[test] - fn durable_panic_record_is_readable_without_a_background_flush() { - let directory = directory(); - let log = ApplicationLog::new(directory.clone(), true); - log.record("error", "panic", Some("panic failure"), None, true); - let text = fs::read_to_string(directory.join("app.txt")).unwrap(); - assert!(text.lines().next().unwrap().ends_with("ERROR panic")); - fs::remove_dir_all(directory).unwrap(); - } - #[test] fn renderer_reports_reject_oversized_input_and_limit_each_budget() { let directory = directory(); @@ -912,7 +889,10 @@ mod tests { .is_err() ); assert!(!directory.join("app.txt").exists()); - for _ in 0..50 { + let artwork: RendererEvent = serde_json::from_str("\"artwork\"").unwrap(); + log.report(Level::Info, artwork, Some("queue_wait_ms=10"), None) + .unwrap(); + for _ in 1..50 { log.report(Level::Warn, RendererEvent::Console, Some("warning"), None) .unwrap(); } @@ -935,6 +915,8 @@ mod tests { ); assert_eq!(log.status().dropped_records, 2); let text = fs::read_to_string(directory.join("app.txt")).unwrap(); + assert!(text.contains("INFO artwork")); + assert!(text.contains("queue_wait_ms=10")); assert_eq!( text.lines().filter(|line| line.starts_with('[')).count(), 100 diff --git a/src-tauri/src/archive_install.rs b/src-tauri/src/archive_install.rs index 660a35e8..0358c4be 100644 --- a/src-tauri/src/archive_install.rs +++ b/src-tauri/src/archive_install.rs @@ -423,10 +423,9 @@ mod tests { "safe.7z", ); let stage = result.unwrap(); - assert!( - stage - .join("src-tauri/test-fixtures/archive/safe.zip") - .exists() + assert_eq!( + fs::read(stage.join("src-tauri/test-fixtures/archive/safe.zip")).unwrap(), + include_bytes!("../test-fixtures/archive/safe.zip") ); fs::remove_dir_all(root).unwrap(); } @@ -496,10 +495,13 @@ mod tests { ), ] { let (root, archive) = fixture(bytes, "archive.zip"); - assert_eq!( - verify_and_stage(&archive, None, &root.join("staging")).is_ok(), - allowed - ); + let stage = root.join("staging"); + assert_eq!(verify_and_stage(&archive, None, &stage).is_ok(), allowed); + if allowed { + assert_eq!(fs::read(stage.join("Game/data.bin")).unwrap(), b"hello"); + } else { + assert!(!stage.exists()); + } fs::remove_dir_all(root).unwrap(); } } diff --git a/src-tauri/src/compatibility_options.rs b/src-tauri/src/compatibility_options.rs index 70a76f1b..787fdd28 100644 --- a/src-tauri/src/compatibility_options.rs +++ b/src-tauri/src/compatibility_options.rs @@ -451,11 +451,9 @@ mod tests { let mut command = Command::new("/usr/bin/env"); prepared.apply(&mut command, &config).unwrap(); PreparedOptions::apply_debug_logging(&mut command, &config, &runner, Path::new("/logs")); - assert!( - command - .get_envs() - .any(|(key, value)| { key == "WINEDEBUG" && value == Some(WINE_DEBUG.as_ref()) }) - ); + assert!(command.get_envs().any(|(key, value)| { + key == "WINEDEBUG" && value == Some("+timestamp,+pid,+tid,+seh".as_ref()) + })); config .environment @@ -519,8 +517,13 @@ mod tests { .is_some_and(|value| value == "480") ); let preload = environment.get("LD_PRELOAD").unwrap(); - assert!(preload.contains("ubuntu12_32/gameoverlayrenderer.so")); - assert!(preload.contains("ubuntu12_64/gameoverlayrenderer.so")); + let libraries = std::env::split_paths(preload).collect::>(); + for architecture in ["ubuntu12_32", "ubuntu12_64"] { + let expected = + fs::canonicalize(steam_root.join(architecture).join("gameoverlayrenderer.so")) + .unwrap(); + assert!(libraries.contains(&expected)); + } fs::remove_dir_all(steam_root).unwrap(); } diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 7b4cb477..7565e33b 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -375,30 +375,35 @@ mod tests { #[test] fn rotates_complete_records_with_bounded_retention() { let directory = std::env::temp_dir().join(format!("legio-logs-{}", uuid::Uuid::new_v4())); - let record = Record { - timestamp_ms: 1, - category: "network", - operation: Operation::HydraSearch, - outcome: Outcome::Timeout, - elapsed_ms: 10, - status: None, - response_bytes: None, - }; - for _ in 0..4000 { + fs::create_dir(&directory).unwrap(); + let current = directory.join("network.jsonl"); + let previous = directory.join("network.previous.jsonl"); + fs::write(&previous, b"obsolete generation").unwrap(); + for (outcome, expected) in [(Outcome::Timeout, "timeout"), (Outcome::Success, "success")] { + let record = Record { + timestamp_ms: 1, + category: "network", + operation: Operation::HydraSearch, + outcome, + elapsed_ms: 10, + status: None, + response_bytes: None, + }; + let mut line = serde_json::to_vec(&record).unwrap(); + line.push(b'\n'); + let seed = line.repeat(MAX_LOG_BYTES as usize / line.len()); + fs::write(¤t, &seed).unwrap(); append(&directory, &record).unwrap(); - } - let entries = fs::read_dir(&directory) - .unwrap() - .collect::, _>>() - .unwrap(); - assert_eq!(entries.len(), 2); - for entry in entries { - assert!(entry.metadata().unwrap().len() <= MAX_LOG_BYTES); - for line in fs::read_to_string(entry.path()).unwrap().lines() { - let parsed: serde_json::Value = serde_json::from_str(line).unwrap(); - assert_eq!(parsed["outcome"], "timeout"); + assert_eq!(fs::read(&previous).unwrap(), seed); + assert_eq!(fs::read(¤t).unwrap(), line); + let parsed: serde_json::Value = + serde_json::from_slice(&fs::read(¤t).unwrap()).unwrap(); + assert_eq!(parsed["outcome"], expected); + for path in [¤t, &previous] { + assert!(fs::metadata(path).unwrap().len() <= MAX_LOG_BYTES); } } + assert_eq!(fs::read_dir(&directory).unwrap().count(), 2); fs::remove_dir_all(directory).unwrap(); } diff --git a/src-tauri/src/discord_presence.rs b/src-tauri/src/discord_presence.rs index 7cc7ab7f..a0ca41fe 100644 --- a/src-tauri/src/discord_presence.rs +++ b/src-tauri/src/discord_presence.rs @@ -289,6 +289,8 @@ mod tests { fn activity_limits_utf8_and_preserves_session_time() { let name = limit_text(&"🦈".repeat(100)); assert_eq!(name.len(), 128); + let prefix = "a".repeat(127); + assert_eq!(limit_text(&format!("{prefix}é")), prefix); let activity = Activity { name: Some(name.clone()), started_at: Some(1234), diff --git a/src-tauri/src/discord_presence/ipc.rs b/src-tauri/src/discord_presence/ipc.rs index 476d2928..b7e3de5b 100644 --- a/src-tauri/src/discord_presence/ipc.rs +++ b/src-tauri/src/discord_presence/ipc.rs @@ -176,8 +176,13 @@ mod tests { for ready in [true, false] { let (mut client, mut server) = tokio::io::duplex(4096); let peer = tokio::spawn(async move { - let (opcode, request) = read_frame(&mut server).await.unwrap(); - assert_eq!(opcode, 0); + assert_eq!(server.read_u32_le().await.unwrap(), 0); + let length = server.read_u32_le().await.unwrap() as usize; + assert!(length <= 4096); + let mut bytes = vec![0; length]; + server.read_exact(&mut bytes).await.unwrap(); + let request: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(request["v"], 1); assert_eq!(request["client_id"], "123456789012345678"); write_frame( &mut server, @@ -205,11 +210,21 @@ mod tests { for expected in [Some(json!({"details": "A game"})), None] { let (opcode, request) = read_frame(&mut server).await.unwrap(); assert_eq!(opcode, 1); + assert_eq!(request["cmd"], "SET_ACTIVITY"); + assert_eq!(request["args"]["pid"], std::process::id()); + assert!( + request["nonce"] + .as_str() + .is_some_and(|nonce| !nonce.is_empty()) + ); assert_eq!(request["args"]["activity"], json!(expected)); write_frame(&mut server, 3, &json!({"ping": true})) .await .unwrap(); - assert_eq!(read_frame(&mut server).await.unwrap().0, 4); + assert_eq!( + read_frame(&mut server).await.unwrap(), + (4, json!({"ping": true})) + ); write_frame( &mut server, 1, diff --git a/src-tauri/src/download_resolver.rs b/src-tauri/src/download_resolver.rs index a91ff395..59697cc8 100644 --- a/src-tauri/src/download_resolver.rs +++ b/src-tauri/src/download_resolver.rs @@ -399,9 +399,28 @@ mod tests { } #[test] - fn gofile_folder_resolution_requires_all_files_and_safe_names() { - let contents = serde_json::json!({"type":"folder","children":{"a":{"type":"file","name":"part1.zip","link":"https://store1.gofile.io/download/a/part1.zip"},"b":{"type":"file","name":"part2.zip","link":"https://store1.gofile.io/download/b/part2.zip"}}}); - assert_eq!(gofile_files(&contents).unwrap().len(), 2); - assert!(gofile_files(&serde_json::json!({"type":"file","name":"../escape","link":"https://gofile.io/file"})).is_err()); + fn gofile_file_resolution_requires_safe_names_and_hosts() { + let url = "https://store1.gofile.io/download/game.zip"; + let file = serde_json::json!({"name": "game.zip", "link": url}); + assert_eq!( + gofile_files(&file).unwrap(), + vec![("game.zip".to_owned(), url.to_owned())] + ); + for (key, value) in [ + ("name", serde_json::json!("../escape")), + ( + "link", + serde_json::json!("https://gofile.io.attacker.test/game.zip"), + ), + ( + "link", + serde_json::json!("http://store1.gofile.io/game.zip"), + ), + ("canAccess", serde_json::json!(false)), + ] { + let mut invalid = file.clone(); + invalid[key] = value; + assert!(gofile_files(&invalid).is_err(), "{key}: {invalid}"); + } } } diff --git a/src-tauri/src/filesystem_copy.rs b/src-tauri/src/filesystem_copy.rs index fbbdd7fc..cf25a7c4 100644 --- a/src-tauri/src/filesystem_copy.rs +++ b/src-tauri/src/filesystem_copy.rs @@ -46,11 +46,12 @@ mod tests { .open(&copied) .unwrap(); assert_eq!(copy(&mut source, &mut destination).unwrap(), 8); + assert_eq!(std::fs::read(&copied).unwrap(), b"original"); destination.seek(SeekFrom::Start(0)).unwrap(); destination.write_all(b"new data").unwrap(); - assert_eq!(std::fs::read(original).unwrap(), b"original"); - std::fs::write(&copied, b"changed").unwrap(); - assert_eq!(std::fs::read(copied).unwrap(), b"changed"); + assert_eq!(std::fs::read(&original).unwrap(), b"original"); + std::fs::write(&original, b"changed!").unwrap(); + assert_eq!(std::fs::read(&copied).unwrap(), b"new data"); drop(source); drop(destination); std::fs::remove_dir_all(root).unwrap(); diff --git a/src-tauri/src/finalize_install.rs b/src-tauri/src/finalize_install.rs index b625f8c2..320bd017 100644 --- a/src-tauri/src/finalize_install.rs +++ b/src-tauri/src/finalize_install.rs @@ -785,6 +785,11 @@ mod tests { finalize(&database, &data_dir, &id, "Wrapper/Game/Game.exe").unwrap(); let game = database.game(&id).unwrap(); let root = data_dir.join("installed/Test Game"); + assert_eq!(fs::read(root.join("Game.exe")).unwrap(), b"play"); + assert_eq!(state(&database, &id).0, "installed"); + assert!(!stage.exists()); + assert_eq!(database.games().unwrap().len(), 1); + assert_eq!(game.steam_app_id, Some(42)); assert_eq!( Path::new(game.executable_path.as_ref().unwrap()), root.join("Game.exe") @@ -874,6 +879,18 @@ mod tests { }) .unwrap(); assert!(staged_download_directory(&database, &data_dir, &id).is_err()); + database + .with_connection(|connection| { + connection + .execute( + "UPDATE downloads SET status = 'staged', staged_path = ?2 WHERE id = ?1", + params![id, data_dir.to_string_lossy()], + ) + .map_err(database_error)?; + Ok(()) + }) + .unwrap(); + assert!(staged_download_directory(&database, &data_dir, &id).is_err()); drop(database); fs::remove_dir_all(data_dir).unwrap(); } @@ -889,33 +906,6 @@ mod tests { fs::remove_dir_all(data_dir).unwrap(); } - #[test] - fn finalizes_and_commits_game_and_download() { - let (database, data_dir, id) = fixture(); - let (stage, name) = staged_download_directory(&database, &data_dir, &id).unwrap(); - let scan = manual_import::scan_staged_directory(&stage, Some(&name)).unwrap(); - let executable = scan.selected_relative_path.unwrap(); - finalize(&database, &data_dir, &id, &executable).unwrap(); - let target = data_dir.join("installed/Test Game"); - assert_eq!(fs::read(target.join("game.exe")).unwrap(), b"game"); - assert_eq!(state(&database, &id).0, "installed"); - assert!( - !data_dir - .join("downloads") - .join(format!("{id}.stage")) - .exists() - ); - let games = database.games().unwrap(); - assert_eq!(games.len(), 1); - assert_eq!(games[0].steam_app_id, Some(42)); - assert_eq!( - games[0].executable_path.as_deref().map(Path::new), - Some(target.join("game.exe").as_path()) - ); - drop(database); - fs::remove_dir_all(data_dir).unwrap(); - } - #[test] fn custom_folder_is_the_install_root() { let (database, data_dir, id) = fixture_with_custom_path(true); @@ -966,7 +956,10 @@ mod tests { let target = intent.target.clone(); fs::create_dir_all(&target).unwrap(); fs::write(target.join("foreign"), b"keep").unwrap(); - assert!(finish(&database, &intent).is_err()); + let error = finish(&database, &intent).unwrap_err(); + assert!(error.contains("already exists")); + assert_eq!(state(&database, &id).0, "finalizing"); + assert!(intent.stage.is_dir()); record_error(&database, &id, "conflict").unwrap(); assert_eq!(fs::read(target.join("foreign")).unwrap(), b"keep"); fs::remove_dir_all(&target).unwrap(); @@ -977,27 +970,6 @@ mod tests { fs::remove_dir_all(data_dir).unwrap(); } - #[test] - fn refuses_existing_target_and_keeps_evidence() { - let (database, data_dir, id) = fixture(); - let intent = claim(&database, &data_dir, &id, "bin/game.exe").unwrap(); - let target = intent.target.clone(); - fs::create_dir_all(&target).unwrap(); - fs::write(target.join("foreign"), b"keep").unwrap(); - let error = finish(&database, &intent).unwrap_err(); - assert!(error.contains("already exists")); - assert_eq!(state(&database, &id).0, "finalizing"); - assert_eq!(fs::read(target.join("foreign")).unwrap(), b"keep"); - assert!( - data_dir - .join("downloads") - .join(format!("{id}.stage")) - .exists() - ); - drop(database); - fs::remove_dir_all(data_dir).unwrap(); - } - #[test] fn recovers_after_database_commit_fails_and_database_reopens() { let (database, data_dir, id) = fixture(); diff --git a/src-tauri/src/game_transfer.rs b/src-tauri/src/game_transfer.rs index c0f85bfb..62f11ffa 100644 --- a/src-tauri/src/game_transfer.rs +++ b/src-tauri/src/game_transfer.rs @@ -699,6 +699,7 @@ mod tests { recover(&database).unwrap(); assert!(intent.destination.join(&intent.relative).is_file()); assert!(!intent.destination.join(MARKER).exists()); + assert!(pending_transfers(&database, None).unwrap().is_empty()); recover(&database).unwrap(); drop(database); fs::remove_dir_all(root).unwrap(); @@ -738,6 +739,8 @@ mod tests { recover(&database).unwrap(); assert!(intent.source.join(&intent.relative).exists()); assert!(!intent.temporary.exists()); + assert!(!intent.source.join(MARKER).exists()); + assert!(pending_transfers(&database, None).unwrap().is_empty()); assert_eq!( database .game(&intent.game_id) @@ -762,7 +765,9 @@ mod tests { database.with_connection(|connection| { connection.execute_batch("CREATE TRIGGER fail_transfer BEFORE UPDATE ON games BEGIN SELECT RAISE(ABORT, 'injected'); END;").map_err(database_error) }).unwrap(); + let before = database.game(&intent.game_id).unwrap(); assert!(commit_transfer(&database, &intent).is_err()); + assert_eq!(database.game(&intent.game_id).unwrap(), before); assert_eq!( database .native_launch_config(&intent.game_id) @@ -780,6 +785,16 @@ mod tests { }) .unwrap(); recover(&database).unwrap(); + assert!(pending_transfers(&database, None).unwrap().is_empty()); + assert!(!intent.source.exists()); + assert_eq!( + database + .game(&intent.game_id) + .unwrap() + .installation_root + .as_deref(), + intent.destination.to_str() + ); drop(database); fs::remove_dir_all(root).unwrap(); } @@ -801,23 +816,19 @@ mod tests { #[test] fn remaps_equivalent_source_paths() { - let (root, database, intent) = fixture("prepared"); + let root = std::env::temp_dir().join(format!("legio-remap-{}", Uuid::new_v4())); + let source = root.join("source"); + let destination = root.join("target"); + fs::create_dir_all(source.join("bin")).unwrap(); assert_eq!( remap( - Some( - intent - .source - .join("../source/bin") - .to_string_lossy() - .into_owned() - ), - &dunce::canonicalize(&intent.source).unwrap(), - &intent.destination + Some(source.join("../source/bin").to_string_lossy().into_owned()), + &dunce::canonicalize(&source).unwrap(), + &destination ) .map(PathBuf::from), - Some(intent.destination.join("bin")) + Some(destination.join("bin")) ); - drop(database); fs::remove_dir_all(root).unwrap(); } } diff --git a/src-tauri/src/game_uninstall.rs b/src-tauri/src/game_uninstall.rs index 7b41c8a2..56e32935 100644 --- a/src-tauri/src/game_uninstall.rs +++ b/src-tauri/src/game_uninstall.rs @@ -514,6 +514,7 @@ mod tests { std::os::unix::fs::symlink(&outside, f.directory.join("linked")).unwrap(); let target = checked_target(&f.database, &f.id, &[]).unwrap(); remove_installation(&f.database, &f.id, &target, &[]).unwrap(); - assert!(outside.join("keep.txt").is_file()); + assert!(!f.directory.exists()); + assert_eq!(fs::read(outside.join("keep.txt")).unwrap(), b"keep"); } } diff --git a/src-tauri/src/game_updates.rs b/src-tauri/src/game_updates.rs index 6a5e55c3..0c25758b 100644 --- a/src-tauri/src/game_updates.rs +++ b/src-tauri/src/game_updates.rs @@ -442,6 +442,29 @@ mod tests { queue_automatic(&database).unwrap(); queue_automatic(&database).unwrap(); assert_eq!(count(), 1); + let queued = database + .with_connection(|connection| { + connection + .query_row( + "SELECT target_game_id, source_id, source_entry_id, release_version FROM downloads", + [], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .map_err(database_error) + }) + .unwrap(); + assert_eq!(queued.0, game.id); + assert_eq!( + (queued.1.as_str(), queued.2.as_str(), queued.3.as_str()), + ("source", "portal-onlinefix", "2") + ); let mut other = manifest.clone(); other["verified"][0]["id"] = serde_json::json!("other-variant"); database diff --git a/src-tauri/src/hydra_source.rs b/src-tauri/src/hydra_source.rs index f066e6d5..38336237 100644 --- a/src-tauri/src/hydra_source.rs +++ b/src-tauri/src/hydra_source.rs @@ -160,7 +160,7 @@ mod tests { } #[test] - fn parses_named_source_and_keeps_mirrors_without_claiming_trust() { + fn parses_source_name_mirrors_and_optional_metadata() { let source = parse(br#"{"name":"SteamGG","downloads":[{"title":"Portal v1.0 [SteamGG]","uris":["https://gofile.io/d/example","https://pixeldrain.com/u/example"],"uploadDate":"2026-10-17T00:00:00Z","fileSize":"1.2 GB","verified":true}]}"#).unwrap(); assert_eq!(source.name, "SteamGG"); assert_eq!(source.downloads.len(), 1); @@ -177,7 +177,7 @@ mod tests { } #[test] - fn rejects_bad_shape_and_duplicate_or_unbounded_content() { + fn rejects_invalid_document_shapes() { assert!(parse(br#"{"name":"","downloads":[]}"#).is_err()); assert!(parse(br#"{"schemaVersion":1,"downloads":[]}"#).is_err()); assert!(parse(br#"{"name":"Public","downloads":"wrong"}"#).is_err()); diff --git a/src-tauri/src/image_trim.rs b/src-tauri/src/image_trim.rs index cf8b7166..3df802ce 100644 --- a/src-tauri/src/image_trim.rs +++ b/src-tauri/src/image_trim.rs @@ -342,5 +342,9 @@ mod tests { let trimmed = webp_asset(&bytes).unwrap(); let decoded = image::load_from_memory(&trimmed).unwrap(); assert_eq!(decoded.dimensions(), (6, 6)); + let pixels = decoded.to_rgba8(); + assert_eq!(pixels.get_pixel(0, 0)[3], 255); + assert_eq!(pixels.get_pixel(5, 5)[3], 255); + assert_eq!(pixels.get_pixel(2, 2)[3], 0); } } diff --git a/src-tauri/src/launch_journal.rs b/src-tauri/src/launch_journal.rs index bba6131f..b23ceba0 100644 --- a/src-tauri/src/launch_journal.rs +++ b/src-tauri/src/launch_journal.rs @@ -97,25 +97,34 @@ mod tests { fn journal_records_stages_and_exit_codes_with_bounded_retention() { let directory = std::env::temp_dir().join(format!("legio-launch-journal-{}", uuid::Uuid::new_v4())); - let event = Event { - timestamp_ms: 1, - game_id: "game", - stage: "runner_exit", - failed: true, - exit_code: Some(127), - }; - for _ in 0..6000 { + fs::create_dir(&directory).unwrap(); + let current = directory.join("game-launches.jsonl"); + let previous = directory.join("game-launches.previous.jsonl"); + fs::write(&previous, b"obsolete generation").unwrap(); + for (stage, exit_code) in [("runner_exit", 127), ("second_exit", 126)] { + let event = Event { + timestamp_ms: 1, + game_id: "game", + stage, + failed: true, + exit_code: Some(exit_code), + }; + let mut line = serde_json::to_vec(&event).unwrap(); + line.push(b'\n'); + let seed = line.repeat(MAX_BYTES as usize / line.len()); + fs::write(¤t, &seed).unwrap(); append(&directory, &event).unwrap(); - } - for filename in ["game-launches.jsonl", "game-launches.previous.jsonl"] { - let path = directory.join(filename); - assert!(fs::metadata(&path).unwrap().len() <= MAX_BYTES); - for line in fs::read_to_string(path).unwrap().lines() { - let record: serde_json::Value = serde_json::from_str(line).unwrap(); - assert_eq!(record["exit_code"], 127); - assert_eq!(record["stage"], "runner_exit"); + assert_eq!(fs::read(&previous).unwrap(), seed); + assert_eq!(fs::read(¤t).unwrap(), line); + let record: serde_json::Value = + serde_json::from_slice(&fs::read(¤t).unwrap()).unwrap(); + assert_eq!(record["exit_code"], exit_code); + assert_eq!(record["stage"], stage); + for path in [¤t, &previous] { + assert!(fs::metadata(path).unwrap().len() <= MAX_BYTES); } } + assert_eq!(fs::read_dir(&directory).unwrap().count(), 2); fs::remove_dir_all(directory).unwrap(); } } diff --git a/src-tauri/src/legio_source.rs b/src-tauri/src/legio_source.rs index c2cebb2c..2f7d9abd 100644 --- a/src-tauri/src/legio_source.rs +++ b/src-tauri/src/legio_source.rs @@ -433,7 +433,9 @@ mod tests { ); let mut document = valid(); - let duplicate = document["verified"][0].clone(); + let mut duplicate = document["verified"][0].clone(); + duplicate["release"]["version"] = json!("2.0.0"); + duplicate["download"]["url"] = json!("https://downloads.example.invalid/other.zip"); document["verified"].as_array_mut().unwrap().push(duplicate); assert_eq!( parse(document).unwrap_err().kind, diff --git a/src-tauri/src/legio_source_cache.rs b/src-tauri/src/legio_source_cache.rs index 871d7c72..5dc3362c 100644 --- a/src-tauri/src/legio_source_cache.rs +++ b/src-tauri/src/legio_source_cache.rs @@ -648,7 +648,16 @@ mod tests { )); let loaded = snapshot(&database, 101).unwrap(); assert_eq!(loaded.sources.len(), 2); - assert_eq!(loaded.manifest.unwrap().verified.len(), 2); + let mut app_ids = loaded + .manifest + .as_ref() + .unwrap() + .verified + .iter() + .map(|entry| entry.steam_app_id) + .collect::>(); + app_ids.sort_unstable(); + assert_eq!(app_ids, [400, 402]); remove(&database, &loaded.sources[0].id).unwrap(); drop(database); let reopened = Database::open(&directory).unwrap(); @@ -787,7 +796,6 @@ mod tests { Some(&id), ) .unwrap(); - assert!(!job.id().is_empty()); let sizes = database .with_connection(|connection| { connection @@ -834,7 +842,24 @@ mod tests { Some(123), ) .unwrap(); - assert!(!job.id().is_empty()); + let stored = database + .with_connection(|connection| { + connection + .query_row( + "SELECT url, size_bytes, published_size_bytes FROM downloads WHERE id = ?1", + [job.id()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .map_err(db_error) + }) + .unwrap(); + assert_eq!(stored, ("https://example.test/other.zip".into(), 123, None)); assert!( crate::download_queue::enqueue_prepared( &database, diff --git a/src-tauri/src/manual_import.rs b/src-tauri/src/manual_import.rs index dff9a1ab..8c0916c7 100644 --- a/src-tauri/src/manual_import.rs +++ b/src-tauri/src/manual_import.rs @@ -683,7 +683,15 @@ mod tests { directory.join("Game/bin/other.exe").to_str().unwrap(), ) .unwrap(); + let expected_root = dunce::canonicalize(directory.join("Game")).unwrap(); + let expected_executable = + dunce::canonicalize(directory.join("Game/bin/other.exe")).unwrap(); + assert_eq!(game.installation_root.as_deref(), expected_root.to_str()); assert_eq!(selected.installation_root, game.installation_root); + assert_eq!( + selected.executable_path.as_deref(), + expected_executable.to_str() + ); drop(state); fs::remove_dir_all(directory).unwrap(); } @@ -752,6 +760,10 @@ mod tests { fs::write(root.join("Setup.exe"), []).unwrap(); let scan = scan_directory(root.to_str().unwrap(), None).unwrap(); assert_eq!(scan.candidates.len(), 1); + assert_eq!( + Path::new(&scan.candidates[0].path), + dunce::canonicalize(root.join("Game.exe")).unwrap() + ); fs::remove_dir_all(root).unwrap(); } @@ -778,6 +790,8 @@ mod tests { .unwrap(); drop(database); let reopened = Database::open(&root).unwrap(); + let expected = dunce::canonicalize(&executable).unwrap(); + assert_eq!(game.executable_path.as_deref(), expected.to_str()); assert_eq!(reopened.games().unwrap(), vec![game]); drop(reopened); fs::remove_dir_all(root).unwrap(); diff --git a/src-tauri/src/native_rendering.rs b/src-tauri/src/native_rendering.rs index 597114d4..a58e6a94 100644 --- a/src-tauri/src/native_rendering.rs +++ b/src-tauri/src/native_rendering.rs @@ -173,7 +173,7 @@ fn full_damage( #[cfg(test)] mod tests { use super::*; - use gtk::cairo::{Context, Format, ImageSurface, Operator, RectangleInt, Region}; + use gtk::cairo::{RectangleInt, Region}; #[test] fn skia_runtime_does_not_install_legacy_damage_expansion() { @@ -215,39 +215,6 @@ mod tests { std::fs::remove_dir_all(directory).unwrap(); } - fn draw_frame(surface: &mut ImageSurface, partial_damage: bool) -> Vec { - let damage = Region::create_rectangle(&RectangleInt::new( - 0, - 0, - if partial_damage { 2 } else { 12 }, - if partial_damage { 2 } else { 12 }, - )); - full_damage(&damage, 12, 12).expect("expand damage"); - let context = Context::new(&*surface).expect("image context"); - context.add_region(&damage); - context.clip(); - context.set_operator(Operator::Clear); - context.paint().expect("clear GTK damage region"); - context.set_operator(Operator::Over); - context.set_source_rgba(0.15, 0.15, 0.15, 0.4); - context.paint().expect("draw translucent background"); - context.set_source_rgba(1.0, 1.0, 1.0, 0.6); - context.rectangle(6.0, 6.0, 4.0, 4.0); - context.fill().expect("draw antialiased text pixels"); - drop(context); - surface.flush(); - surface.data().expect("frame pixels").to_vec() - } - - #[test] - fn repeated_partial_updates_do_not_accumulate_translucent_text_pixels() { - let mut surface = ImageSurface::create(Format::ARgb32, 12, 12).expect("image surface"); - let first = draw_frame(&mut surface, false); - for _ in 0..20 { - assert_eq!(draw_frame(&mut surface, true), first); - } - } - #[test] fn damage_tracks_the_current_window_size_and_preserves_existing_regions() { let damage = Region::create_rectangle(&RectangleInt::new(20, 20, 2, 2)); diff --git a/src-tauri/src/news.rs b/src-tauri/src/news.rs index db2118f8..81846d3b 100644 --- a/src-tauri/src/news.rs +++ b/src-tauri/src/news.rs @@ -219,7 +219,7 @@ mod tests { assert!(parse(&vec![b' '; MAX_BYTES + 1]).is_err()); } #[test] - fn cache_survives_database_reopen() { + fn cached_feed_survives_reopen_and_is_revalidated() { let root = std::env::temp_dir().join(format!("legio-news-{}", uuid::Uuid::new_v4())); let state = DatabaseState::new(Ok(root.clone())); let snapshot = Snapshot { @@ -242,15 +242,27 @@ mod tests { .unwrap(); drop(state); let reopened = DatabaseState::new(Ok(root.clone())); + let cached = read_cache(reopened.database().unwrap()).unwrap(); assert_eq!( - read_cache(reopened.database().unwrap()) - .unwrap() - .feed - .unwrap() - .items[0] - .id, - "welcome" + serde_json::to_value(cached).unwrap(), + serde_json::to_value(&snapshot).unwrap() ); + let mut corrupted = serde_json::to_value(&snapshot).unwrap(); + corrupted["feed"]["schemaVersion"] = 2.into(); + reopened + .database() + .unwrap() + .with_connection(|connection| { + connection + .execute( + "UPDATE settings SET value = ?1 WHERE key = 'news_cache'", + [serde_json::to_string(&corrupted).unwrap()], + ) + .map(|_| ()) + .map_err(|error| error.to_string()) + }) + .unwrap(); + assert!(read_cache(reopened.database().unwrap()).is_err()); drop(reopened); std::fs::remove_dir_all(root).unwrap(); } diff --git a/src-tauri/src/online_fix.rs b/src-tauri/src/online_fix.rs index 8814ce05..ac85b62d 100644 --- a/src-tauri/src/online_fix.rs +++ b/src-tauri/src/online_fix.rs @@ -191,5 +191,7 @@ mod tests { assert!(!enabled(true, Some(true), true)); assert!(!enabled(false, Some(false), true)); assert!(enabled(false, None, true)); + assert!(enabled(false, Some(true), false)); + assert!(!enabled(false, None, false)); } } diff --git a/src-tauri/src/proton_runtime.rs b/src-tauri/src/proton_runtime.rs index 921fc187..7254a598 100644 --- a/src-tauri/src/proton_runtime.rs +++ b/src-tauri/src/proton_runtime.rs @@ -314,12 +314,12 @@ mod tests { install_runtime(&steam, 1628350, "sniper"); let prefix = data.join("pfx"); prepare(&runner, &steam, &prefix, &prefix).unwrap(); + prepare(&runner, &steam, &prefix, &prefix).unwrap(); assert_eq!(fs::read_link(prefix.join("pfx")).unwrap(), Path::new(".")); assert_eq!( fs::read(prefix.join("drive_c/save.dat")).unwrap(), b"saved game" ); - prepare(&runner, &steam, &prefix, &prefix).unwrap(); fs::remove_dir_all(root).unwrap(); } diff --git a/src-tauri/src/runner_discovery.rs b/src-tauri/src/runner_discovery.rs index e2200c55..2f282acd 100644 --- a/src-tauri/src/runner_discovery.rs +++ b/src-tauri/src/runner_discovery.rs @@ -415,11 +415,11 @@ mod tests { let root = temp_dir(); let tools = root.join("compatibilitytools.d"); fs::create_dir_all(&tools).unwrap(); - let ge = make_runner(&tools, "GE-Proton9-1", Some("GE-Proton9-1-custom\n")); - let proton = make_runner(&tools, "Proton 9.0", None); + make_runner(&tools, "GE-Proton9-1", Some("GE-Proton9-1-custom\n")); + make_runner(&tools, "Proton 9.0", None); let fake_game = make_runner(&tools, "Proton Game", None); fs::remove_file(fake_game.join("proton")).unwrap(); - let unrelated = make_runner(&tools, "OtherTool", None); + make_runner(&tools, "OtherTool", None); let mut entries = fs::read_dir(&tools) .unwrap() @@ -431,10 +431,13 @@ mod tests { .filter_map(|entry| classify_proton_dir(&entry.path())) .collect::>(); - assert_eq!(found.len(), 2); - assert_eq!(found[0].0, RunnerKind::GeProton); - assert_eq!(found[1].0, RunnerKind::Proton); - assert!(ge.exists() && proton.exists() && unrelated.exists()); + assert_eq!( + found, + vec![ + (RunnerKind::GeProton, "GE-Proton9-1".to_owned()), + (RunnerKind::Proton, "Proton 9.0".to_owned()), + ] + ); fs::remove_dir_all(root).unwrap(); } diff --git a/src-tauri/src/source_links.rs b/src-tauri/src/source_links.rs index 9be2fe1b..c84be2ff 100644 --- a/src-tauri/src/source_links.rs +++ b/src-tauri/src/source_links.rs @@ -201,7 +201,12 @@ mod tests { ); let pending = links.take().unwrap(); assert_eq!(pending.requests.len(), 2); - assert!(pending.requests[0].url.is_some()); + assert_eq!( + pending.requests[0].url.as_deref(), + Some("https://example.invalid/a.json") + ); + assert!(pending.requests[0].error.is_none()); + assert!(pending.requests[1].url.is_none()); assert!(pending.requests[1].error.is_some()); assert!(links.take().unwrap().requests.is_empty()); } diff --git a/src-tauri/src/webview_diagnostics.rs b/src-tauri/src/webview_diagnostics.rs index 7c636c62..5544ea06 100644 --- a/src-tauri/src/webview_diagnostics.rs +++ b/src-tauri/src/webview_diagnostics.rs @@ -263,9 +263,12 @@ mod tests { record_browser_exit(&log, Some(0)); record_browser_exit(&log, Some(1)); let records = records(&directory); - assert!(records.contains("INFO webview_browser_exited")); - assert!(records.contains("ERROR webview_browser_exited")); - assert!(records.contains("kind: 1")); + let events = records.trim_end().split("\n\n").collect::>(); + assert_eq!(events.len(), 2); + assert!(events[0].contains("INFO webview_browser_exited")); + assert!(events[0].contains("kind: 0")); + assert!(events[1].contains("ERROR webview_browser_exited")); + assert!(events[1].contains("kind: 1")); std::fs::remove_dir_all(directory).unwrap(); } @@ -276,7 +279,8 @@ mod tests { record_runtime(&log, "135.0.1.2"); record_runtime(&log, "https://PRIVATE_USER/private-profile"); let records = records(&directory); - assert!(records.contains("135.0.1.2")); + assert!(records.contains("WebView2 version: 135.0.1.2")); + assert!(records.contains("WebView2 version: unavailable")); assert!(!records.contains("PRIVATE_USER")); std::fs::remove_dir_all(directory).unwrap(); } From b92ad195c589be1b0df8d323ce563080cb534b28 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 22:32:43 +0200 Subject: [PATCH 07/12] test: check migration effects and use platform-correct paths --- src-tauri/src/database.rs | 118 ++++++++++++++++++--------------- src-tauri/src/game_library.rs | 2 +- src-tauri/src/game_transfer.rs | 15 +++-- 3 files changed, 74 insertions(+), 61 deletions(-) diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 80903439..0b6b50a9 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -1633,7 +1633,7 @@ mod tests { } #[test] - fn manual_steam_launch_does_not_require_a_catalog_app_id() { + fn manual_steam_launch_respects_platform_support_without_a_catalog_app_id() { let directory = temporary_directory(); let database = Database::open(&directory).unwrap(); let game = database @@ -1642,16 +1642,20 @@ mod tests { steam_app_id: None, }) .unwrap(); - let saved = database - .save_game_compatibility_overrides( - &game.id, - GameCompatibilityOverrides { - launch_via_steam: Some(true), - ..GameCompatibilityOverrides::default() - }, - ) - .unwrap(); - assert_eq!(saved.launch_via_steam, Some(true)); + let saved = database.save_game_compatibility_overrides( + &game.id, + GameCompatibilityOverrides { + launch_via_steam: Some(true), + ..GameCompatibilityOverrides::default() + }, + ); + #[cfg(target_os = "linux")] + assert_eq!(saved.unwrap().launch_via_steam, Some(true)); + #[cfg(not(target_os = "linux"))] + assert_eq!( + saved.unwrap_err(), + "Launch via Steam is available on Linux only" + ); assert_eq!(database.game(&game.id).unwrap().steam_app_id, None); drop(database); fs::remove_dir_all(directory).unwrap(); @@ -1730,6 +1734,16 @@ mod tests { .unwrap(); assert_eq!(game_name, "Existing"); assert_eq!(version, SCHEMA_VERSION); + let restored_tables: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN + ('compatibility_defaults', 'game_compatibility_overrides', + 'game_native_launch_config', 'game_sessions')", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(restored_tables, 4); } #[test] @@ -1846,21 +1860,14 @@ mod tests { ) .unwrap(); migrate(&connection).unwrap(); - let name: String = connection + let game: (String, Option) = connection .query_row( - "SELECT name_override FROM games WHERE id = 'manual'", + "SELECT name_override, executable_path FROM games WHERE id = 'manual'", [], - |row| row.get(0), - ) - .unwrap(); - let downloads_exists: i64 = connection - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'downloads'", - [], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); - assert_eq!((name.as_str(), downloads_exists), ("Manual game", 1)); + assert_eq!(game, ("Manual game".to_owned(), None)); } #[test] @@ -1978,10 +1985,8 @@ mod tests { ..CompatibilityDefaults::default() }) .unwrap(); - assert_eq!( - saved.prefix_root.as_deref(), - Some(directory.to_str().unwrap()) - ); + let canonical = fs::canonicalize(&directory).unwrap(); + assert_eq!(saved.prefix_root.as_deref(), canonical.to_str()); assert!( database .save_compatibility_defaults(CompatibilityDefaults { @@ -2052,7 +2057,12 @@ mod tests { .save_game_compatibility_overrides( &first.id, GameCompatibilityOverrides { - prefix_path: Some("/games/original-prefix".to_owned()), + prefix_path: Some( + directory + .join("original-prefix") + .to_string_lossy() + .into_owned(), + ), ..Default::default() }, ) @@ -2061,7 +2071,12 @@ mod tests { .save_game_compatibility_overrides( &second.id, GameCompatibilityOverrides { - prefix_path: Some("/games/modded-prefix".to_owned()), + prefix_path: Some( + directory + .join("modded-prefix") + .to_string_lossy() + .into_owned(), + ), ..Default::default() }, ) @@ -2078,7 +2093,7 @@ mod tests { .unwrap() .prefix_path .as_deref(), - Some("/games/original-prefix") + directory.join("original-prefix").to_str() ); assert_eq!( database @@ -2086,7 +2101,7 @@ mod tests { .unwrap() .prefix_path .as_deref(), - Some("/games/modded-prefix") + directory.join("modded-prefix").to_str() ); let summaries = database.playtime_summaries(4_000).unwrap(); assert_eq!( @@ -2383,7 +2398,7 @@ mod tests { } #[test] - fn compatibility_settings_persist_and_empty_game_values_clear_inherited_collections() { + fn compatibility_defaults_and_explicit_overrides_survive_reopen() { let directory = temporary_directory(); let database = Database::open(&directory).unwrap(); let game = database @@ -2397,7 +2412,7 @@ mod tests { environment.insert("WINEDEBUG".to_owned(), "-all".to_owned()); let defaults = CompatibilityDefaults { runner_path: Some("/opt/Proton 10.0".to_owned()), - prefix_root: Some("/prefixes".to_owned()), + prefix_root: Some(directory.join("prefixes").to_string_lossy().into_owned()), arguments_before: vec!["-windowed".to_owned()], arguments_after: Vec::new(), working_directory: Some("/games/default".to_owned()), @@ -2434,15 +2449,6 @@ mod tests { assert_eq!(reopened.compatibility_defaults().unwrap(), defaults); let stored = reopened.game_compatibility_overrides(&game.id).unwrap(); assert_eq!(stored, overrides); - assert_eq!(stored.runner_path, None); - assert_eq!(stored.arguments_before, Some(Vec::new())); - assert_eq!(stored.environment, Some(BTreeMap::new())); - assert_eq!( - stored.graphics_renderer, - Some(GraphicsRenderer::RunnerDefault) - ); - assert_eq!(stored.wayland, Some(WaylandMode::Disabled)); - assert_eq!(stored.debug_logging, Some(false)); fs::remove_dir_all(directory).unwrap(); } @@ -2486,7 +2492,7 @@ mod tests { database .save_compatibility_defaults(CompatibilityDefaults { runner_path: Some("/opt/Proton 10.0".to_owned()), - prefix_root: Some("/prefixes".to_owned()), + prefix_root: Some(directory.join("prefixes").to_string_lossy().into_owned()), arguments_before: vec!["default-before".to_owned()], arguments_after: vec!["default-after".to_owned()], working_directory: Some("/games/default".to_owned()), @@ -2508,7 +2514,12 @@ mod tests { &game.id, GameCompatibilityOverrides { runner_path: Some("/opt/Proton custom".to_owned()), - prefix_path: Some("/prefixes/custom".to_owned()), + prefix_path: Some( + directory + .join("custom-prefix") + .to_string_lossy() + .into_owned(), + ), arguments_before: Some(vec!["game-before".to_owned()]), working_directory: Some(String::new()), environment: Some(environment), @@ -2522,8 +2533,14 @@ mod tests { let effective = database.effective_compatibility_config(&game.id).unwrap(); assert_eq!(effective.runner_path.as_deref(), Some("/opt/Proton custom")); - assert_eq!(effective.prefix_root.as_deref(), Some("/prefixes")); - assert_eq!(effective.prefix_path.as_deref(), Some("/prefixes/custom")); + assert_eq!( + effective.prefix_root.as_deref(), + directory.join("prefixes").to_str() + ); + assert_eq!( + effective.prefix_path.as_deref(), + directory.join("custom-prefix").to_str() + ); assert_eq!(effective.arguments_before, vec!["game-before"]); assert_eq!(effective.arguments_after, vec!["default-after"]); assert_eq!(effective.working_directory, None); @@ -2569,7 +2586,7 @@ mod tests { database .save_compatibility_defaults(CompatibilityDefaults { runner_path: Some("/usr/bin/wine".to_owned()), - prefix_root: Some("/prefixes".to_owned()), + prefix_root: Some(directory.join("prefixes").to_string_lossy().into_owned()), arguments_before: vec!["default".to_owned()], arguments_after: vec!["default".to_owned()], working_directory: Some("/games/default".to_owned()), @@ -2598,7 +2615,7 @@ mod tests { database.effective_compatibility_config(&game.id).unwrap(), EffectiveCompatibilityConfig { runner_path: None, - prefix_root: Some("/prefixes".to_owned()), + prefix_root: Some(directory.join("prefixes").to_string_lossy().into_owned()), prefix_path: None, arguments_before: Vec::new(), arguments_after: Vec::new(), @@ -2642,13 +2659,6 @@ mod tests { ..EffectiveCompatibilityConfig::default() } ); - assert!( - database - .effective_compatibility_config(&game.id) - .unwrap() - .debug_logging - ); - database .save_game_compatibility_overrides( &game.id, diff --git a/src-tauri/src/game_library.rs b/src-tauri/src/game_library.rs index 60f2d345..ada8f71f 100644 --- a/src-tauri/src/game_library.rs +++ b/src-tauri/src/game_library.rs @@ -973,7 +973,7 @@ mod tests { } #[test] - fn json_manifest_is_portable_and_does_not_export_source_urls() { + fn json_manifest_uses_relative_installation_paths() { let directory = std::env::temp_dir().join(format!("legio-json-{}", uuid::Uuid::new_v4())); let database = Database::open(&directory).unwrap(); let root = directory.join("games"); diff --git a/src-tauri/src/game_transfer.rs b/src-tauri/src/game_transfer.rs index 62f11ffa..1c56beb1 100644 --- a/src-tauri/src/game_transfer.rs +++ b/src-tauri/src/game_transfer.rs @@ -788,12 +788,15 @@ mod tests { assert!(pending_transfers(&database, None).unwrap().is_empty()); assert!(!intent.source.exists()); assert_eq!( - database - .game(&intent.game_id) - .unwrap() - .installation_root - .as_deref(), - intent.destination.to_str() + dunce::canonicalize( + database + .game(&intent.game_id) + .unwrap() + .installation_root + .unwrap() + ) + .unwrap(), + dunce::canonicalize(&intent.destination).unwrap() ); drop(database); fs::remove_dir_all(root).unwrap(); From 43a2af60fa84cbc4f98833c912d50d346b88f436 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 23:40:46 +0200 Subject: [PATCH 08/12] test: finish individual Rust test review and fix weak coverage --- src-tauri/src/artwork_scheduler.rs | 8 +- src-tauri/src/catalog.rs | 2 +- src-tauri/src/compatibility_logs.rs | 15 +++- src-tauri/src/desktop_shortcuts.rs | 131 +++++++--------------------- src-tauri/src/download_queue.rs | 61 +++++++------ src-tauri/src/game_artwork.rs | 39 ++++++++- src-tauri/src/game_lifecycle.rs | 66 +++++--------- src-tauri/src/game_process.rs | 2 +- src-tauri/src/steam_assets.rs | 38 +++++--- src-tauri/src/steam_details.rs | 19 ++-- src-tauri/src/steam_import.rs | 18 ++-- src-tauri/src/steam_local.rs | 87 ++++++++++++------ src-tauri/src/steam_pics.rs | 14 +-- src-tauri/src/steam_switch.rs | 1 + src-tauri/src/steam_vdf.rs | 9 +- 15 files changed, 264 insertions(+), 246 deletions(-) diff --git a/src-tauri/src/artwork_scheduler.rs b/src-tauri/src/artwork_scheduler.rs index fdab1cbc..28322b47 100644 --- a/src-tauri/src/artwork_scheduler.rs +++ b/src-tauri/src/artwork_scheduler.rs @@ -723,10 +723,10 @@ mod tests { s.acquire_conversion(&b), ) .await; - assert!( - second.is_ok(), - "An unselected Library must use both conversion slots" - ); + let second = second + .expect("An unselected Library must use both conversion slots") + .unwrap(); + drop(second); drop(first); } diff --git a/src-tauri/src/catalog.rs b/src-tauri/src/catalog.rs index 9004ded9..fb204674 100644 --- a/src-tauri/src/catalog.rs +++ b/src-tauri/src/catalog.rs @@ -950,7 +950,7 @@ mod tests { } #[test] - fn rejects_invalid_remote_pages_before_cache_mutation() { + fn rejects_invalid_remote_pages() { for bytes in [ br#"{"count":1,"edges":[{"objectId":"0","title":"Game","shop":"steam"}]}"#.as_slice(), br#"{"count":1,"edges":[{"objectId":"1","title":" ","shop":"steam"}]}"#.as_slice(), diff --git a/src-tauri/src/compatibility_logs.rs b/src-tauri/src/compatibility_logs.rs index 03ec929d..c9760aeb 100644 --- a/src-tauri/src/compatibility_logs.rs +++ b/src-tauri/src/compatibility_logs.rs @@ -733,7 +733,10 @@ mod tests { serde_json::json!(["SteamGameId", "0"]) ); let path = directory.join("steam-0.log"); - fs::write(&path, vec![b'x'; MAX_PROTON_LOG_BYTES as usize + 1]).unwrap(); + File::create(&path) + .unwrap() + .set_len(MAX_PROTON_LOG_BYTES + 1) + .unwrap(); logs.cap_runner_log(); assert_eq!(fs::metadata(path).unwrap().len(), MAX_PROTON_LOG_BYTES); drop(logs); @@ -746,13 +749,14 @@ mod tests { let config = EffectiveCompatibilityConfig::default(); let mut logs = test_log(&root, &config); let mut child = std::process::Command::new("/bin/sh") - .args(["-c", "printf runner-out; printf runner-err >&2"]) + .args(["-c", "printf runner-out; printf runner-err >&2; exit 7"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); logs.capture_output(&mut child).unwrap(); let status = child.wait().unwrap(); + assert_eq!(status.code(), Some(7)); logs.record_exit_code(status.code().unwrap()); logs.wait_for_readers(); let directory = logs.state().directory().to_path_buf(); @@ -766,7 +770,7 @@ mod tests { ); assert_eq!( fs::read_to_string(directory.join("runner-exit-code.txt")).unwrap(), - "0\n" + "7\n" ); fs::remove_dir_all(root).unwrap(); } @@ -828,7 +832,10 @@ mod tests { let config = EffectiveCompatibilityConfig::default(); let logs = test_log(&root, &config); let path = logs.state().directory().join("steam-default.log"); - fs::write(&path, vec![b'x'; MAX_PROTON_LOG_BYTES as usize + 1]).unwrap(); + File::create(&path) + .unwrap() + .set_len(MAX_PROTON_LOG_BYTES + 1) + .unwrap(); logs.cap_runner_log(); assert_eq!(fs::metadata(path).unwrap().len(), MAX_PROTON_LOG_BYTES); assert!(logs.state().truncated()); diff --git a/src-tauri/src/desktop_shortcuts.rs b/src-tauri/src/desktop_shortcuts.rs index 05ec5aeb..a53cb1f7 100644 --- a/src-tauri/src/desktop_shortcuts.rs +++ b/src-tauri/src/desktop_shortcuts.rs @@ -943,28 +943,6 @@ mod linux { Ok(path) } - #[cfg(test)] - fn remove_owned_entry(directory: &Path, filename: &str, game_id: &str) -> Result<(), String> { - let path = directory.join(filename); - let metadata = match fs::symlink_metadata(&path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(format!("Could not inspect shortcut: {error}")), - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err("shortcut path is not a regular file".to_owned()); - } - if metadata.len() > 64 * 1024 { - return Err("refusing to remove an oversized desktop entry".to_owned()); - } - let contents = fs::read_to_string(&path) - .map_err(|error| format!("Could not inspect shortcut: {error}"))?; - if !is_owned_entry(&contents, game_id) { - return Err("refusing to remove a desktop entry not owned by Legio".to_owned()); - } - fs::remove_file(path).map_err(|error| format!("Could not remove shortcut: {error}")) - } - fn is_owned_entry(contents: &str, game_id: &str) -> bool { let mut lines = contents.lines(); let marker = format!("X-Legio-GameId={game_id}"); @@ -988,10 +966,11 @@ mod linux { "Round\n\"Trip\"", Path::new("/opt/Legio App %$`\\.AppImage"), "00000000-0000-0000-0000-000000000001", - None, + Some(Path::new("/tmp/Legio Icons/game icon.png")), ) .unwrap(); assert!(entry.contains("Name=Round\\n\"Trip\"")); + assert!(entry.contains("Icon=/tmp/Legio Icons/game icon.png\n")); assert!( entry.contains( "Exec=\"/opt/Legio App %%\\\\$\\\\`\\\\\\\\.AppImage\" --launch-game=00000000-0000-0000-0000-000000000001" @@ -1050,11 +1029,15 @@ mod linux { ) .unwrap_err(); assert!(error.contains("not owned by Legio")); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "[Desktop Entry]\nName=Other application\nX-Legio-GameId=other-game\n" + ); fs::remove_dir_all(directory).unwrap(); } #[test] - fn game_shortcut_uses_readable_filename_and_preserves_name_collisions() { + fn shortcut_filename_and_ownership_detection_keep_game_identities_separate() { let directory = test_dir("readable-name"); let first_id = "00000000-0000-0000-0000-000000000001"; let second_id = "00000000-0000-0000-0000-000000000002"; @@ -1076,56 +1059,36 @@ mod linux { } #[test] - fn removes_only_legio_owned_shortcuts() { + fn removes_only_owned_shortcuts_and_preserves_kept_foreign_and_symlink_entries() { + use std::os::unix::fs::symlink; + let directory = test_dir("remove-owned"); let game_id = "00000000-0000-0000-0000-000000000001"; - let filename = format!("legio-game-{game_id}.desktop"); let contents = desktop_entry("Test Game", Path::new("/usr/bin/legio"), game_id, None).unwrap(); - let path = write_entry(&directory, &filename, game_id, &contents).unwrap(); - - remove_owned_entry(&directory, &filename, game_id).unwrap(); - - assert!(!path.exists()); - fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn refuses_to_remove_a_shortcut_not_owned_by_legio() { - let directory = test_dir("remove-foreign"); - let game_id = "00000000-0000-0000-0000-000000000001"; - let filename = format!("legio-game-{game_id}.desktop"); - let path = directory.join(&filename); - fs::write( - &path, - "[Desktop Entry]\nName=Other app\nX-Legio-GameId=other-game\n", - ) - .unwrap(); - - let error = remove_owned_entry(&directory, &filename, game_id).unwrap_err(); - - assert!(error.contains("not owned by Legio")); - assert!(path.is_file()); - fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn refuses_to_remove_a_symlinked_shortcut() { - use std::os::unix::fs::symlink; - - let directory = test_dir("remove-symlink"); - let game_id = "00000000-0000-0000-0000-000000000001"; - let filename = format!("legio-game-{game_id}.desktop"); - let target = directory.join("outside.desktop"); - fs::write( - &target, - format!("[Desktop Entry]\nX-Legio-GameId={game_id}\n"), - ) - .unwrap(); - symlink(&target, directory.join(&filename)).unwrap(); - - assert!(remove_owned_entry(&directory, &filename, game_id).is_err()); - assert!(target.is_file()); + let owned = write_entry(&directory, "old.desktop", game_id, &contents).unwrap(); + let kept = write_entry(&directory, "current.desktop", game_id, &contents).unwrap(); + let foreign = directory.join("foreign.desktop"); + let foreign_contents = "[Desktop Entry]\nName=Other app\nX-Legio-GameId=other-game\n"; + fs::write(&foreign, foreign_contents).unwrap(); + fs::create_dir(directory.join("outside")).unwrap(); + let target = directory.join("outside/target.desktop"); + fs::write(&target, &contents).unwrap(); + let link = directory.join("link.desktop"); + symlink(&target, &link).unwrap(); + + remove_other_owned_entries(&directory, game_id, Some(&kept)).unwrap(); + + assert!(!owned.exists()); + assert_eq!(fs::read_to_string(&kept).unwrap(), contents); + assert_eq!(fs::read_to_string(&foreign).unwrap(), foreign_contents); + assert!( + fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(fs::read_to_string(&target).unwrap(), contents); fs::remove_dir_all(directory).unwrap(); } @@ -1149,34 +1112,6 @@ mod linux { assert!(require_utf8_directory(&directory).is_err()); } - #[test] - fn installed_desktop_entry_passes_desktop_file_validate_when_available() { - let directory = test_dir("validate"); - let contents = desktop_entry( - "Game with % and \"quotes\"", - Path::new("/opt/Legio App.AppImage"), - "00000000-0000-0000-0000-000000000001", - Some(Path::new("/tmp/Legio Icons/game icon.png")), - ) - .unwrap(); - let path = write_entry( - &directory, - "legio-game-00000000-0000-0000-0000-000000000001.desktop", - "00000000-0000-0000-0000-000000000001", - &contents, - ) - .unwrap(); - assert!(contents.contains("Icon=/tmp/Legio Icons/game icon.png\n")); - if let Ok(output) = Command::new("desktop-file-validate").arg(&path).output() { - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - fs::remove_dir_all(directory).unwrap(); - } - #[test] fn launch_argument_requires_one_valid_game_id() { use std::ffi::OsString; diff --git a/src-tauri/src/download_queue.rs b/src-tauri/src/download_queue.rs index 236bd5e0..dab59beb 100644 --- a/src-tauri/src/download_queue.rs +++ b/src-tauri/src/download_queue.rs @@ -873,20 +873,6 @@ fn recover_staging(queue: &DownloadQueueState, database: &Database) -> Result<() Ok(()) } -#[cfg(test)] -fn downloaded_ids(database: &Database) -> Result, String> { - database.with_connection(|connection| { - let mut statement = connection - .prepare("SELECT id FROM downloads WHERE status = 'downloaded' ORDER BY queue_position, created_at, id") - .map_err(db_error)?; - statement - .query_map([], |row| row.get(0)) - .map_err(db_error)? - .collect::>() - .map_err(db_error) - }) -} - fn remove_partial(path: &Path) -> std::io::Result<()> { match fs::remove_file(path) { Ok(()) => Ok(()), @@ -2283,6 +2269,9 @@ mod tests { false, ) .unwrap(); + let mut settings = database.settings().unwrap(); + settings.download_notifications = true; + database.save_settings(settings.clone()).unwrap(); for state in [ "downloading", "downloaded", @@ -2320,9 +2309,6 @@ mod tests { Ok(()) }) .unwrap(); - let mut settings = database.settings().unwrap(); - settings.download_notifications = true; - database.save_settings(settings.clone()).unwrap(); assert_eq!( completion_notification(&database, &job.id) .unwrap() @@ -2542,7 +2528,11 @@ mod tests { fn changing_bandwidth_does_not_charge_previous_bytes() { let mut window = BandwidthWindow::new(0, 0); assert!(window.delay(100 * 1024 * 1024, 1024 * 1024).is_zero()); - assert!(window.delay(101 * 1024 * 1024, 1024 * 1024) <= Duration::from_secs(1)); + let before = window.started.elapsed(); + let delay = window.delay(101 * 1024 * 1024, 1024 * 1024); + let after = window.started.elapsed(); + assert!(delay >= Duration::from_secs(1).saturating_sub(after)); + assert!(delay <= Duration::from_secs(1).saturating_sub(before)); assert!(window.delay(101 * 1024 * 1024, 0).is_zero()); } @@ -2730,7 +2720,9 @@ mod tests { let first = enqueue(&database, 400, url, "1", true).unwrap(); let second = enqueue(&database, 400, url, "2", true).unwrap(); assert_eq!(first.sha256, None); + assert_eq!(first.release_version, "1"); assert_eq!(second.release_version, "2"); + assert_ne!(first.id, second.id); let queue = DownloadQueueState::new(Ok(directory.clone()), "test").unwrap(); fs::write( queue.path(&first.id, "archive").unwrap(), @@ -2904,8 +2896,17 @@ mod tests { } #[test] - fn startup_staging_covers_only_completed_downloads() { - let (database, _queue, completed, directory) = downloaded_fixture(); + fn startup_schedules_completed_downloads_and_preserves_queued_and_failed_jobs() { + let (database, queue, completed, directory) = downloaded_fixture(); + let (archive, hash) = install_archive_fixture(); + fs::write(queue.path(&completed.id, "archive").unwrap(), archive).unwrap(); + database.with_connection(|connection| { + connection.execute( + "UPDATE downloads SET sha256 = ?2, size_bytes = ?3, published_size_bytes = ?3, downloaded_bytes = ?3 WHERE id = ?1", + params![completed.id, hash, archive.len() as i64], + ).map_err(db_error)?; + Ok(()) + }).unwrap(); let queued = enqueue( &database, 400, @@ -2914,7 +2915,7 @@ mod tests { false, ) .unwrap(); - let staged = enqueue( + let failed = enqueue( &database, 400, "https://example.invalid/portal.zip", @@ -2922,10 +2923,20 @@ mod tests { false, ) .unwrap(); - set_status(&database, &staged.id, "staged"); + set_status(&database, &failed.id, "failed"); + let app = test_app(&directory); - assert_eq!(downloaded_ids(&database).unwrap(), vec![completed.id]); + schedule_installations(app.handle()); + wait_for_installation(&app, &database, &completed.id); + + let game = database.game(&completed.id).unwrap(); + assert_eq!( + fs::read(game.executable_path.unwrap()).unwrap(), + b"#!/bin/sh\nexit 0\n" + ); assert_eq!(status_of(&database, &queued.id).as_deref(), Some("queued")); + assert_eq!(status_of(&database, &failed.id).as_deref(), Some("failed")); + drop(app); drop(database); fs::remove_dir_all(directory).unwrap(); } @@ -3628,7 +3639,6 @@ mod tests { let (database, queue, job, directory) = downloaded_fixture(); set_status(&database, &job.id, "failed"); - assert!(REMOVABLE.contains(&DownloadStatus::Failed)); remove_entry(&queue, &database, &job.id).unwrap(); assert_eq!(status_of(&database, &job.id), None); @@ -3729,7 +3739,8 @@ mod tests { let error = remove_entry(&queue, &database, &job.id).unwrap_err(); assert!(error.contains("preserved for inspection"), "{error}"); - assert!(outside.join("keep.bin").exists()); + assert_eq!(fs::read(outside.join("keep.bin")).unwrap(), b"keep"); + assert_eq!(status_of(&database, &job.id).as_deref(), Some("cancelled")); drop(database); fs::remove_dir_all(directory).unwrap(); } diff --git a/src-tauri/src/game_artwork.rs b/src-tauri/src/game_artwork.rs index d2c5049b..3d28f175 100644 --- a/src-tauri/src/game_artwork.rs +++ b/src-tauri/src/game_artwork.rs @@ -488,10 +488,14 @@ mod tests { assert_eq!(store.shortcut_icon_path(&game).unwrap(), None); assert!(!root.join("app-data/game-shortcut-icons").exists()); store.set(&game.id, ArtworkKind::Icon, &source).unwrap(); + let expected = root + .join("app-data/game-icons") + .join(format!("{}.png", game.id)); assert_eq!( store.shortcut_icon_path(&game).unwrap(), - store.path(&game.id, ArtworkKind::Icon).unwrap() + Some(expected.clone()) ); + assert_eq!(fs::read(expected).unwrap(), PNG); fs::remove_dir_all(root).unwrap(); } @@ -601,6 +605,15 @@ mod tests { assert_eq!(replaced.content_type, "image/jpeg"); assert!(!app_data.join(format!("game-icons/{game_id}.png")).exists()); assert!(app_data.join(format!("game-icons/{game_id}.jpg")).is_file()); + let reopened = GameArtworkStore::new(Ok(app_data)); + assert_eq!( + reopened + .get(game_id, ArtworkKind::Icon) + .unwrap() + .unwrap() + .bytes, + fs::read(&jpeg).unwrap() + ); fs::remove_dir_all(root).unwrap(); } @@ -688,7 +701,7 @@ mod tests { } #[test] - fn removing_game_artwork_cleans_both_owned_files() { + fn removing_game_artwork_cleans_all_kinds_without_touching_other_games() { let root = test_dir(); let source = root.join("selected.png"); let app_data = root.join("app-data"); @@ -697,11 +710,32 @@ mod tests { let store = GameArtworkStore::new(Ok(app_data.clone())); store.set(game_id, ArtworkKind::Icon, &source).unwrap(); store.set(game_id, ArtworkKind::Banner, &source).unwrap(); + store + .set(game_id, ArtworkKind::ShortcutIcon, &source) + .unwrap(); + let other_game_id = "00000000-0000-0000-0000-000000000002"; + store + .set(other_game_id, ArtworkKind::Icon, &source) + .unwrap(); store.remove_for_game(game_id).unwrap(); assert!(store.get(game_id, ArtworkKind::Icon).unwrap().is_none()); assert!(store.get(game_id, ArtworkKind::Banner).unwrap().is_none()); + assert!( + store + .get(game_id, ArtworkKind::ShortcutIcon) + .unwrap() + .is_none() + ); + assert_eq!( + store + .get(other_game_id, ArtworkKind::Icon) + .unwrap() + .unwrap() + .bytes, + PNG + ); assert!(app_data.join("game-icons").is_dir()); assert!(app_data.join("game-banners").is_dir()); fs::remove_dir_all(root).unwrap(); @@ -777,6 +811,7 @@ mod tests { .contains("unsupported image format") ); store.remove(game_id, ArtworkKind::Icon).unwrap(); + assert!(store.get(game_id, ArtworkKind::Icon).unwrap().is_none()); fs::remove_dir_all(root).unwrap(); } } diff --git a/src-tauri/src/game_lifecycle.rs b/src-tauri/src/game_lifecycle.rs index e9620a98..b534d4f7 100644 --- a/src-tauri/src/game_lifecycle.rs +++ b/src-tauri/src/game_lifecycle.rs @@ -2722,6 +2722,16 @@ mod tests { .unwrap(); assert_eq!(report["appliedOptions"]["debugLogging"], false); assert_eq!(report["debugEnvironment"], serde_json::json!([])); + assert_eq!(report["runner"], "Controlled Wine"); + assert_eq!(report["runnerVersion"], "test"); + assert_eq!( + report["prefixPath"], + serde_json::json!( + dunce::canonicalize(base.join("prefixes")) + .unwrap() + .join(&game.id) + ) + ); let output_timeout = Instant::now() + Duration::from_secs(3); loop { let stdout = fs::read_to_string(log_directory.join("stdout.log")).unwrap_or_default(); @@ -2735,9 +2745,6 @@ mod tests { ); thread::sleep(Duration::from_millis(20)); } - let report = fs::read_to_string(log_directory.join("launch.json")).unwrap(); - assert!(report.contains("Controlled Wine")); - assert!(report.contains("prefixPath")); drop(app); drop(database); @@ -3156,32 +3163,6 @@ mod tests { fs::remove_dir_all(base).unwrap(); } - #[cfg(target_os = "linux")] - #[test] - fn runner_spawn_failure_returns_to_idle_with_error() { - let base = test_dir("runner-spawn-failure"); - let manager = GameLaunchManager::new(); - let token = uuid::Uuid::new_v4().to_string(); - let target = runner_test_target(&base, &token); - let cancel = manager - .reserve_launch("runner", None, target.clone(), None) - .unwrap(); - manager.run_launch( - "runner".to_owned(), - target, - cancel, - LaunchContext::default(), - |_| Err("Could not start compatibility runner: no such file".to_owned()), - ); - let state = &manager.list().unwrap()[0]; - assert_eq!(state.status, GameStatus::Idle); - assert_eq!( - state.error.as_deref(), - Some("Launch stage failed: Could not start compatibility runner: no such file") - ); - fs::remove_dir_all(base).unwrap(); - } - #[cfg(target_os = "linux")] #[test] fn runner_wrapper_failure_without_compatibility_logs_returns_to_idle() { @@ -3269,7 +3250,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn controlled_process_runs_through_prepare_launch_monitor_and_terminate() { + fn steam_game_process_is_monitored_and_stopped() { const APP_ID: u32 = 4_294_967_293; let base = test_dir("lifecycle-process"); let install_path = base.join("game"); @@ -3509,7 +3490,7 @@ mod tests { } #[test] - fn steam_process_events_keep_proton_launch_active_after_completion() { + fn steam_process_log_preserves_event_order_around_completion() { let path = std::env::temp_dir().join(format!( "legio-steam-process-log-{}-{:?}", std::process::id(), @@ -3540,20 +3521,21 @@ mod tests { } #[test] - fn completion_waits_for_all_steam_game_processes_to_end() { + fn steam_progress_tracks_completion_and_saturating_process_counts() { let mut progress = SteamLaunchProgress::default(); - for event in [ - SteamLaunchEvent::ProcessAdded, - SteamLaunchEvent::ProcessAdded, - SteamLaunchEvent::Completed, - SteamLaunchEvent::ProcessUpdated, - SteamLaunchEvent::ProcessRemoved, + for (event, count, completed) in [ + (SteamLaunchEvent::ProcessAdded, 1, false), + (SteamLaunchEvent::ProcessAdded, 2, false), + (SteamLaunchEvent::Completed, 2, true), + (SteamLaunchEvent::ProcessUpdated, 2, true), + (SteamLaunchEvent::ProcessRemoved, 1, true), + (SteamLaunchEvent::ProcessRemoved, 0, true), + (SteamLaunchEvent::ProcessRemoved, 0, true), + (SteamLaunchEvent::ProcessUpdated, 1, true), ] { assert_eq!(progress.observe(event), None); + assert_eq!(progress.active_processes, count); + assert_eq!(progress.completed, completed); } - assert!(progress.completed); - assert_eq!(progress.active_processes, 1); - assert_eq!(progress.observe(SteamLaunchEvent::ProcessRemoved), None); - assert_eq!(progress.active_processes, 0); } } diff --git a/src-tauri/src/game_process.rs b/src-tauri/src/game_process.rs index 63b3bbdc..71db9482 100644 --- a/src-tauri/src/game_process.rs +++ b/src-tauri/src/game_process.rs @@ -889,7 +889,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn runner_process_requires_launch_token_and_game_executable_name() { + fn runner_process_matches_game_name_and_rejects_other_named_helpers() { use std::process::Command; use std::thread; use std::time::{Duration, Instant}; diff --git a/src-tauri/src/steam_assets.rs b/src-tauri/src/steam_assets.rs index fb765837..f47b938c 100644 --- a/src-tauri/src/steam_assets.rs +++ b/src-tauri/src/steam_assets.rs @@ -1922,7 +1922,7 @@ mod tests { prefetch .scheduler .run_metadata(&prefetch.handle, || async { - Ok(serde_json::from_str("{}").unwrap()) + Ok(serde_json::from_str(r#"{"logo.png":"0123456789abcdef0123456789abcdef01234567/logo.png"}"#).unwrap()) }) .await .map_err(|error| error.to_string()) @@ -1959,8 +1959,10 @@ mod tests { .await }); let result = tokio::time::timeout(Duration::from_secs(1), async { - first.await.unwrap().unwrap(); - second.await.unwrap().unwrap(); + for (assets, warning) in [first.await.unwrap().unwrap(), second.await.unwrap().unwrap()] { + assert_eq!(warning, None); + assert_eq!(assets.unwrap().url(400, "logo.png").as_deref(), Some("https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/400/0123456789abcdef0123456789abcdef01234567/logo.png")); + } }) .await; assert!( @@ -2190,9 +2192,18 @@ mod tests { requests.next().unwrap() ); server.join().unwrap(); - let first = first.unwrap(); - assert_eq!(second.unwrap().bytes, first.bytes); - assert_eq!(third.unwrap().bytes, first.bytes); + let results = [first.unwrap(), second.unwrap(), third.unwrap()]; + let decoded = image::load_from_memory(&results[0].bytes).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (2, 2)); + for result in &results { + assert_eq!(result.bytes, results[0].bytes); + assert!( + result + .cache_warning + .as_deref() + .is_some_and(|warning| warning.contains("could not be saved locally")) + ); + } fs::remove_dir_all(cache.directory().unwrap().parent().unwrap()).unwrap(); } @@ -2881,7 +2892,7 @@ mod tests { } #[test] - fn screenshot_download_keeps_validated_identity_after_metadata_reordering() { + fn explicit_screenshot_url_survives_metadata_reordering() { let mut details: SteamDetails = serde_json::from_value(serde_json::json!({ "steamAppId":400,"name":"Portal","appType":"game","developers":[],"publishers":[],"genres":[], "assets":{"header":null,"capsule":null,"background":null,"screenshots":[ @@ -2898,7 +2909,7 @@ mod tests { Some(&validated), ) .unwrap(); - assert_eq!(selected, validated); + assert_eq!(selected, "https://cdn.steamstatic.com/400/a.jpg"); } #[test] @@ -2993,7 +3004,7 @@ mod tests { } #[test] - fn cache_is_limited_to_128_files() { + fn cache_limits_transient_asset_files() { let cache = cache(); for index in 0..=CACHE_FILES { cache @@ -3132,7 +3143,12 @@ mod tests { .unwrap(); server.join().unwrap(); assert_eq!(result.content_type, "image/webp"); - assert!(cache.read("400-header", &url).unwrap().is_some()); + let decoded = image::load_from_memory(&result.bytes).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (2, 2)); + assert_eq!( + cache.read("400-header", &url).unwrap().unwrap().bytes, + result.bytes + ); fs::remove_dir_all(cache.directory().unwrap()).unwrap(); } } @@ -3190,6 +3206,8 @@ mod tests { .unwrap(); server.join().unwrap(); assert_eq!(result.content_type, "image/webp"); + let decoded = image::load_from_memory(&result.bytes).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (2, 2)); assert!( result .cache_warning diff --git a/src-tauri/src/steam_details.rs b/src-tauri/src/steam_details.rs index 8db10915..09fddb1a 100644 --- a/src-tauri/src/steam_details.rs +++ b/src-tauri/src/steam_details.rs @@ -1019,7 +1019,12 @@ mod tests { Some("A puzzle game") ); assert_eq!(details.release_date.unwrap().date, "10 Oct, 2007"); - assert!(details.assets.header.is_some()); + assert_eq!( + details.assets.header.as_deref(), + Some( + "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/400/header.jpg" + ) + ); assert_eq!(details.assets.capsule, None); assert_eq!(details.assets.background, None); assert_eq!( @@ -1138,15 +1143,9 @@ mod tests { .await .unwrap(); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); - assert!(result.details.unwrap().assets.header.is_some()); - assert!( - cached_details(&database, 400) - .unwrap() - .unwrap() - .assets - .header - .is_some() - ); + let recovered = result.details.unwrap(); + assert_eq!(recovered, decode(&fixture(400), 400).unwrap()); + assert_eq!(cached_details(&database, 400).unwrap(), Some(recovered)); drop(database); std::fs::remove_dir_all(path).unwrap(); } diff --git a/src-tauri/src/steam_import.rs b/src-tauri/src/steam_import.rs index 1bb13c35..424fd8dc 100644 --- a/src-tauri/src/steam_import.rs +++ b/src-tauri/src/steam_import.rs @@ -291,22 +291,22 @@ mod tests { .with_connection(|connection| { connection .execute( - "UPDATE games SET executable_path = ?2 WHERE id = ?1", - params![manual.id, "/games/Portal.exe"], + "UPDATE games SET executable_path = ?2, steam_install_path = ?3 WHERE id = ?1", + params![manual.id, "/games/Portal.exe", fs::canonicalize(fixture.0.join("steam/steamapps/common/Portal")).unwrap().to_string_lossy()], ) .map_err(database_error)?; Ok(()) }) .unwrap(); + let original = database.game(&manual.id).unwrap(); let result = import_scan(&database, fixture.scan()).unwrap(); assert_eq!((result.inserted, result.updated), (1, 0)); - assert!( - database - .game(&manual.id) - .unwrap() - .steam_install_path - .is_none() - ); + assert_eq!(database.game(&manual.id).unwrap(), original); + let games = database.games().unwrap(); + assert_eq!(games.len(), 2); + let installed = games.iter().find(|game| game.id != manual.id).unwrap(); + assert_eq!(installed.steam_install_path, original.steam_install_path); + assert_eq!(installed.executable_path, None); } #[test] diff --git a/src-tauri/src/steam_local.rs b/src-tauri/src/steam_local.rs index 02cdfe17..5f638749 100644 --- a/src-tauri/src/steam_local.rs +++ b/src-tauri/src/steam_local.rs @@ -1098,12 +1098,13 @@ mod tests { "76561198000000001" { "AccountName" "secret-login" "PersonaName" "Same name" "RememberPassword" "1" } "76561198000000002" { "PersonaName" "Same name" "Token" { "value" "secret-token" } } }"#).unwrap(); - assert_eq!(accounts.len(), 2); - assert_eq!(accounts[0].display_name, "Same name"); - assert_eq!(accounts[1].display_name, "Same name"); - let output = serde_json::to_string(&accounts).unwrap(); - assert!(!output.contains("secret-login")); - assert!(!output.contains("secret-token")); + assert_eq!( + serde_json::to_value(&accounts).unwrap(), + serde_json::json!([ + {"steamId": "76561198000000001", "displayName": "Same name"}, + {"steamId": "76561198000000002", "displayName": "Same name"} + ]) + ); } #[test] @@ -1308,42 +1309,71 @@ mod tests { #[test] fn parses_modern_and_legacy_libraries_without_confusing_nested_paths() { + let root = if cfg!(windows) { "C:" } else { "" }; + let text = format!( + r#"// library metadata + "LibraryFolders" {{ + "TimeNextStatsReport" "1234" + "0" {{ "path" "{root}/games/日本語" "apps" {{ "570" "100" }} + "extra" {{ "path" "{root}/ignored" }} }} + "1" "{root}/other games" + }}"# + ); assert_eq!( - parse_library_folders( - r#"// library metadata - "LibraryFolders" { - "TimeNextStatsReport" "1234" - "0" { "path" "/games/日本語" "apps" { "570" "100" } - "extra" { "path" "/ignored" } } - "1" "/other games" - }"# - .as_bytes() - ), + parse_library_folders(text.as_bytes()), Ok(vec![ - PathBuf::from("/games/日本語"), - PathBuf::from("/other games") + PathBuf::from(format!("{root}/games/日本語")), + PathBuf::from(format!("{root}/other games")) ]) ); } #[test] fn rejects_unsafe_or_ambiguous_library_paths() { - for entry in [ - r#""0" "relative""#, - r#""0" "/games/../outside""#, - r#""0" "/games\nprivate""#, - r#""0" { "apps" { "path" "/nested" } }"#, - r#""0" { "path" "/one" "PATH" "/two" }"#, - r#""0" "/one" "00" "/two""#, - r#""0" { "path" {} }"#, + let root = if cfg!(windows) { "C:" } else { "" }; + for (entry, expected) in [ + ( + r#""0" "relative""#, + ManifestDiagnostic::InvalidField("library path"), + ), + ( + r#""0" "/games/../outside""#, + ManifestDiagnostic::InvalidField("library path"), + ), + ( + r#""0" "/games\nprivate""#, + ManifestDiagnostic::InvalidField("library path"), + ), + ( + r#""0" { "apps" { "path" "/nested" } }"#, + ManifestDiagnostic::MissingField("library path"), + ), + ( + r#""0" { "path" "/one" "PATH" "/two" }"#, + ManifestDiagnostic::DuplicateField("library path"), + ), + ( + r#""0" "/one" "00" "/two""#, + ManifestDiagnostic::DuplicateField("library index"), + ), + ( + r#""0" { "path" {} }"#, + ManifestDiagnostic::InvalidField("library path"), + ), ] { + let entry = entry.replace("\"/", &format!("\"{root}/")); let text = format!(r#""libraryfolders" {{ {entry} }}"#); - assert!(parse_library_folders(text.as_bytes()).is_err()); + assert_eq!( + parse_library_folders(text.as_bytes()), + Err(expected), + "{entry}" + ); } } #[test] fn library_metadata_is_bounded_and_requires_a_complete_document() { + let root = if cfg!(windows) { "C:" } else { "" }; assert_eq!( parse_library_folders(&vec![b' '; MAX_MANIFEST_BYTES + 1]), Err(ManifestDiagnostic::InputTooLarge) @@ -1356,13 +1386,14 @@ mod tests { r#""libraryfolders" { "0" "/games""#, r#""libraryfolders" { } "extra" "data""#, ] { + let text = text.replace("\"/", &format!("\"{root}/")); assert!(matches!( parse_library_folders(text.as_bytes()), Err(ManifestDiagnostic::Malformed { .. }) )); } let text = format!( - r#""libraryfolders" {{ "0" {{ "path" "/games" {}{} }} }}"#, + r#""libraryfolders" {{ "0" {{ "path" "{root}/games" {}{} }} }}"#, "\"nested\" {".repeat(MAX_OBJECT_DEPTH), "}".repeat(MAX_OBJECT_DEPTH) ); diff --git a/src-tauri/src/steam_pics.rs b/src-tauri/src/steam_pics.rs index aaa4c862..b700c99b 100644 --- a/src-tauri/src/steam_pics.rs +++ b/src-tauri/src/steam_pics.rs @@ -400,10 +400,9 @@ mod tests { }) .await; let (saved, stale) = read_cache(&path).await.unwrap().unwrap(); - assert_eq!( - saved.url(4656000, "logo.png"), - assets.unwrap().url(4656000, "logo.png") - ); + let expected = Some(format!("{CDN}/4656000/{HASH}/logo.png")); + assert_eq!(saved.url(4656000, "logo.png"), expected); + assert_eq!(assets.unwrap().url(4656000, "logo.png"), expected); assert!(!stale); assert!(warning.is_none()); tokio::fs::remove_dir_all(path.parent().unwrap()) @@ -531,8 +530,9 @@ mod tests { Ok(logo_assets(HASH)) }) .await; - assert!( - assets.is_some(), + assert_eq!( + assets.unwrap().url(4656000, "logo.png"), + Some(format!("{CDN}/4656000/{HASH}/logo.png")), "Cancellation must not be reused as a cached metadata failure" ); assert!(warning.is_none()); @@ -557,7 +557,7 @@ mod tests { "library_header_2x.jpg", "header.jpg", "capsule_231x87.jpg", - "clienticon.jpg", + "clienticon.ico", ] { assert!( assets.url(4656000, filename).is_some(), diff --git a/src-tauri/src/steam_switch.rs b/src-tauri/src/steam_switch.rs index 09a16188..f190c4ea 100644 --- a/src-tauri/src/steam_switch.rs +++ b/src-tauri/src/steam_switch.rs @@ -532,6 +532,7 @@ mod tests { updated: b"selected account".to_vec(), }; write_atomic(&path, &patch.updated).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"selected account"); restore_files(&[patch]).unwrap(); assert_eq!(fs::read(&path).unwrap(), original); diff --git a/src-tauri/src/steam_vdf.rs b/src-tauri/src/steam_vdf.rs index 2d60bc89..a904d54f 100644 --- a/src-tauri/src/steam_vdf.rs +++ b/src-tauri/src/steam_vdf.rs @@ -664,11 +664,10 @@ mod tests { SAMPLE.replace("\"RememberPassword\" \"1\"", "\"RememberPassword\" \"0\""); let patched = patch_account_selection(disabled_password.as_bytes(), "76561198000000001").unwrap(); - assert!( - std::str::from_utf8(&patched) - .unwrap() - .contains("\"RememberPassword\" \"1\"") - ); + let users = parse_loginusers(&patched).unwrap(); + assert_eq!(users[0].steam_id, "76561198000000001"); + assert_eq!(users[0].remember_password.as_deref(), Some("1")); + assert_eq!(users[1].remember_password.as_deref(), Some("0")); } #[test] From b07de9ac6959b9d47611d7a74f5f5df79c45724f Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 23:51:33 +0200 Subject: [PATCH 09/12] test: run Steam library and import regressions on Windows --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52e226f4..4d04efa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,7 +340,9 @@ jobs: shell: bash env: LEGIO_WINDOWS_TESTS: "1" - run: cargo test --manifest-path src-tauri/Cargo.toml --locked --lib steam_local::tests::windows_default_roots + run: | + cargo test --manifest-path src-tauri/Cargo.toml --locked --lib steam_local::tests:: + cargo test --manifest-path src-tauri/Cargo.toml --locked --lib steam_import::tests:: - name: Test Windows game process control if: runner.os == 'Windows' From 32372813c6f410305f91b0c468389036403d650c Mon Sep 17 00:00:00 2001 From: fraa2a Date: Wed, 21 Oct 2026 23:53:15 +0200 Subject: [PATCH 10/12] test: escape Windows Steam library fixture paths --- src-tauri/src/steam_local.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/steam_local.rs b/src-tauri/src/steam_local.rs index 5f638749..74d831f7 100644 --- a/src-tauri/src/steam_local.rs +++ b/src-tauri/src/steam_local.rs @@ -1413,9 +1413,9 @@ mod tests { root.join("steamapps/libraryfolders.vdf"), format!( r#""libraryfolders" {{ "0" {{ "path" "{}" }} "1" {{ "path" "{}" }} "2" "{}" }}"#, - root.display(), - external.display(), - duplicate.display() + root.to_string_lossy().replace('\\', "\\\\"), + external.to_string_lossy().replace('\\', "\\\\"), + duplicate.to_string_lossy().replace('\\', "\\\\") ), ) .unwrap(); @@ -1460,7 +1460,11 @@ mod tests { root.join("steamapps/libraryfolders.vdf"), format!( r#""libraryfolders" {{ "1" "{}" }}"#, - fixture.0.join("missing-private-disk").display() + fixture + .0 + .join("missing-private-disk") + .to_string_lossy() + .replace('\\', "\\\\") ), ) .unwrap(); @@ -1477,12 +1481,12 @@ mod tests { .collect::>(), vec![1] ); - assert_eq!(scan.diagnostics.len(), 2); - assert_ne!(scan.diagnostics[0], scan.diagnostics[1]); - assert!( - scan.diagnostics - .iter() - .all(|message| !message.contains("missing-private-disk")) + assert_eq!( + scan.diagnostics, + vec![ + "could not access a configured Steam library: NotFound", + "could not read a Steam app manifest: InvalidData", + ] ); } From cd01b961155a4214c72266066c0b75962fd9e2b7 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Thu, 22 Oct 2026 00:04:11 +0200 Subject: [PATCH 11/12] test: make appinfo cache invalidation fixture deterministic --- src-tauri/src/steam_local.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/src/steam_local.rs b/src-tauri/src/steam_local.rs index 74d831f7..04e5bd28 100644 --- a/src-tauri/src/steam_local.rs +++ b/src-tauri/src/steam_local.rs @@ -1280,8 +1280,17 @@ mod tests { let first = cached_appinfo_types(&path).unwrap().unwrap(); let second = cached_appinfo_types(&path).unwrap().unwrap(); assert!(std::sync::Arc::ptr_eq(&first, &second)); + let modified = fs::metadata(&path).unwrap().modified().unwrap(); fs::remove_file(&path).unwrap(); fs::write(&path, appinfo_fixture(&[(1, "Tool")])).unwrap(); + fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_times( + fs::FileTimes::new().set_modified(modified + std::time::Duration::from_secs(2)), + ) + .unwrap(); assert_eq!( cached_appinfo_types(&path).unwrap().unwrap().get(&1), Some(&false) From a29be9f250d21f2a1907c5efe2d817fb15afdec2 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Thu, 22 Oct 2026 00:05:13 +0200 Subject: [PATCH 12/12] test: stabilize imported app type change fixtures on Windows --- src-tauri/src/steam_import.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/steam_import.rs b/src-tauri/src/steam_import.rs index 424fd8dc..5bdd26fb 100644 --- a/src-tauri/src/steam_import.rs +++ b/src-tauri/src/steam_import.rs @@ -377,11 +377,21 @@ mod tests { database .set_game_steam_account(&selected.id, Some("76561198000000001")) .unwrap(); + let metadata_path = fixture.0.join("steam/appcache/appinfo.vdf"); + let modified = fs::metadata(&metadata_path).unwrap().modified().unwrap(); fs::write( - fixture.0.join("steam/appcache/appinfo.vdf"), + &metadata_path, steam_local::appinfo_fixture(&[(1, "Game"), (2, "Tool"), (3, "Tool"), (4, "Tool")]), ) .unwrap(); + fs::OpenOptions::new() + .write(true) + .open(&metadata_path) + .unwrap() + .set_times( + fs::FileTimes::new().set_modified(modified + std::time::Duration::from_secs(2)), + ) + .unwrap(); let result = import_scan(&database, fixture.scan()).unwrap(); assert_eq!((result.detected, result.removed), (1, 1)); let games = database.games().unwrap();