Add Tauri desktop testing to CI

This commit is contained in:
Anthony Stirling
2026-08-08 21:47:39 +01:00
parent 37a48aa7a7
commit 9e5477d847
24 changed files with 7352 additions and 35 deletions
+3
View File
@@ -94,11 +94,14 @@ tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
- frontend/editor/src/desktop/**
- frontend/desktop-e2e/**
- frontend/editor/tsconfig.desktop.vite.json
- frontend/package.json
- frontend/package-lock.json
- frontend/editor/vite.config.ts
- .github/workflows/tauri-build.yml
- .github/workflows/desktop-rust.yml
- .github/workflows/desktop-e2e.yml
- Taskfile.yml
- .taskfiles/desktop.yml
+28
View File
@@ -201,6 +201,30 @@ jobs:
docker-base-changed: ${{ needs.files-changed.outputs.docker-base }}
dockerfiles-changed: ${{ needs.files-changed.outputs.dockerfiles }}
# Fast Rust gate: clippy + cargo test on all three desktop OSes in a couple
# of minutes, because it skips the jlink/bootJar prep that tauri-build needs.
desktop-rust:
if: needs.files-changed.outputs.tauri == 'true'
needs: [files-changed]
permissions:
contents: read
uses: ./.github/workflows/desktop-rust.yml
secrets: inherit
# Runs the real app: window opens, frontend mounts, bundled JRE boots the
# bundled JAR. Linux only on PRs (cheapest runner, and the platform where the
# webview and jlink regressions have actually bitten); nightly covers Windows
# too via its own dispatch.
desktop-e2e:
if: needs.files-changed.outputs.tauri == 'true'
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/desktop-e2e.yml
secrets: inherit
with:
platform: linux
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
needs: [files-changed]
@@ -296,6 +320,8 @@ jobs:
- check-licence
- docker-compose-tests
- test-build-docker-images
- desktop-rust
- desktop-e2e
- tauri-build
- ai-engine
- generated-models
@@ -322,6 +348,8 @@ jobs:
check-licence=${{ needs.check-licence.result }}
docker-compose-tests=${{ needs.docker-compose-tests.result }}
test-build-docker-images=${{ needs.test-build-docker-images.result }}
desktop-rust=${{ needs.desktop-rust.result }}
desktop-e2e=${{ needs.desktop-e2e.result }}
tauri-build=${{ needs.tauri-build.result }}
ai-engine=${{ needs.ai-engine.result }}
generated-models=${{ needs.generated-models.result }}
+230
View File
@@ -0,0 +1,230 @@
name: Desktop E2E
# Launches the real desktop app and exercises it. tauri-build.yml proves the
# bundle *compiles and packages*; nothing there ever runs it. This workflow
# covers what only shows up at runtime.
#
# Two suites, because tauri-driver has no macOS backend (Apple ships no
# WebDriver for WKWebView):
#
# smoke - launches the binary, reads the backend port off its stdout, and
# runs real tools over HTTP. No webview driver, so it runs on all
# three platforms. This is where per-platform runtime bugs live:
# a jlink runtime missing a module, a JRE older than the JAR, or
# JPDFium natives not published for this OS/arch.
# ui - drives the real webview through tauri-driver: window opens, the
# frontend mounts, and a tool runs end to end from the UI.
# Linux + Windows only.
on:
workflow_call:
inputs:
platform:
description: "Platform to test (linux, windows, macos, or all)."
required: false
type: string
default: "linux"
workflow_dispatch:
inputs:
platform:
description: "Platform to test (linux, windows, macos, or all)"
required: true
default: "all"
type: choice
options:
- all
- linux
- windows
- macos
permissions:
contents: read
jobs:
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Determine test matrix
id: set-matrix
env:
PLATFORM: ${{ inputs.platform }}
run: |
# `webdriver` gates the UI suite: macOS runs the headless smoke only,
# because tauri-driver cannot drive WKWebView. macos-15 runners are
# Apple silicon, hence darwin-arm64 natives.
LINUX='{"os":"ubuntu-22.04","name":"linux","jpdfium_platforms":"linux-x64","webdriver":true}'
WINDOWS='{"os":"windows-latest","name":"windows","jpdfium_platforms":"windows-x64","webdriver":true}'
MACOS='{"os":"macos-15","name":"macos","jpdfium_platforms":"darwin-arm64","webdriver":false}'
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
macos) ENTRIES=("$MACOS") ;;
all) ENTRIES=("$LINUX" "$WINDOWS" "$MACOS") ;;
*) ENTRIES=("$LINUX") ;;
esac
JOINED=$(IFS=','; echo "${ENTRIES[*]}")
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
e2e:
name: desktop-e2e (${{ matrix.name }})
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 75
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# webkit2gtk-driver supplies WebKitWebDriver, which tauri-driver proxies
# to; xvfb gives the app a display to open its window on.
- name: Install Linux dependencies
if: matrix.name == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev webkit2gtk-driver xvfb
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
toolchain: stable
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
# Shares tauri-build's cache: both build the app crate with real
# resources, so the dependency crates are byte-identical. macOS has no
# shared entry - tauri-build's macOS leg targets universal-apple-darwin
# while this one builds host-native - so it gets its own key.
shared-key: ${{ matrix.name == 'linux' && 'tauri-linux-x86_64' || matrix.name == 'windows' && 'tauri-windows-x86_64' || 'desktop-e2e-macos' }}
save-if: ${{ matrix.name == 'macos' && github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
# --no-bundle: a launchable binary with the JRE and JAR staged beside it,
# without paying for deb/rpm/AppImage/MSI packaging that tauri-build
# already covers.
- name: Build desktop app (no bundle)
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
JPDFIUM_PLATFORMS: ${{ matrix.jpdfium_platforms }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }} # gitleaks:allow
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
run: task desktop:build:dev
# Runs everywhere, including macOS. Needs no npm install - the runner is
# plain Node - so this is the cheapest real coverage in the workflow.
- name: Run desktop smoke (Linux)
if: matrix.name == 'linux'
env:
WEBKIT_DISABLE_COMPOSITING_MODE: "1"
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" task desktop:smoke
- name: Run desktop smoke (Windows / macOS)
if: matrix.name != 'linux'
run: task desktop:smoke
- name: Install E2E harness
if: matrix.webdriver
env:
CI: true
run: task desktop:e2e:install
# msedgedriver must match the installed WebView2/Edge major version. The
# runner image ships a driver that usually already matches; fall back to
# the exact build from Microsoft's CDN when it has drifted.
- name: Resolve Edge WebDriver (Windows)
if: matrix.name == 'windows'
shell: pwsh
run: |
$edgeExe = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
if (-not (Test-Path $edgeExe)) { Write-Host "[ERROR] Edge not found"; exit 1 }
$edgeVersion = (Get-Item $edgeExe).VersionInfo.ProductVersion
Write-Host "Edge version: $edgeVersion"
$preinstalled = "C:\SeleniumWebDrivers\EdgeDriver\msedgedriver.exe"
if (Test-Path $preinstalled) {
$driverVersion = (Get-Item $preinstalled).VersionInfo.ProductVersion
Write-Host "Preinstalled driver version: $driverVersion"
if ($driverVersion.Split('.')[0] -eq $edgeVersion.Split('.')[0]) {
Write-Host "Using preinstalled msedgedriver"
echo "TAURI_DRIVER_NATIVE=$preinstalled" >> $env:GITHUB_ENV
exit 0
}
Write-Host "Major version mismatch - downloading a matching driver"
}
$dest = Join-Path $env:RUNNER_TEMP "edgedriver"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$zip = Join-Path $env:RUNNER_TEMP "edgedriver.zip"
Invoke-WebRequest -Uri "https://msedgedriver.microsoft.com/$edgeVersion/edgedriver_win64.zip" -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $dest -Force
echo "TAURI_DRIVER_NATIVE=$dest\msedgedriver.exe" >> $env:GITHUB_ENV
- name: Run desktop UI E2E (Linux)
if: matrix.name == 'linux'
env:
# WebKitGTK's compositing path is unreliable under Xvfb's software
# GL; the app already disables the DMA-BUF renderer itself.
WEBKIT_DISABLE_COMPOSITING_MODE: "1"
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" task desktop:e2e
- name: Run desktop UI E2E (Windows)
if: matrix.name == 'windows'
run: task desktop:e2e
- name: Upload E2E logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-e2e-${{ matrix.name }}-${{ github.run_id }}
path: frontend/desktop-e2e/logs/
retention-days: 7
if-no-files-found: ignore
+82
View File
@@ -0,0 +1,82 @@
name: Desktop Rust checks
# Fast Rust gate for the Tauri desktop shell: clippy + cargo test, no bundling.
#
# tauri-build.yml also runs the Rust tests, but only after `task desktop:prepare`
# has built the backend JAR and jlink runtime - 20+ minutes before a broken Rust
# change surfaces. Emptying `bundle.resources` via TAURI_CONFIG lets the crate
# compile on its own, so this job reports in a couple of minutes instead.
#
# Runs on all three desktop OSes deliberately: large parts of src-tauri sit
# behind #[cfg(target_os = ...)] (native printing, default-handler registration,
# keyring backends) and are simply not compiled - let alone linted - anywhere
# else.
on:
workflow_call:
permissions:
contents: read
jobs:
rust-checks:
name: ${{ matrix.name }}
# Matches tauri-build.yml: the lite profile skips desktop work entirely.
if: ${{ vars.CI_PROFILE != 'lite' }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
name: linux
- os: windows-latest
name: windows
- os: macos-15
name: macos
runs-on: ${{ matrix.os }}
timeout-minutes: 30
env:
# tauri-build hard-fails when a `resources` glob matches nothing, and
# those globs point at the JAR and jlink runtime this job deliberately
# skips building. Keep in sync with RUST_ONLY_CONFIG in .taskfiles/desktop.yml.
TAURI_CONFIG: '{"bundle":{"resources":[]}}'
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# webkit2gtk + friends are link-time dependencies of the tauri crate, so
# even `cargo clippy` needs them present.
- name: Install Linux build dependencies
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Rust
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
toolchain: stable
components: clippy
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
# Separate from tauri-build's cache: that one is built without the
# TAURI_CONFIG override, and mixing the two just thrashes both.
shared-key: desktop-rust-${{ matrix.name }}
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Clippy
run: task desktop:lint
- name: Cargo test
run: task desktop:test:rust
+13
View File
@@ -108,3 +108,16 @@ jobs:
platform: all
sign: false
secrets: inherit
# PRs only run the Linux leg to keep desktop-touching PRs affordable. The
# platform-specific runtime bugs - Windows WebView2, the macOS jlink runtime
# and darwin JPDFium natives - get their coverage here instead. macOS runs the
# headless smoke only; there is no WebDriver for WKWebView.
desktop-e2e:
name: Desktop E2E (linux + windows + macos)
permissions:
contents: read
uses: ./.github/workflows/desktop-e2e.yml
with:
platform: all
secrets: inherit
+94
View File
@@ -10,6 +10,17 @@ vars:
# fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify.
REQUIRED_JAVA: "25"
# tauri.conf.json's `resources` globs point at the bundled JAR and the jlink
# runtime, and tauri-build hard-fails when a glob matches nothing ("glob
# pattern libs/*.jar path not found"). Emptying `resources` via TAURI_CONFIG
# lets the Rust crate compile on its own, turning the ~20min jlink + bootJar
# prepare into a ~2min check. Only bundling actually needs those files.
RUST_ONLY_CONFIG: '{"bundle":{"resources":[]}}'
# Pinned so CI and local runs drive the app through the same WebDriver
# bridge. Bump deliberately alongside the tauri crate.
TAURI_DRIVER_VERSION: "2.0.6"
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
JPDFIUM_PLATFORMS:
sh: |
@@ -93,6 +104,89 @@ tasks:
cmds:
- cargo test
# ============================================================
# Rust checks — no JRE/JAR needed, so they run in ~2min
# ============================================================
test:rust:
desc: "Run Tauri/Cargo tests without building the JRE/JAR (fast)"
dir: editor/src-tauri
env:
TAURI_CONFIG: '{{.RUST_ONLY_CONFIG}}'
cmds:
- cargo test --locked {{.CLI_ARGS}}
lint:
desc: "Clippy the Tauri Rust sources"
dir: editor/src-tauri
env:
TAURI_CONFIG: '{{.RUST_ONLY_CONFIG}}'
cmds:
- cargo clippy --locked --all-targets -- -D warnings
check:
desc: "Fast desktop Rust gate (clippy + tests, no bundle)"
cmds:
- task: lint
- task: test:rust
# ============================================================
# E2E — drives the real built app through tauri-driver
# ============================================================
e2e:install:
desc: "Install the desktop E2E harness (WebdriverIO + tauri-driver)"
deps: [e2e:install:npm, e2e:install:driver]
e2e:install:npm:
internal: true
dir: desktop-e2e
cmds:
- '{{ if eq .CI "true" }}npm ci{{ else }}npm install{{ end }}'
env:
CI: '{{ .CI | default "false" }}'
status:
- test -d node_modules
e2e:install:driver:
internal: true
cmds:
- cargo install tauri-driver --version {{.TAURI_DRIVER_VERSION}} --locked
# Version-aware so a bumped TAURI_DRIVER_VERSION actually reinstalls,
# while a matching install skips the recompile.
status:
- cargo install --list | grep -q "tauri-driver v{{.TAURI_DRIVER_VERSION}}"
smoke:
desc: "Headless desktop smoke against the built app (all platforms)"
summary: |
Launches the built binary, waits for the bundled backend, and runs real
tools through it. Needs no webview driver, so unlike desktop:e2e this
also covers macOS.
task desktop:build:dev
task desktop:smoke
dir: desktop-e2e
cmds:
- node smoke/run-smoke.mjs
e2e:
desc: "Run desktop UI E2E against the built app (Linux/Windows only)"
summary: |
Requires a non-bundled release build to exist:
task desktop:build:dev
task desktop:e2e
macOS is unsupported: there is no WebDriver for WKWebView, so
tauri-driver has no macOS backend.
Pass extra WebdriverIO flags via -- :
task desktop:e2e -- --spec specs/app-boot.e2e.js
deps: [e2e:install]
dir: desktop-e2e
cmds:
- npx wdio run wdio.conf.mjs {{.CLI_ARGS}}
clean:
desc: "Clean Tauri/Cargo build artifacts"
dir: editor
+2
View File
@@ -0,0 +1,2 @@
node_modules/
logs/
+95
View File
@@ -0,0 +1,95 @@
# Desktop E2E
Tests that run the **real built desktop app** - the Tauri shell, its webview,
and the bundled JRE running the Stirling JAR.
Two suites, because tauri-driver has no macOS backend:
| Suite | Command | Runs on | What it drives |
| --------- | -------------------- | -------------------- | ---------------------------------------------------------- |
| **smoke** | `task desktop:smoke` | Linux, Windows, macOS | Launches the binary, reads the backend port off its stdout, runs real tools over HTTP |
| **ui** | `task desktop:e2e` | Linux, Windows | Drives the real webview through `tauri-driver` |
## What each one is for
**smoke** is where per-platform *runtime* bugs surface, and it is the only
desktop coverage macOS can have. Everything it calls goes through the bundled
jlink JRE running the bundled JAR, so it catches a runtime missing a module (the
`jdk.dynalink` regression), a JRE older than the JAR's class-file version, and
JPDFium natives that were never published for this OS/arch. It needs no npm
install - the runner is plain Node.
- `rotate-pdf` - a pure-PDFBox tool: proves an ordinary server tool works end to end.
- `merge-pdfs` - goes through `stirling.software.jpdfium.PdfMerge`, which
rethrows any native failure rather than falling back to PDFBox, so a 200 here
means the platform's JPDFium native loaded and ran.
**ui** covers what only breaks in the packaged webview: the window opens, the
frontend actually mounts (rather than the blank window of #6878), the navigation
guard holds, Tauri IPC answers, and a tool runs end to end from the UI -
workbench → IndexedDB → the bundled backend → review panel.
## How this fits the rest of the suite
| Layer | Where | What it proves |
| --------------------- | --------------------------------- | -------------------------------------- |
| Rust unit/integration | `editor/src-tauri/{src,tests}` | Rust command logic |
| Desktop TS units | `editor/src/desktop/**/*.test.ts` | The frontend's desktop seams |
| **Desktop E2E** | `frontend/desktop-e2e` | The bundle actually launches and works |
| Browser E2E | `editor/src/core/tests` | Product UI in a browser (Playwright) |
Keep specs here focused on things that can only break in the packaged app.
Product behaviour belongs in the Playwright suites, which run far faster.
## Why WebdriverIO and not Playwright
Playwright cannot attach to WebKitGTK or WebView2 as embedded in a Tauri app -
it speaks CDP and its own protocols, not WebDriver classic.
[`tauri-driver`](https://v2.tauri.app/develop/tests/webdriver/) is a WebDriver
server that launches the app binary and proxies to the platform's native webview
driver, so the UI suite has to use a WebDriver client.
| Platform | UI suite | Native driver |
| -------- | -------- | ------------------------------------------ |
| Linux | yes | `WebKitWebDriver` (`webkit2gtk-driver`) |
| Windows | yes | `msedgedriver` matching the WebView2 build |
| macOS | **no** | Apple ships no WebDriver for WKWebView |
## Running locally
```bash
task desktop:build:dev
task desktop:smoke
task desktop:e2e
```
`desktop:build:dev` produces a non-bundled release binary under
`editor/src-tauri/target/release/` with the jlink runtime and JAR staged
alongside it - enough to launch, without paying for MSI/dmg packaging.
On Windows, point the UI suite at a matching Edge driver if it is not on `PATH`:
```bash
TAURI_DRIVER_NATIVE="C:/SeleniumWebDrivers/EdgeDriver/msedgedriver.exe" task desktop:e2e
```
Other environment variables:
- `STIRLING_APP_BINARY` - test a specific binary instead of the discovered one.
- `SMOKE_TIMEOUT_MS` - how long the smoke runner waits for the backend port.
## Adding specs
UI specs share a single WebDriver session, and therefore a single app process,
because the app registers `tauri-plugin-single-instance` - a second launch hands
off to the first and exits. Add new files to the spec group in `wdio.conf.mjs`
rather than relying on a glob; the group order matters, since
`backend-sidecar.e2e.js` is what blocks until the backend is reachable.
## Why this lives here and not under `editor/`
Tauri resolves its "app directory" by recursively searching under `src-tauri`'s
parent for a `package.json`. A `package.json` anywhere below `frontend/editor`
therefore wins that search, and `tauri build` runs `beforeBuildCommand` from it -
so the vite build dies with `Could not resolve entry module "index.html"`.
Keeping this package a sibling of `editor/` avoids that entirely.
+69
View File
@@ -0,0 +1,69 @@
// Locating the built desktop app + the PDFs the suites exercise it with.
// Shared by the WebDriver suite (wdio.conf.mjs) and the headless smoke runner.
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const e2eDir = join(dirname(fileURLToPath(import.meta.url)), "..");
// Sibling of editor/, deliberately NOT inside it: Tauri resolves its app dir by
// recursively searching under src-tauri's parent for a package.json, so a
// package.json anywhere below frontend/editor makes `tauri build` run
// beforeBuildCommand in the wrong directory and the vite build fails with
// "Could not resolve entry module index.html".
export const tauriDir = join(e2eDir, "..", "editor", "src-tauri");
export const isWindows = process.platform === "win32";
const exe = isWindows ? ".exe" : "";
/** Reuses the Playwright suites' fixtures rather than adding more binaries. */
export function fixture(name) {
const path = join(
e2eDir,
"..",
"editor",
"src",
"core",
"tests",
"test-fixtures",
name,
);
if (!existsSync(path)) {
throw new Error(`Missing test fixture: ${path}`);
}
return path;
}
/**
* `tauri build --no-bundle` renames the Cargo artefact to `mainBinaryName`,
* which only differs from the crate name on a case-sensitive filesystem - so
* check both spellings, release before debug.
*/
export function resolveAppBinary() {
if (process.env.STIRLING_APP_BINARY) {
const explicit = process.env.STIRLING_APP_BINARY;
if (!existsSync(explicit)) {
throw new Error(
`STIRLING_APP_BINARY points at a missing file: ${explicit}`,
);
}
return explicit;
}
const candidates = [];
for (const profile of ["release", "debug"]) {
for (const name of ["Stirling-PDF", "stirling-pdf"]) {
candidates.push(join(tauriDir, "target", profile, `${name}${exe}`));
}
}
const found = candidates.find((candidate) => existsSync(candidate));
if (!found) {
throw new Error(
"No built desktop binary found. Build one first:\n" +
" task desktop:build:dev\n" +
`Looked in:\n${candidates.map((c) => ` ${c}`).join("\n")}`,
);
}
return found;
}
@@ -0,0 +1,82 @@
// Exercises real tools against whichever backend the desktop app started.
//
// Every call here goes through the *bundled* jlink JRE running the *bundled*
// JAR, so a failure means the shipped runtime is wrong for this platform -
// a missing jlink module, a JRE older than the JAR's class-file version, or
// native libraries that were not published for this OS/arch.
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const PDF_MAGIC = "%PDF-";
async function pdfPart(path) {
const bytes = await readFile(path);
return new File([bytes], basename(path), { type: "application/pdf" });
}
async function postForm(baseUrl, path, form) {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
body: form,
});
if (!response.ok) {
const detail = await response.text().catch(() => "<unreadable body>");
throw new Error(
`POST ${path} returned ${response.status}: ${detail.slice(0, 800)}`,
);
}
return Buffer.from(await response.arrayBuffer());
}
export function baseUrl(port) {
return `http://127.0.0.1:${port}`;
}
export async function fetchStatus(port) {
const response = await fetch(`${baseUrl(port)}/api/v1/info/status`);
if (!response.ok) {
throw new Error(`status endpoint returned ${response.status}`);
}
return response.text();
}
/**
* Pure-PDFBox tool: proves the bundled JRE can run an ordinary server tool
* end to end and hand back a real PDF.
*/
export async function rotatePdf(port, filePath, angle = 90) {
const form = new FormData();
form.set("fileInput", await pdfPart(filePath));
form.set("angle", String(angle));
return postForm(baseUrl(port), "/api/v1/general/rotate-pdf", form);
}
/**
* Merge goes through `stirling.software.jpdfium.PdfMerge`, so it loads the
* JPDFium native for this platform. Those natives are selected at build time
* (-PjpdfiumPlatforms) and a wrong or missing one only fails at runtime -
* exactly what a build-only CI job cannot catch.
*/
export async function mergePdfs(port, filePaths) {
const form = new FormData();
for (const path of filePaths) {
form.append("fileInput", await pdfPart(path));
}
return postForm(baseUrl(port), "/api/v1/general/merge-pdfs", form);
}
export function assertIsPdf(bytes, what) {
if (bytes.length < 1024) {
throw new Error(`${what}: suspiciously small response (${bytes.length}B)`);
}
const header = bytes.subarray(0, PDF_MAGIC.length).toString("latin1");
if (header !== PDF_MAGIC) {
throw new Error(`${what}: response is not a PDF (starts with "${header}")`);
}
}
/** Cheap page count - good enough to prove a merge actually combined inputs. */
export function countPages(bytes) {
const matches = bytes.toString("latin1").match(/\/Type\s*\/Page[^s]/g);
return matches ? matches.length : 0;
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "stirling-desktop-e2e",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Desktop E2E suite - drives the built Tauri app through tauri-driver. Deliberately kept out of the frontend npm workspace so WebdriverIO never lands in the shared install.",
"scripts": {
"test": "wdio run wdio.conf.mjs"
},
"devDependencies": {
"@wdio/cli": "^9.20.0",
"@wdio/jasmine-framework": "^9.20.0",
"@wdio/local-runner": "^9.20.0",
"@wdio/spec-reporter": "^9.20.0"
}
}
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
// Headless desktop smoke: launch the built app, wait for its bundled backend,
// then run real tools through it.
//
// Why this exists alongside the WebDriver suite: tauri-driver has no macOS
// backend (Apple ships no WebDriver for WKWebView), so the wdio specs can only
// run on Linux and Windows. This runner never touches the webview - it reads
// the backend port off the app's stdout and talks HTTP - so it is the only
// desktop coverage that works on all three platforms. On Linux the app still
// needs a display; CI wraps this in xvfb-run.
import { spawn, spawnSync } from "node:child_process";
import { isWindows, resolveAppBinary, fixture } from "../lib/app-binary.mjs";
import {
assertIsPdf,
countPages,
fetchStatus,
mergePdfs,
rotatePdf,
} from "../lib/backend-tools.mjs";
// Spring Boot cold start inside a jlink runtime on a cold CI runner.
const BACKEND_TIMEOUT_MS = Number(process.env.SMOKE_TIMEOUT_MS || 240_000);
// utils/logging.rs mirrors every add_log() to stdout; commands/backend.rs logs
// this line once the Java process reports the port the OS handed it.
const PORT_PATTERNS = [
/backend started on port:\s*(\d+)/i,
/running on port:\s*(\d+)/i,
];
const checks = [];
async function check(name, fn) {
const started = Date.now();
try {
await fn();
checks.push({ name, ok: true, ms: Date.now() - started });
console.log(` ok ${name}`);
} catch (error) {
checks.push({ name, ok: false, ms: Date.now() - started, error });
console.log(
` FAIL ${name}\n ${error.message.replace(/\n/g, "\n ")}`,
);
}
}
function killTree(child) {
if (!child?.pid || child.exitCode !== null) return;
if (isWindows) {
// The app forks the bundled java process; killing only the parent leaves
// it holding its port.
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
stdio: "ignore",
});
} else {
child.kill("SIGTERM");
}
}
function launchApp(binary) {
const child = spawn(binary, [], { stdio: ["ignore", "pipe", "pipe"] });
const output = [];
let resolvePort;
let rejectPort;
const port = new Promise((resolve, reject) => {
resolvePort = resolve;
rejectPort = reject;
});
const scan = (chunk) => {
const text = chunk.toString();
output.push(text);
for (const pattern of PORT_PATTERNS) {
const match = text.match(pattern);
if (match) resolvePort(Number(match[1]));
}
};
child.stdout.on("data", scan);
child.stderr.on("data", scan);
child.on("error", rejectPort);
child.on("exit", (code) =>
rejectPort(new Error(`app exited early with code ${code}`)),
);
const timer = setTimeout(
() =>
rejectPort(new Error(`no backend port within ${BACKEND_TIMEOUT_MS}ms`)),
BACKEND_TIMEOUT_MS,
);
port.finally(() => clearTimeout(timer)).catch(() => {});
return { child, port, output };
}
const binary = resolveAppBinary();
console.log(`Desktop smoke on ${process.platform}`);
console.log(`Application under test: ${binary}\n`);
const { child, port: portPromise, output } = launchApp(binary);
let port;
try {
port = await portPromise;
console.log(`Bundled backend came up on port ${port}\n`);
} catch (error) {
console.error(`\nApp never reported a backend port: ${error.message}`);
console.error("--- app output ---");
console.error(output.join("") || "(nothing captured)");
console.error("--- end app output ---");
killTree(child);
process.exit(1);
}
try {
await check("bundled JAR serves a healthy status endpoint", async () => {
const body = await fetchStatus(port);
if (!body.includes("UP"))
throw new Error(`status body was: ${body.slice(0, 200)}`);
});
await check("server tool: rotate-pdf returns a rotated PDF", async () => {
const result = await rotatePdf(port, fixture("sample.pdf"), 90);
assertIsPdf(result, "rotate-pdf");
});
await check("JPDFium: merge-pdfs combines documents natively", async () => {
const inputs = [
fixture("compare_sample_a.pdf"),
fixture("compare_sample_b.pdf"),
];
const merged = await mergePdfs(port, inputs);
assertIsPdf(merged, "merge-pdfs");
// MergeController merges via stirling.software.jpdfium.PdfMerge and rethrows
// any native failure as "JPDFium merge failed", so a 200 here means the
// platform's JPDFium native loaded and ran.
const pages = countPages(merged);
if (pages < 2) {
throw new Error(`merged PDF has ${pages} page(s); expected at least 2`);
}
});
} finally {
killTree(child);
}
const failed = checks.filter((c) => !c.ok);
console.log(
`\n${checks.length - failed.length}/${checks.length} checks passed` +
(failed.length
? ` - failing: ${failed.map((c) => c.name).join(", ")}`
: ""),
);
if (failed.length) {
console.error("\n--- app output ---");
console.error(output.join("") || "(nothing captured)");
console.error("--- end app output ---");
process.exit(1);
}
@@ -0,0 +1,32 @@
// Proves the packaged app actually opens and renders. A `tauri build` that
// succeeds still ships a blank window when the webview fails to start (#6878
// Wayland/EGL) or when the frontend bundle never mounts - neither shows up in
// a build-only CI job.
import { waitForAppMount } from "./helpers/ui.js";
describe("desktop app boot", () => {
it("opens the app window with the bundled frontend title", async () => {
await expect(browser).toHaveTitle("Stirling PDF");
});
it("mounts the React app instead of showing a blank window", async () => {
const root = await $("#root");
await expect(root).toExist();
// A blank window still has #root - the tell is that nothing rendered into
// it. Wait for real children rather than asserting immediately, since the
// webview is driveable before React has finished its first paint.
await waitForAppMount();
});
it("stays on an app URL so the navigation guard has not been bypassed", async () => {
// lib.rs::is_app_url only permits the bundled app and the dev server.
// Anything else here means the webview navigated away from the app, which
// is what left the window unclosable in #6872.
const url = await browser.getUrl();
expect(url).toMatch(
/^(tauri:\/\/localhost|https?:\/\/(tauri\.localhost|localhost|127\.0\.0\.1))/,
);
});
});
@@ -0,0 +1,40 @@
// Proves the bundled JRE can actually run the bundled JAR.
//
// This is the regression net for the whole jlink chain: a runtime missing a
// module (jdk.dynalink was missing and broke PDF/A validation), a JRE older
// than the JAR's class-file version, a JAR that never got copied into
// resources, or natives absent for the platform. All of those bundle
// perfectly happily and only fail when the app is run.
import { readAppLogs, waitForBackendPort } from "./helpers/tauri.js";
describe("bundled backend sidecar", () => {
let port;
it("starts the bundled JRE and reports the backend port", async () => {
try {
port = await waitForBackendPort();
} catch (error) {
// The Rust side logs every step of JRE/JAR discovery, so dump it before
// failing - otherwise a CI failure here is undebuggable.
const logs = await readAppLogs();
console.error("--- app logs ---");
console.error(logs.join("\n") || "(no logs captured)");
console.error("--- end app logs ---");
throw error;
}
expect(port).toBeGreaterThan(0);
});
it("serves a healthy status endpoint from the bundled JAR", async () => {
// Checked from Node rather than the webview: the backend's CORS policy is
// written for the app's tauri:// origin, and the desktop app itself goes
// through tauri-plugin-http, so a raw in-page fetch is not representative.
const response = await fetch(`http://127.0.0.1:${port}/api/v1/info/status`);
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toContain("UP");
});
});
@@ -0,0 +1,49 @@
// Drives a real tool from the UI, in the packaged app, end to end.
//
// This is the only test that covers the whole desktop chain in one go: the
// webview renders the workbench, a file lands in IndexedDB inside the packaged
// app, the tool posts to /api/v1/general/rotate-pdf on the *bundled* backend,
// and the result comes back into the review panel. Any link in that chain can
// break in the desktop build alone - the Playwright suites run against a dev
// server with stubbed APIs and would not notice.
import { fixture } from "../lib/app-binary.mjs";
import {
dismissStartupModals,
uploadFile,
waitForAppMount,
} from "./helpers/ui.js";
describe("desktop UI runs a tool against the bundled backend", () => {
it("uploads a PDF, rotates it, and surfaces a downloadable result", async () => {
await waitForAppMount();
await dismissStartupModals();
await uploadFile(fixture("sample.pdf"));
const rotateTool = await $('[data-tour="tool-button-rotate"]');
await rotateTool.click();
// Rotate needs no configuration beyond its default angle, so the run button
// enabling is the signal that the tool accepted the uploaded file.
const runButton = await $('[data-tour="run-button"]');
await runButton.waitForEnabled({
timeout: 30_000,
timeoutMsg:
"Rotate's run button never enabled - the uploaded file did not reach " +
"the tool panel.",
});
await runButton.click();
// The review panel only renders once the backend has returned a result, so
// this failing means the request never completed: the bundled backend was
// unreachable from the webview, or it errored.
await $('[data-testid="review-panel-container"]').waitForExist({
timeout: 120_000,
timeoutMsg:
"No review panel after running Rotate - the UI never got a result " +
"back from the bundled backend.",
});
await expect($('[data-testid="download-result-button"]')).toExist();
});
});
@@ -0,0 +1,77 @@
// `browser`, `$` and `expect` are injected as globals by WebdriverIO's mocha
// framework (injectGlobals defaults to true), so specs and helpers use them
// without importing.
/**
* Fires a Tauri command in the webview and returns the most recent answer.
*
* Fire-and-stash rather than awaiting inside the browser: that keeps us on
* synchronous `browser.execute`, whose behaviour is stable across WebdriverIO
* majors (the `executeAsync` callback form is deprecated), and it re-seeds
* itself if the webview ever reloads and wipes the stash.
*
* @returns {Promise<{value?: unknown, error: string|null}>} `value` is absent
* until the first call resolves.
*/
export function invokeLatest(command, key = command) {
return browser.execute(
(cmd, stashKey) => {
const stash = (window.__stirlingE2E = window.__stirlingE2E || {});
const slot = (stash[stashKey] = stash[stashKey] || { error: null });
window.__TAURI_INTERNALS__
.invoke(cmd)
.then((value) => {
slot.value = value;
})
.catch((error) => {
slot.error = String(error);
});
return { value: slot.value, error: slot.error };
},
command,
key,
);
}
/**
* Waits for the bundled JRE to boot the Stirling JAR and resolves to the port
* the backend picked. The app asks the OS for a free port (-Dserver.port=0),
* so the port is only knowable at runtime via the `get_backend_port` command.
*/
export async function waitForBackendPort(timeout = 210_000) {
let port = null;
await browser.waitUntil(
async () => {
const { value, error } = await invokeLatest("get_backend_port");
if (error) {
throw new Error(`get_backend_port rejected: ${error}`);
}
if (typeof value === "number") {
port = value;
return true;
}
return false;
},
{
timeout,
interval: 2_000,
timeoutMsg:
`Backend never reported a port within ${timeout}ms. The bundled JRE ` +
"either failed to launch or crashed before Spring Boot bound a port - " +
"check the app logs dumped by the spec.",
},
);
return port;
}
/** Reads the in-app log buffer, which records the whole backend startup path. */
export async function readAppLogs() {
const { value } = await invokeLatest("get_tauri_logs", "logs");
if (Array.isArray(value)) return value;
// First call only fires the request; give it a beat, then read the result.
await browser.pause(1_000);
const { value: retried } = await invokeLatest("get_tauri_logs", "logs");
return Array.isArray(retried) ? retried : [];
}
+86
View File
@@ -0,0 +1,86 @@
// `browser`, `$` and `expect` are injected as globals by WebdriverIO's jasmine
// framework (injectGlobals defaults to true), so specs and helpers use them
// without importing.
/** Resolves once React has rendered something into #root. */
export async function waitForAppMount(timeout = 60_000) {
await browser.waitUntil(
async () =>
(await browser.execute(
() => document.querySelectorAll("#root *").length,
)) > 0,
{
timeout,
interval: 500,
timeoutMsg:
"#root never received any children - the app window opened but the " +
"frontend bundle did not mount (blank-window regression).",
},
);
}
function countOverlays() {
return browser.execute(
() => document.querySelectorAll(".mantine-Modal-overlay").length,
);
}
/**
* A fresh profile greets you with the "Welcome to Stirling V2" modal and then
* the sign-in modal, each behind an overlay that swallows clicks. Close them in
* a loop rather than assuming a fixed number: the chain is version-dependent,
* and a spec that hard-codes two dismissals breaks the moment a third appears.
*
* The `?bypassOnboarding=true` route is deliberately not used - it also
* suppresses the sign-in modal, and dismissing what a real first-run user sees
* is closer to the thing we want to know still works.
*/
export async function dismissStartupModals(maxModals = 6) {
for (let attempt = 0; attempt < maxModals; attempt += 1) {
if ((await countOverlays()) === 0) return;
const close = await $('.mantine-Modal-content [aria-label="Close"]');
if (await close.isExisting()) {
await close.click().catch(() => {});
} else {
await browser.keys(["Escape"]);
}
await browser.pause(1_000);
}
await browser.waitUntil(async () => (await countOverlays()) === 0, {
timeout: 15_000,
interval: 500,
timeoutMsg: `A modal overlay is still blocking the UI after ${maxModals} dismissals.`,
});
}
/**
* Puts a file into the workbench through the app's own file input.
*
* The input is visually hidden, and unlike Playwright's setInputFiles a
* WebDriver send-keys needs the element to be interactable - WebKitWebDriver
* refuses outright. Making it briefly visible is the standard workaround and
* still exercises the real upload path.
*/
export async function uploadFile(path) {
await browser.execute(() => {
const input = document.querySelector('[data-testid="file-input"]');
if (!input) return;
input.style.display = "block";
input.style.visibility = "visible";
input.style.opacity = "1";
input.style.width = "1px";
input.style.height = "1px";
});
await $('[data-testid="file-input"]').addValue(path);
// The sidebar list only renders once addFiles has resolved, which awaits the
// IndexedDB write - so this doubles as proof that desktop file persistence
// works in the packaged webview.
await $(".file-sidebar-file-item").waitForExist({
timeout: 30_000,
timeoutMsg: `${path} never appeared in the file sidebar.`,
});
}
+141
View File
@@ -0,0 +1,141 @@
// WebdriverIO config for the desktop E2E suite.
//
// tauri-driver is a WebDriver shim: it speaks W3C WebDriver to us, launches the
// real Tauri binary, and proxies to the platform's native webview driver
// (WebKitWebDriver on Linux, msedgedriver on Windows). macOS has no WebDriver
// for WKWebView, so this suite is Linux + Windows only.
//
// Playwright cannot drive these webviews - it speaks CDP/its own protocol, not
// WebDriver classic - which is why the desktop suite uses WebdriverIO while the
// browser suites stay on Playwright.
import { spawn, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { isWindows, resolveAppBinary } from "./lib/app-binary.mjs";
const e2eDir = dirname(fileURLToPath(import.meta.url));
const exe = isWindows ? ".exe" : "";
const appBinary = resolveAppBinary();
// Spawned without a shell so the returned pid is tauri-driver itself. Going
// through cmd.exe on Windows would give us the shell's pid, and killing that
// leaves tauri-driver (and the webview driver it owns) running forever - which
// hangs the run at teardown. `cargo install` puts it in the cargo bin dir;
// fall back to PATH for anyone who installed it elsewhere.
function resolveTauriDriver() {
const cargoBin = join(
process.env.CARGO_HOME || join(homedir(), ".cargo"),
"bin",
`tauri-driver${exe}`,
);
return existsSync(cargoBin) ? cargoBin : `tauri-driver${exe}`;
}
let tauriDriver;
export const config = {
runner: "local",
// tauri-driver is already running as a plain WebDriver server on this port,
// so wdio must attach to it rather than manage a browser driver itself.
hostname: "127.0.0.1",
port: 4444,
path: "/",
// Nested array = one session shared by both specs, run in this order. That
// is required, not just an optimisation: the app registers
// tauri-plugin-single-instance, so a second launch while the first is still
// shutting down hands off to the old instance and exits immediately. Sharing
// a session also means we pay the Spring Boot cold start only once.
// Order matters: backend-sidecar blocks until the bundled backend reports a
// port, so the UI spec after it can assume the backend is reachable instead
// of racing Spring Boot's cold start.
specs: [
[
join(e2eDir, "specs", "app-boot.e2e.js"),
join(e2eDir, "specs", "backend-sidecar.e2e.js"),
join(e2eDir, "specs", "frontend-tool-run.e2e.js"),
],
],
// The app owns a single OS window and a single bundled backend; running
// sessions concurrently would have them fight over both.
maxInstances: 1,
capabilities: [
{
browserName: "wry",
"tauri:options": {
application: appBinary,
},
// WebdriverIO prefers WebDriver BiDi where the driver advertises it
// (msedgedriver does). BiDi evaluates scripts in its own realm, and
// Tauri's IPC rejects invokes from there with "Origin header is not a
// valid URL" - so every window.__TAURI_INTERNALS__.invoke() fails. The
// classic executeScript endpoint runs in the page's real realm and works.
// WebKitWebDriver has no BiDi anyway, so this also keeps Linux and
// Windows on the same code path.
"wdio:enforceWebDriverClassic": true,
},
],
// Per-worker logs on disk so a CI failure can be inspected after the fact.
outputDir: join(e2eDir, "logs"),
logLevel: "info",
bail: 0,
waitforTimeout: 30_000,
connectionRetryTimeout: 180_000,
connectionRetryCount: 3,
// Jasmine rather than Mocha: same describe/it surface, but Mocha's tree
// carries unpatched advisories (serialize-javascript) that dependency-review
// fails the PR on, and its only "fix" is a major downgrade of the wdio
// framework adapter.
framework: "jasmine",
reporters: ["spec"],
jasmineOpts: {
// The bundled JRE + Spring Boot cold start dominates: on a cold CI runner
// the backend can take well over a minute to report its port.
defaultTimeoutInterval: 240_000,
},
onPrepare: () => {
const args = ["--port", "4444"];
// Windows runners ship msedgedriver at a fixed path but do not put it on
// PATH; Linux gets WebKitWebDriver on PATH from the webkit2gtk-driver
// package, so the override is optional there.
if (process.env.TAURI_DRIVER_NATIVE) {
args.push("--native-driver", process.env.TAURI_DRIVER_NATIVE);
}
const bin = resolveTauriDriver();
console.log(`Launching ${bin} ${args.join(" ")}`);
console.log(`Application under test: ${appBinary}`);
tauriDriver = spawn(bin, args, {
stdio: ["ignore", "inherit", "inherit"],
});
tauriDriver.on("error", (error) => {
console.error("tauri-driver failed to start:", error);
process.exit(1);
});
},
onComplete: () => {
// Without this the driver keeps the webview driver (and the app) alive and
// the run never exits after the last spec.
if (!tauriDriver?.pid) return;
if (isWindows) {
// tauri-driver spawns msedgedriver as a child; a plain kill() leaves it
// orphaned and holding the port, so tear down the whole tree.
spawnSync("taskkill", ["/pid", String(tauriDriver.pid), "/T", "/F"], {
stdio: "ignore",
});
} else {
tauriDriver.kill();
}
},
};
@@ -508,8 +508,8 @@ pub async fn login(
let username = login_response.user.user_metadata
.as_ref()
.and_then(|m| m.full_name.clone())
.or_else(|| email.clone())
.unwrap_or_else(|| username);
.or(email.clone())
.unwrap_or(username);
Ok(LoginResponse {
token: login_response.access_token,
@@ -568,7 +568,7 @@ pub async fn login(
} else if error_lower.contains("timeout") {
format!("Connection timeout: Server at {} is not responding. Check your network connection.", login_url)
} else if error_lower.contains("dns") || error_lower.contains("resolve") {
format!("DNS resolution failed: Cannot resolve hostname. Check if the server URL is correct.")
"DNS resolution failed: Cannot resolve hostname. Check if the server URL is correct.".to_string()
} else {
format!("Network error: {}", e)
}
@@ -55,7 +55,7 @@ fn check_backend_status() -> Result<(), String> {
}
// Find the bundled JRE and return the java executable path
fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
fn find_bundled_jre(resource_dir: &Path) -> Result<PathBuf, String> {
let jre_dir = resource_dir.join("runtime").join("jre");
let java_executable = if cfg!(windows) {
jre_dir.join("bin").join("java.exe")
@@ -74,7 +74,7 @@ fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
}
// Find the Stirling-PDF JAR file
fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
fn find_stirling_jar(resource_dir: &Path) -> Result<PathBuf, String> {
let libs_dir = resource_dir.join("libs");
let mut jar_files: Vec<_> = std::fs::read_dir(&libs_dir)
.map_err(|e| {
@@ -113,20 +113,20 @@ fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
}
// Normalize path to remove Windows UNC prefix
fn normalize_path(path: &PathBuf) -> PathBuf {
fn normalize_path(path: &Path) -> PathBuf {
if cfg!(windows) {
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
PathBuf::from(&path_str[4..]) // Remove \\?\ prefix
if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path.clone()
path.to_path_buf()
}
} else {
path.clone()
path.to_path_buf()
}
}
fn migrate_legacy_workspace(legacy_dir: &PathBuf, target_root: &PathBuf) -> std::io::Result<()> {
fn migrate_legacy_workspace(legacy_dir: &Path, target_root: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(legacy_dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
@@ -313,7 +313,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
// Format: "Stirling-PDF running on port: PORT"
if output_str.contains("running on port:") {
_startup_detected = true;
if let Some(port) = extract_port_from_running_log(&output_str) {
if let Some(port) = extract_port_from_running_log(output_str) {
let mut port_guard = BACKEND_PORT.lock().unwrap();
*port_guard = Some(port);
add_log(format!("🎉 Backend started on port: {}", port));
@@ -428,15 +428,13 @@ pub async fn start_backend(
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
// Find the bundled JRE
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
let java_executable = find_bundled_jre(&resource_dir).inspect_err(|_| {
reset_starting_flag();
e
})?;
// Find the Stirling-PDF JAR
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
let jar_path = find_stirling_jar(&resource_dir).inspect_err(|_| {
reset_starting_flag();
e
})?;
// Normalize the paths to remove Windows UNC prefix
@@ -448,9 +446,8 @@ pub async fn start_backend(
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
// Create and start the Java command
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).inspect_err(|_| {
reset_starting_flag();
e
})?;
// Reset the starting flag since startup is complete
@@ -27,27 +27,23 @@ const PROVISIONING_FILE_NAME: &str = "stirling-provisioning.json";
/// How the desktop auto-updater should behave on startup.
///
/// * `Prompt` default. Show the update popup when a new version is available
/// and let the user decide whether to install.
/// * `Auto` silently download and install updates on startup, then restart.
/// Intended for managed deployments (Intune/MDM) where the user
/// cannot (or should not) be prompted.
/// * `Disabled` never check for updates, never show the update UI. Administrators
/// are expected to push updates through their normal packaging flow.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
/// * `Prompt` default. Show the update popup when a new version is available
/// and let the user decide whether to install.
/// * `Auto` silently download and install updates on startup, then restart.
/// Intended for managed deployments (Intune/MDM) where the user cannot (or
/// should not) be prompted.
/// * `Disabled` never check for updates, never show the update UI.
/// Administrators are expected to push updates through their normal
/// packaging flow.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpdateMode {
#[default]
Prompt,
Auto,
Disabled,
}
impl Default for UpdateMode {
fn default() -> Self {
UpdateMode::Prompt
}
}
/// Current update mode plus whether the UI is allowed to change it. Returned
/// by [`get_update_mode`] so the settings page can show a "managed by
/// administrator" hint instead of silently ignoring clicks.
@@ -309,7 +305,7 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
if let Some(mode) = parsed.update_mode {
store.set(
UPDATE_MODE_KEY,
serde_json::to_value(&mode)
serde_json::to_value(mode)
.map_err(|e| format!("Failed to serialize update mode: {}", e))?,
);
// Only lock the UI when the provisioning file came from a path that
@@ -445,7 +441,7 @@ pub async fn set_update_mode(
store.set(
UPDATE_MODE_KEY,
serde_json::to_value(&mode)
serde_json::to_value(mode)
.map_err(|e| format!("Failed to serialize update mode: {}", e))?,
);
store
+1 -1
View File
@@ -177,7 +177,7 @@ pub fn run() {
});
}
if let Err(err) = apply_provisioning_if_present(&app.handle()) {
if let Err(err) = apply_provisioning_if_present(app.handle()) {
add_log(format!("⚠️ Failed to apply provisioning file: {}", err));
}
+23
View File
@@ -269,6 +269,29 @@ export default defineConfig({
node: true,
},
},
{
// Desktop E2E harness: a standalone npm package (its own package.json and
// lockfile), deliberately outside editor/ so its package.json cannot
// hijack Tauri's app-dir resolution. Nothing resolves @app/* there - no
// bundler, no tsconfig paths - so relative imports are the only option.
// `browser`, `$` and `expect` are injected as globals by WebdriverIO.
files: ["desktop-e2e/**/*.{js,mjs}"],
globals: {
...modernGlobals,
browser: "readonly",
$: "readonly",
$$: "readonly",
expect: "readonly",
describe: "readonly",
it: "readonly",
},
env: {
node: true,
},
rules: {
"no-restricted-imports": "off",
},
},
{
// Editor app source (excluding desktop): ban relative/src imports, ban
// Tauri (desktop-only), and the shared-DS Mantine import ban.