diff --git a/.github/workflows/app-e2e.yml b/.github/workflows/app-e2e.yml new file mode 100644 index 0000000..eeacc6b --- /dev/null +++ b/.github/workflows/app-e2e.yml @@ -0,0 +1,179 @@ +name: App E2E Tests + +on: + workflow_call: + inputs: + smoke_only: + description: Run the cross-platform pull-request smoke matrix + required: false + default: true + type: boolean + workflow_dispatch: + inputs: + suite: + description: E2E suite to run + required: false + default: full + type: choice + options: + - full + - smoke + - ui + - entities + - integrations + - sync + - browser + push: + branches: ["main"] + schedule: + - cron: "17 3 * * 1" + +permissions: + contents: read + +jobs: + smoke: + name: Smoke (${{ matrix.os }}) + if: ${{ inputs.smoke_only == true }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + + steps: + - name: Disable git core.autocrlf on Windows + if: runner.os == 'Windows' + run: git config --global core.autocrlf false + + - name: Checkout Donut Browser + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: donutbrowser + + - name: Checkout private cross-platform WebDriver + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY || 'zhom/tauri-cross-platform-webdriver' }} + token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }} + path: tauri-cross-platform-webdriver + + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: donutbrowser/.node-version + cache: pnpm + cache-dependency-path: donutbrowser/pnpm-lock.yaml + + - name: Install Rust + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: | + donutbrowser/src-tauri -> target + tauri-cross-platform-webdriver -> target + + - name: Install Tauri dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev xvfb openvpn + + - name: Install app dependencies + working-directory: donutbrowser + run: pnpm install --frozen-lockfile + + - name: Run isolated smoke suite + working-directory: donutbrowser + env: + DONUT_E2E_KEEP_ARTIFACTS: "1" + shell: bash + run: | + if [[ "${RUNNER_OS}" == "Linux" ]]; then + xvfb-run -a pnpm e2e:smoke + else + pnpm e2e:smoke + fi + + - name: Upload E2E diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-e2e-smoke-${{ matrix.os }} + path: ${{ runner.temp }}/donut-e2e-* + if-no-files-found: ignore + retention-days: 7 + + full: + name: Full UI, sync, encryption, and Wayfern + if: ${{ inputs.smoke_only != true }} + runs-on: macos-latest + timeout-minutes: 40 + + steps: + - name: Checkout Donut Browser + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: donutbrowser + + - name: Checkout private cross-platform WebDriver + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY || 'zhom/tauri-cross-platform-webdriver' }} + token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }} + path: tauri-cross-platform-webdriver + + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: donutbrowser/.node-version + cache: pnpm + cache-dependency-path: donutbrowser/pnpm-lock.yaml + + - name: Install Rust + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: | + donutbrowser/src-tauri -> target + tauri-cross-platform-webdriver -> target + + - name: Install app dependencies + working-directory: donutbrowser + run: pnpm install --frozen-lockfile + + - name: Run full isolated app suite + working-directory: donutbrowser + env: + DONUT_E2E_KEEP_ARTIFACTS: "1" + DONUT_E2E_SUITE: ${{ inputs.suite || 'full' }} + WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }} + run: node e2e/run.mjs --suite=${DONUT_E2E_SUITE} + + - name: Upload E2E diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-e2e-full-macos + path: ${{ runner.temp }}/donut-e2e-* + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 64234fe..7c8d685 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -45,15 +45,22 @@ jobs: permissions: contents: read + app-e2e: + name: Cross-platform App E2E + uses: ./.github/workflows/app-e2e.yml + secrets: inherit + permissions: + contents: read + pr-status: name: PR Status Check runs-on: ubuntu-latest - needs: [lint-js, lint-rust, security-scan, sync-e2e] + needs: [lint-js, lint-rust, security-scan, sync-e2e, app-e2e] if: always() steps: - name: Check all jobs succeeded run: | - if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then + if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" || "${{ needs.app-e2e.result }}" != "success" ]]; then echo "One or more checks failed" exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index bfeaf85..970f0e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,9 @@ donutbrowser/ │ └── Cargo.toml # Rust dependencies ├── donut-sync/ # NestJS sync server (self-hostable) │ └── src/ # Controllers, services, auth, S3 sync -├── docs/ # Documentation (self-hosting guide) +├── e2e/ # Isolated native UI/sync/Wayfern E2E system +│ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers +│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites ├── flake.nix # Nix development environment └── .github/workflows/ # CI/CD pipelines ``` @@ -64,6 +66,34 @@ donutbrowser/ - The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter: `pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed. +### Native app E2E tests are mandatory for affected behavior + +The native suites use the sibling `../tauri-cross-platform-webdriver/` repository and launch an +`e2e`-feature build. Every session gets its own temporary Donut data/cache/log root, home directory, +WebView store, ports, and sync bucket. Never point a suite at production or development data. + +After a behavior change, run the smallest affected subset below in addition to the standard +format/lint/unit-test command. A code change is not considered verified until its affected native +suite passes: + +| Changed area | Required command | +| --- | --- | +| Startup, settings, persistence, window state, shortcuts, navigation | `pnpm e2e:smoke` | +| React components, dialogs, responsive layout, accessibility, onboarding | `pnpm e2e:ui` | +| Profiles, imports, groups, proxies, VPNs, extensions, DNS, cookies, passwords, traffic | `pnpm e2e:entities` | +| REST API/OpenAPI, MCP, cloud/update contracts, team locks, real-time synchronizer | `pnpm e2e:integrations` | +| Sync client/server, manifests, timestamps, deletion, encryption, password rollover | `pnpm e2e:sync` | +| Wayfern download/terms/fingerprint, browser runner, CDP, automation endpoints, process cleanup | `pnpm e2e:browser` | +| E2E harness, WebDriver plugin/driver, app isolation hooks, or changes spanning multiple rows | Run every affected row; use `pnpm e2e` for cross-cutting changes | + +`e2e:browser` and the full suite require `WAYFERN_TEST_TOKEN` in the environment or the local +`.env`; all other suites must run without credentials. Use `--no-build` only when the frontend, +Rust app, sidecar, and WebDriver binaries are already current. Keep failed artifacts and inspect the +per-session app/driver logs and screenshot before changing assertions. + +When adding a Tauri command, assign it exactly once in `e2e/coverage-map.mjs` and add executable +evidence to the owning suite. `e2e:smoke` fails if command registration and the coverage map drift. + ## Logs (when debugging a running app) Three log surfaces, in order of usefulness: diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..1b45734 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,68 @@ +# Donut Browser native E2E tests + +These tests exercise the actual Tauri application through the private +`../tauri-cross-platform-webdriver/` driver. They do not replace Rust or React unit tests; they +cover the process boundaries those tests cannot: WKWebView/WebView2/WebKitGTK UI, Tauri invokes, +REST and MCP servers, two-device sync, S3 payload encryption, Wayfern, CDP, and child-process +cleanup. + +## Local setup + +Place both repositories beside each other: + +```text +Code/ +├── donutbrowser/ +└── tauri-cross-platform-webdriver/ +``` + +Install Donut dependencies with `pnpm install`. The browser suite also needs +`WAYFERN_TEST_TOKEN`. The runner reads it from the environment, Donut's `.env`, or +`../wayfern-test/.env` without printing it. If `../wayfern-test/test_extracted_app` contains a +Wayfern build, the runner links/copies it into the test data root; otherwise the browser suite +downloads the current published build into that root. + +Run one suite: + +```sh +pnpm e2e:smoke +pnpm e2e:ui +pnpm e2e:entities +pnpm e2e:integrations +pnpm e2e:sync +pnpm e2e:browser +``` + +Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, the +`e2e`-feature app, and `tauri-wd`. Add `--no-build` to `node e2e/run.mjs --suite=` only when +all four outputs are current. `DONUT_E2E_KEEP_ARTIFACTS=1` retains successful runs; failed runs are +always retained and their location is printed. + +## Isolation contract + +Each app session receives a unique root under the operating-system test temp directory. The +runner redirects: + +- Donut data, cache, and logs with `DONUTBROWSER_DATA_ROOT`; +- `HOME`, `USERPROFILE`, `CFFIXED_USER_HOME`, XDG paths, `APPDATA`, and `LOCALAPPDATA`; +- `TMPDIR`, `TMP`, and `TEMP`; +- the Tauri WebView store (incognito for WKWebView, whose persistent data-directory API is not + honored); +- all REST, MCP, WebDriver, fixture, MinIO, and sync-server ports; +- each sync test to a new MinIO bucket and random token. + +The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still +exercise published Wayfern downloads when no local fixture exists. Entitlement fallback from +`WAYFERN_TEST_TOKEN` exists only in the feature-gated test binary. Production builds never include +the WebDriver plugin or this fallback. + +## CI + +`.github/workflows/app-e2e.yml` runs smoke tests on macOS, Linux/Xvfb, and Windows for pull +requests. Pushes to `main`, weekly schedules, and manual runs execute the full macOS suite, +including MinIO-backed sync and real Wayfern automation. + +Because the WebDriver repository is private, CI needs a `TAURI_WEBDRIVER_TOKEN` secret with +read-only access. `TAURI_WEBDRIVER_REPOSITORY` may override its default +`zhom/tauri-cross-platform-webdriver` repository name. The full job also requires the +`WAYFERN_TEST_TOKEN` secret. diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs new file mode 100644 index 0000000..c9f9e1e --- /dev/null +++ b/e2e/coverage-map.mjs @@ -0,0 +1,286 @@ +/** + * Auditable ownership for every Tauri command. The coverage test compares this + * map to generate_handler!, so adding a backend capability without assigning it + * to an E2E suite fails immediately. + * + * "integration" means the suite exercises the command with real isolated state. + * "contract" means the command's safe/read-only or unauthenticated path is run. + * "host-mutating" is reserved for operations whose purpose is to change the + * machine outside Donut's data roots; their reason must remain explicit. + */ +export const commandCoverage = { + lifecycle: { + suite: "smoke", + level: "integration", + commands: [ + "confirm_quit", + "hide_to_tray", + "update_tray_menu", + "get_app_settings", + "save_app_settings", + "read_log_files", + "get_table_sorting_settings", + "save_table_sorting_settings", + "get_system_language", + "get_system_info", + "dismiss_window_resize_warning", + "get_window_resize_warning_dismissed", + "get_onboarding_completed", + "complete_onboarding", + ], + }, + profileEntities: { + suite: "entities", + level: "integration", + commands: [ + "delete_profile", + "clone_profile", + "create_browser_profile_new", + "list_browser_profiles", + "get_all_tags", + "update_profile_proxy", + "update_profile_vpn", + "update_profile_tags", + "update_profile_note", + "update_profile_clear_on_close", + "update_profile_launch_hook", + "update_profile_window_color", + "update_profile_proxy_bypass_rules", + "update_profile_dns_blocklist", + "rename_profile", + "detect_existing_profiles", + "import_browser_profiles", + "scan_folder_for_profiles", + "scan_profile_archive", + "cleanup_profile_import_scratch", + "get_profile_groups", + "get_groups_with_profile_counts", + "create_profile_group", + "update_profile_group", + "delete_profile_group", + "assign_profiles_to_group", + "delete_selected_profiles", + ], + }, + proxyEntities: { + suite: "entities", + level: "integration", + commands: [ + "create_stored_proxy", + "get_stored_proxies", + "update_stored_proxy", + "delete_stored_proxy", + "check_proxy_validity", + "get_cached_proxy_check", + "export_proxies", + "import_proxies_json", + "parse_txt_proxies", + "import_proxies_from_parsed", + ], + }, + extensions: { + suite: "entities", + level: "integration", + commands: [ + "list_extensions", + "get_extension_icon", + "add_extension", + "update_extension", + "delete_extension", + "list_extension_groups", + "create_extension_group", + "update_extension_group", + "delete_extension_group", + "add_extension_to_group", + "remove_extension_from_group", + "assign_extension_group_to_profile", + "get_extension_group_for_profile", + ], + }, + vpn: { + suite: "entities", + level: "integration", + commands: [ + "import_vpn_config", + "list_vpn_configs", + "get_vpn_config", + "delete_vpn_config", + "create_vpn_config_manual", + "update_vpn_config", + "check_vpn_validity", + "disconnect_vpn", + "get_vpn_status", + "list_active_vpn_connections", + ], + }, + cookiesPasswordsAndTraffic: { + suite: "entities", + level: "integration", + commands: [ + "get_all_traffic_snapshots", + "get_profile_traffic_snapshot", + "clear_all_traffic_stats", + "clear_profile_traffic_stats", + "get_traffic_stats_for_period", + "read_profile_cookies", + "get_profile_cookie_stats", + "copy_profile_cookies", + "import_cookies_from_file", + "export_profile_cookies", + "set_profile_password", + "change_profile_password", + "remove_profile_password", + "verify_profile_password", + "unlock_profile", + "lock_profile", + "is_profile_locked", + ], + }, + dns: { + suite: "entities", + level: "integration", + commands: [ + "dns_blocklist::get_dns_blocklist_cache_status", + "dns_blocklist::refresh_dns_blocklists", + "dns_blocklist::get_custom_dns_config", + "dns_blocklist::set_custom_dns_config", + "dns_blocklist::import_custom_dns_rules", + "dns_blocklist::export_custom_dns_rules", + ], + }, + browser: { + suite: "browser", + level: "integration", + commands: [ + "get_supported_browsers", + "check_browser_exists", + "is_browser_supported_on_platform", + "download_browser", + "cancel_download", + "launch_browser_profile", + "fetch_browser_versions_with_count", + "fetch_browser_versions_cached_first", + "fetch_browser_versions_with_count_cached_first", + "get_downloaded_browser_versions", + "get_browser_release_types", + "check_browser_status", + "kill_browser_profile", + "open_url_with_profile", + "check_missing_binaries", + "check_missing_geoip_database", + "ensure_all_binaries_exist", + "ensure_active_browsers_downloaded", + "update_wayfern_config", + "generate_sample_fingerprint", + "is_geoip_database_available", + "download_geoip_database", + "fingerprint_consistency::check_profile_fingerprint_consistency", + "fingerprint_consistency::match_profile_fingerprint_to_exit", + "check_wayfern_terms_accepted", + "check_wayfern_downloaded", + "accept_wayfern_terms", + ], + }, + localIntegrations: { + suite: "integrations", + level: "integration", + commands: [ + "start_api_server", + "stop_api_server", + "get_api_server_status", + "start_mcp_server", + "stop_mcp_server", + "get_mcp_server_status", + "get_mcp_config", + "list_mcp_agents", + "add_mcp_to_agent", + "remove_mcp_from_agent", + "synchronizer::start_sync_session", + "synchronizer::stop_sync_session", + "synchronizer::remove_sync_follower", + "synchronizer::get_sync_sessions", + ], + }, + syncAndEncryption: { + suite: "sync", + level: "integration", + commands: [ + "get_sync_settings", + "save_sync_settings", + "cloud_auth::restart_sync_service", + "set_profile_sync_mode", + "cancel_profile_sync", + "request_profile_sync", + "set_proxy_sync_enabled", + "set_group_sync_enabled", + "is_proxy_in_use_by_synced_profile", + "is_group_in_use_by_synced_profile", + "set_vpn_sync_enabled", + "is_vpn_in_use_by_synced_profile", + "set_extension_sync_enabled", + "set_extension_group_sync_enabled", + "get_unsynced_entity_counts", + "enable_sync_for_all_entities", + "set_e2e_password", + "check_has_e2e_password", + "verify_e2e_password", + "delete_e2e_password", + "rollover_encryption_for_all_entities", + ], + }, + cloudContracts: { + suite: "integrations", + level: "contract", + commands: [ + "get_commercial_trial_status", + "acknowledge_trial_expiration", + "has_acknowledged_trial_expiration", + "cloud_auth::cloud_exchange_device_code", + "cloud_auth::cloud_get_user", + "cloud_auth::cloud_refresh_profile", + "cloud_auth::cloud_logout", + "cloud_auth::cloud_get_proxy_usage", + "cloud_auth::cloud_get_countries", + "cloud_auth::cloud_get_regions", + "cloud_auth::cloud_get_cities", + "cloud_auth::cloud_get_isps", + "cloud_auth::create_cloud_location_proxy", + "cloud_auth::cloud_get_wayfern_token", + "cloud_auth::cloud_refresh_wayfern_token", + "team_lock::get_team_locks", + "team_lock::get_team_lock_status", + ], + }, + updateContracts: { + suite: "integrations", + level: "contract", + commands: [ + "clear_all_version_cache_and_refetch", + "is_default_browser", + "trigger_manual_version_update", + "get_version_update_status", + "check_for_browser_updates", + "dismiss_update_notification", + "complete_browser_update_with_auto_update", + "check_for_app_updates", + "check_for_app_updates_manual", + "download_and_prepare_app_update", + ], + }, + hostMutating: { + suite: "full", + level: "host-mutating", + reason: + "These commands intentionally change OS registration, launch external file managers, restart the test process, install an external MCP agent, or create a kernel VPN interface. Their surrounding UI and validation paths are automated, but success-path mutation is forbidden on developer and CI hosts.", + commands: [ + "open_log_directory", + "set_as_default_browser", + "restart_application", + "connect_vpn", + ], + }, +}; + +export function allCoveredCommands() { + return Object.values(commandCoverage).flatMap((entry) => entry.commands); +} diff --git a/e2e/lib/app.mjs b/e2e/lib/app.mjs new file mode 100644 index 0000000..30fbf78 --- /dev/null +++ b/e2e/lib/app.mjs @@ -0,0 +1,383 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { WebDriverClient } from "./webdriver.mjs"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function isolatedEnvironment(root, extra = {}) { + const home = path.join(root, "home"); + const temp = path.join(root, "tmp"); + return { + DONUTBROWSER_DATA_ROOT: path.join(root, "donut"), + HOME: home, + USERPROFILE: home, + ...(process.platform === "darwin" ? { CFFIXED_USER_HOME: home } : {}), + TMPDIR: temp, + TMP: temp, + TEMP: temp, + XDG_CONFIG_HOME: path.join(root, "xdg", "config"), + XDG_CACHE_HOME: path.join(root, "xdg", "cache"), + XDG_DATA_HOME: path.join(root, "xdg", "data"), + APPDATA: path.join(root, "windows", "roaming"), + LOCALAPPDATA: path.join(root, "windows", "local"), + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "127.0.0.1,localhost", + HTTP_PROXY: "", + HTTPS_PROXY: "", + ALL_PROXY: "", + http_proxy: "", + https_proxy: "", + all_proxy: "", + RUST_BACKTRACE: "1", + ...extra, + }; +} + +export class AppSession { + constructor({ + name, + root, + application, + driverUrl, + cwd, + token, + extraEnv = {}, + args = [], + seedVersionCache = true, + }) { + this.name = name; + this.root = root; + this.application = application; + this.driver = new WebDriverClient(driverUrl); + this.cwd = cwd; + this.token = token; + this.extraEnv = extraEnv; + this.args = args; + this.seedVersionCache = seedVersionCache; + this.session = null; + } + + get dataRoot() { + return path.join(this.root, "donut"); + } + + async start() { + await Promise.all([ + mkdir(path.join(this.root, "home"), { recursive: true }), + mkdir(path.join(this.root, "tmp"), { recursive: true }), + mkdir(path.join(this.root, "artifacts"), { recursive: true }), + ]); + if (this.seedVersionCache) { + const versionCache = path.join( + this.root, + "donut", + "cache", + "version_cache", + "wayfern_versions.json", + ); + await mkdir(path.dirname(versionCache), { recursive: true }); + await writeFile( + versionCache, + `${JSON.stringify({ + releases: [{ version: "150.0.7871.100", date: "2026-07-01" }], + timestamp: Math.floor(Date.now() / 1000), + })}\n`, + { flag: "wx" }, + ).catch((error) => { + if (error.code !== "EEXIST") { + throw error; + } + }); + } + const env = isolatedEnvironment(this.root, { + DONUT_E2E_DISABLE_STARTUP_NETWORK: "1", + ...(process.env.DONUT_E2E_FIXTURE_URL + ? { + DONUT_E2E_DNS_BLOCKLIST_BASE_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/dns`, + ...(process.env.DONUT_E2E_GEOIP_FIXTURE_READY === "1" + ? { + DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`, + } + : {}), + } + : {}), + ...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}), + ...this.extraEnv, + }); + this.session = await this.driver.createSession({ + application: this.application, + args: this.args, + env, + cwd: this.cwd, + startupTimeout: 120_000, + }); + await this.session.setTimeouts(); + await this.waitFor( + async () => { + const ready = await this.execute( + "return document.readyState === 'complete' && Boolean(window.__TAURI_INTERNALS__);", + ); + return ready === true; + }, + { + description: `${this.name} frontend and Tauri bridge`, + timeoutMs: 60_000, + }, + ); + return this; + } + + async restart() { + await this.close(); + return this.start(); + } + + async execute(script, args = []) { + assert.ok(this.session, `${this.name} is not started`); + return this.session.execute(script, args); + } + + async invoke(command, args = {}) { + assert.ok(this.session, `${this.name} is not started`); + const result = await this.session.executeAsync( + ` + const done = arguments[arguments.length - 1]; + const command = arguments[0]; + const args = arguments[1]; + window.__TAURI_INTERNALS__.invoke(command, args) + .then((value) => done({ ok: true, value })) + .catch((error) => done({ + ok: false, + error: typeof error === "string" ? error : (error?.message ?? JSON.stringify(error)) + })); + `, + [command, args], + ); + if (!result?.ok) { + throw new Error( + `Tauri command ${command} failed: ${result?.error ?? "unknown error"}`, + ); + } + return result.value; + } + + async invokeError(command, args = {}) { + try { + await this.invoke(command, args); + } catch (error) { + return String(error); + } + throw new Error(`Expected Tauri command ${command} to fail`); + } + + async bodyText() { + return this.execute("return document.body?.innerText ?? '';"); + } + + async html() { + return this.execute("return document.documentElement?.outerHTML ?? '';"); + } + + async visibleTextIncludes(text) { + return this.execute( + ` + const wanted = arguments[0]; + return [...document.querySelectorAll("body *")].some((node) => { + const style = getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && + rect.width > 0 && rect.height > 0 && + (node.innerText ?? "").trim().includes(wanted); + }); + `, + [text], + ); + } + + async waitFor( + check, + { timeoutMs = 20_000, intervalMs = 100, description = "condition" } = {}, + ) { + const started = Date.now(); + let lastError; + while (Date.now() - started < timeoutMs) { + try { + const value = await check(); + if (value) { + return value; + } + } catch (error) { + lastError = error; + } + await sleep(intervalMs); + } + throw new Error( + `Timed out after ${timeoutMs}ms waiting for ${description}${lastError ? `: ${lastError}` : ""}`, + ); + } + + async waitForText(text, timeoutMs = 20_000) { + return this.waitFor(() => this.visibleTextIncludes(text), { + timeoutMs, + description: `visible text ${JSON.stringify(text)}`, + }); + } + + async clickText( + text, + { exact = true, roles = ["button", "tab", "menuitem", "link"] } = {}, + ) { + const element = await this.execute( + ` + const wanted = arguments[0]; + const exact = arguments[1]; + const roles = new Set(arguments[2]); + const candidates = [...document.querySelectorAll("button, a, [role], [data-slot='button']")]; + const visible = (node) => { + const style = getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && + rect.width > 0 && rect.height > 0; + }; + return candidates.find((node) => { + const role = node.getAttribute("role") || (node.tagName === "A" ? "link" : "button"); + const label = (node.getAttribute("aria-label") || node.innerText || node.textContent || "").trim(); + return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted)); + }) ?? null; + `, + [text, exact, roles], + ); + assert.ok( + element, + `No visible interactive element matched ${JSON.stringify(text)}`, + ); + await this.session.click(element); + } + + async clickSelector(selector) { + const element = await this.waitFor( + () => + this.execute( + ` + const node = document.querySelector(arguments[0]); + if (!node) return null; + const style = getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && + rect.width > 0 && rect.height > 0 ? node : null; + `, + [selector], + ), + { description: `visible selector ${selector}` }, + ); + await this.session.click(element); + } + + async fillSelector(selector, value) { + const element = await this.waitFor( + () => + this.execute("return document.querySelector(arguments[0]);", [ + selector, + ]), + { description: `selector ${selector}` }, + ); + await this.session.clear(element); + await this.session.sendKeys(element, value); + } + + async pressShortcut({ + key, + meta = false, + ctrl = false, + alt = false, + shift = false, + }) { + await this.execute( + ` + window.dispatchEvent(new KeyboardEvent("keydown", { + key: arguments[0], + code: arguments[1], + metaKey: arguments[2], + ctrlKey: arguments[3], + altKey: arguments[4], + shiftKey: arguments[5], + bubbles: true, + cancelable: true + })); + `, + [ + key, + key.length === 1 ? `Key${key.toUpperCase()}` : key, + meta, + ctrl, + alt, + shift, + ], + ); + } + + async capture(label) { + if (!this.session) { + return; + } + const safe = label.replace(/[^a-z0-9_.-]+/gi, "-"); + try { + const png = await this.session.screenshot(); + await writeFile( + path.join(this.root, "artifacts", `${safe}.png`), + Buffer.from(png, "base64"), + ); + } catch { + // Best-effort diagnostics must never hide the original test failure. + } + try { + await writeFile( + path.join(this.root, "artifacts", `${safe}.html`), + await this.html(), + ); + } catch { + // Best-effort diagnostics must never hide the original test failure. + } + } + + async close() { + if (!this.session) { + return; + } + const session = this.session; + this.session = null; + await session.close(); + } +} + +export function appFromEnvironment(name, options = {}) { + const runRoot = process.env.DONUT_E2E_RUN_ROOT; + assert.ok(runRoot, "DONUT_E2E_RUN_ROOT is required"); + return new AppSession({ + name, + root: options.root ?? path.join(runRoot, "sessions", name), + application: process.env.DONUT_E2E_APP, + driverUrl: process.env.DONUT_E2E_DRIVER_URL, + cwd: process.env.DONUT_E2E_PROJECT_ROOT, + token: process.env.WAYFERN_TEST_TOKEN, + extraEnv: options.extraEnv, + args: options.args, + seedVersionCache: options.seedVersionCache, + }); +} + +export async function withApp(name, callback, options = {}) { + const app = appFromEnvironment(name, options); + try { + await app.start(); + return await callback(app); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + await app.close(); + } +} diff --git a/e2e/lib/cdp.mjs b/e2e/lib/cdp.mjs new file mode 100644 index 0000000..0b5742a --- /dev/null +++ b/e2e/lib/cdp.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class CdpClient { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (message.id === undefined) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject( + new Error( + `CDP ${pending.method} failed: ${JSON.stringify(message.error)}`, + ), + ); + } else { + pending.resolve(message.result ?? {}); + } + }); + socket.addEventListener("close", () => { + for (const pending of this.pending.values()) { + pending.reject( + new Error(`CDP socket closed while waiting for ${pending.method}`), + ); + } + this.pending.clear(); + }); + } + + static async connect(port, { timeoutMs = 30_000 } = {}) { + assert.equal( + typeof WebSocket, + "function", + "This E2E suite requires Node.js 22+ WebSocket", + ); + const started = Date.now(); + let lastError; + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(`http://127.0.0.1:${port}/json`, { + signal: AbortSignal.timeout(1_000), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const targets = await response.json(); + const target = targets.find( + (item) => item.type === "page" && item.webSocketDebuggerUrl, + ); + if (!target) throw new Error("no debuggable page target"); + const socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("CDP WebSocket open timed out")), + 5_000, + ); + socket.addEventListener( + "open", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timeout); + reject(new Error("CDP WebSocket failed to open")); + }, + { once: true }, + ); + }); + return new CdpClient(socket); + } catch (error) { + lastError = error; + await sleep(100); + } + } + throw new Error( + `Timed out connecting to Wayfern CDP on ${port}: ${lastError}`, + ); + } + + command(method, params = {}) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject, method }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + async evaluate(expression) { + const result = await this.command("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + userGesture: true, + }); + if (result.exceptionDetails) { + throw new Error( + `CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`, + ); + } + return result.result?.value; + } + + async waitFor( + expression, + { timeoutMs = 20_000, description = expression } = {}, + ) { + const started = Date.now(); + let lastError; + while (Date.now() - started < timeoutMs) { + try { + const value = await this.evaluate(expression); + if (value) return value; + } catch (error) { + lastError = error; + } + await sleep(100); + } + throw new Error( + `Timed out waiting for ${description}${lastError ? `: ${lastError}` : ""}`, + ); + } + + close() { + this.socket.close(); + } +} diff --git a/e2e/lib/fixtures.mjs b/e2e/lib/fixtures.mjs new file mode 100644 index 0000000..dae1af3 --- /dev/null +++ b/e2e/lib/fixtures.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { chmod, copyFile, mkdir, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const TEST_BROWSER_VERSION = "150.0.7871.100"; + +export function defaultWayfernPath(projectRoot) { + if (process.env.DONUT_E2E_WAYFERN_PATH) { + return path.resolve(process.env.DONUT_E2E_WAYFERN_PATH); + } + const sibling = path.resolve( + projectRoot, + "../wayfern-test/test_extracted_app", + ); + if (process.platform === "darwin") { + return path.join(sibling, "Wayfern.app"); + } + if (process.platform === "win32") { + return path.join(sibling, "Wayfern.exe"); + } + return path.join(sibling, "wayfern"); +} + +export function wayfernExecutable(bundlePath) { + if (process.platform === "darwin") { + return path.join(bundlePath, "Contents", "MacOS", "Wayfern"); + } + return bundlePath; +} + +export function inspectWayfern(bundlePath) { + const executable = wayfernExecutable(bundlePath); + assert.ok( + existsSync(executable), + `Wayfern executable is missing: ${executable}`, + ); + const output = + process.platform === "darwin" + ? execFileSync( + "/usr/bin/plutil", + [ + "-extract", + "CFBundleShortVersionString", + "raw", + "-o", + "-", + path.join(bundlePath, "Contents", "Info.plist"), + ], + { encoding: "utf8" }, + ).trim() + : execFileSync(executable, ["--version"], { + encoding: "utf8", + timeout: 15_000, + }).trim(); + const match = output.match(/(\d+\.\d+\.\d+\.\d+)/); + assert.ok(match, `Could not parse Wayfern version from: ${output}`); + return { bundlePath, executable, version: match[1], output }; +} + +async function linkOrCopy(source, destination) { + await mkdir(path.dirname(destination), { recursive: true }); + try { + await symlink( + source, + destination, + process.platform === "win32" ? "junction" : undefined, + ); + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + } +} + +export async function seedWayfern(dataRoot, wayfern) { + const installDir = path.join( + dataRoot, + "data", + "binaries", + "wayfern", + wayfern.version, + ); + await mkdir(installDir, { recursive: true }); + if (process.platform === "darwin") { + await linkOrCopy(wayfern.bundlePath, path.join(installDir, "Wayfern.app")); + } else { + const name = process.platform === "win32" ? "wayfern.exe" : "wayfern"; + const destination = path.join(installDir, name); + await copyFile(wayfern.executable, destination); + if (process.platform !== "win32") { + await chmod(destination, 0o755); + } + } + const registry = { + browsers: { + wayfern: { + [wayfern.version]: { + browser: "wayfern", + version: wayfern.version, + file_path: installDir, + }, + }, + }, + }; + const registryPath = path.join( + dataRoot, + "data", + "data", + "downloaded_browsers.json", + ); + await mkdir(path.dirname(registryPath), { recursive: true }); + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`); + return installDir; +} + +export function wireGuardFixture() { + return [ + "[Interface]", + "PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "Address = 10.88.0.2/32", + "DNS = 1.1.1.1", + "", + "[Peer]", + "PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + "Endpoint = 127.0.0.1:51820", + "AllowedIPs = 0.0.0.0/0", + "PersistentKeepalive = 25", + "", + ].join("\n"); +} + +export function extensionZipBase64() { + // A deterministic Manifest V3 ZIP containing only manifest.json. Generated + // once and kept inline so the suite has no archiver dependency. + return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA"; +} + +export function currentHostOs() { + return os.platform() === "darwin" + ? "macos" + : os.platform() === "win32" + ? "windows" + : "linux"; +} diff --git a/e2e/lib/webdriver.mjs b/e2e/lib/webdriver.mjs new file mode 100644 index 0000000..3cc9fcc --- /dev/null +++ b/e2e/lib/webdriver.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; + +export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"; + +function abortAfter(timeoutMs) { + return AbortSignal.timeout(timeoutMs); +} + +export class WebDriverClient { + constructor(baseUrl) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + async request(method, pathname, body, timeoutMs = 330_000) { + const response = await fetch(`${this.baseUrl}${pathname}`, { + method, + headers: + body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: abortAfter(timeoutMs), + }); + const text = await response.text(); + let payload = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + throw new Error( + `WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`, + ); + } + } + const error = payload?.value?.error; + if (!response.ok) { + const message = + payload?.value?.message ?? text ?? `HTTP ${response.status}`; + throw new Error( + `WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`, + ); + } + return payload?.value; + } + + async status() { + return this.request("GET", "/status"); + } + + async createSession({ + application, + args = [], + env = {}, + cwd, + startupTimeout = 90_000, + }) { + const options = { application, args, env, startupTimeout }; + if (cwd) { + options.cwd = cwd; + } + const value = await this.request( + "POST", + "/session", + { + capabilities: { + alwaysMatch: { + "tauri:options": options, + }, + }, + }, + startupTimeout + 10_000, + ); + assert.ok(value?.sessionId, "WebDriver did not return a session id"); + return new WebDriverSession( + this, + value.sessionId, + value.capabilities ?? {}, + ); + } +} + +export class WebDriverSession { + constructor(client, id, capabilities) { + this.client = client; + this.id = id; + this.capabilities = capabilities; + this.closed = false; + } + + path(suffix = "") { + return `/session/${encodeURIComponent(this.id)}${suffix}`; + } + + async command(method, suffix, body, timeoutMs) { + return this.client.request(method, this.path(suffix), body, timeoutMs); + } + + async execute(script, args = []) { + return this.command("POST", "/execute/sync", { script, args }); + } + + async executeAsync(script, args = [], timeoutMs = 330_000) { + return this.command("POST", "/execute/async", { script, args }, timeoutMs); + } + + async setTimeouts({ + implicit = 0, + pageLoad = 300_000, + script = 300_000, + } = {}) { + await this.command("POST", "/timeouts", { implicit, pageLoad, script }); + } + + async find(using, value) { + const element = await this.command("POST", "/element", { using, value }); + assert.ok( + element?.[ELEMENT_KEY], + `Element not found using ${using}: ${value}`, + ); + return element; + } + + async findCss(selector) { + return this.find("css selector", selector); + } + + async findXpath(xpath) { + return this.find("xpath", xpath); + } + + async click(element) { + await this.command( + "POST", + `/element/${encodeURIComponent(element[ELEMENT_KEY])}/click`, + {}, + ); + } + + async sendKeys(element, text) { + const chars = [...String(text)]; + await this.command( + "POST", + `/element/${encodeURIComponent(element[ELEMENT_KEY])}/value`, + { + text: String(text), + value: chars, + }, + ); + } + + async clear(element) { + await this.command( + "POST", + `/element/${encodeURIComponent(element[ELEMENT_KEY])}/clear`, + {}, + ); + } + + async title() { + return this.command("GET", "/title"); + } + + async screenshot() { + return this.command("GET", "/screenshot"); + } + + async close() { + if (this.closed) { + return; + } + this.closed = true; + try { + await this.command("DELETE", ""); + } catch (error) { + if (!String(error).includes("invalid session id")) { + throw error; + } + } + } +} diff --git a/e2e/run.mjs b/e2e/run.mjs new file mode 100644 index 0000000..2e29c85 --- /dev/null +++ b/e2e/run.mjs @@ -0,0 +1,600 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import { + createReadStream, + createWriteStream, + existsSync, + statSync, +} from "node:fs"; +import { chmod, mkdir, mkdtemp, readFile, rename, rm } from "node:fs/promises"; +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(dirname, ".."); +const webdriverRoot = path.resolve( + projectRoot, + "../tauri-cross-platform-webdriver", +); +const isWindows = process.platform === "win32"; +const executableSuffix = isWindows ? ".exe" : ""; +const appBinary = path.join( + projectRoot, + "src-tauri", + "target", + "debug", + `donutbrowser${executableSuffix}`, +); +const driverBinary = path.join( + webdriverRoot, + "target", + "debug", + `tauri-wd${executableSuffix}`, +); + +const suiteFiles = { + smoke: ["smoke.test.mjs", "coverage.test.mjs"], + ui: ["ui.test.mjs"], + entities: ["entities.test.mjs"], + integrations: ["integrations.test.mjs"], + sync: ["sync.test.mjs"], + browser: ["browser.test.mjs"], + full: [ + "coverage.test.mjs", + "smoke.test.mjs", + "ui.test.mjs", + "entities.test.mjs", + "integrations.test.mjs", + "sync.test.mjs", + "browser.test.mjs", + ], +}; + +function parseArgs(argv) { + const options = { + suite: "full", + build: true, + keep: process.env.DONUT_E2E_KEEP_ARTIFACTS === "1", + verbose: process.env.DONUT_E2E_VERBOSE === "1", + }; + for (const arg of argv) { + if (arg.startsWith("--suite=")) { + options.suite = arg.slice("--suite=".length); + } else if (arg === "--no-build") { + options.build = false; + } else if (arg === "--keep") { + options.keep = true; + } else if (arg === "--verbose") { + options.verbose = true; + } else { + throw new Error(`Unknown E2E option: ${arg}`); + } + } + if (!suiteFiles[options.suite]) { + throw new Error( + `Unknown suite ${options.suite}; expected ${Object.keys(suiteFiles).join(", ")}`, + ); + } + return options; +} + +function log(message) { + process.stdout.write(`[donut-e2e] ${message}\n`); +} + +function run(command, args, cwd, env = process.env) { + log(`${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { cwd, env, stdio: "inherit" }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${command} exited with status ${result.status}`); + } +} + +async function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForUrl(url, timeoutMs, processRecord) { + const started = Date.now(); + let lastError; + while (Date.now() - started < timeoutMs) { + if (processRecord?.process.exitCode !== null) { + throw new Error( + `${processRecord.name} exited early with ${processRecord.process.exitCode}; see ${processRecord.logPath}`, + ); + } + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }); + if (response.ok) { + return; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}: ${lastError}`); +} + +function startProcess(name, command, args, { cwd, env, runRoot, verbose }) { + const logPath = path.join(runRoot, "logs", `${name}.log`); + const stream = createWriteStream(logPath, { flags: "a" }); + const child = spawn(command, args, { + cwd, + env, + detached: !isWindows, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.pipe(stream, { end: false }); + child.stderr.pipe(stream, { end: false }); + if (verbose) { + child.stdout.on("data", (chunk) => + process.stdout.write(`[${name}] ${chunk}`), + ); + child.stderr.on("data", (chunk) => + process.stderr.write(`[${name}] ${chunk}`), + ); + } + child.on("error", (error) => { + process.stderr.write(`[donut-e2e] ${name} process error: ${error}\n`); + }); + return { name, process: child, stream, logPath }; +} + +async function stopProcess(record) { + if (!record || record.process.exitCode !== null) { + record?.stream.end(); + return; + } + if (isWindows) { + spawnSync("taskkill", ["/PID", String(record.process.pid), "/T", "/F"], { + stdio: "ignore", + }); + } else { + try { + process.kill(-record.process.pid, "SIGTERM"); + } catch { + // The process group may already be gone. + } + } + await Promise.race([ + new Promise((resolve) => record.process.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + if (record.process.exitCode === null && !isWindows) { + try { + process.kill(-record.process.pid, "SIGKILL"); + } catch { + // The process group may already be gone. + } + } + record.stream.end(); +} + +async function loadLocalToken() { + if (process.env.WAYFERN_TEST_TOKEN) { + return process.env.WAYFERN_TEST_TOKEN; + } + for (const file of [ + path.join(projectRoot, ".env"), + path.resolve(projectRoot, "../wayfern-test/.env"), + ]) { + try { + const content = await readFile(file, "utf8"); + const match = content.match( + /^\s*(?:export\s+)?WAYFERN_TEST_TOKEN\s*=\s*(.+?)\s*$/m, + ); + if (match) { + const raw = match[1].trim(); + return raw.replace(/^(['"])(.*)\1$/, "$2"); + } + } catch { + // A local token is only mandatory for the browser suite. + } + } + return ""; +} + +function buildAll() { + if (!existsSync(webdriverRoot)) { + throw new Error(`Missing sibling webdriver repository: ${webdriverRoot}`); + } + run("pnpm", ["build"], projectRoot); + run("pnpm", ["copy-proxy-binary"], projectRoot); + run( + "cargo", + ["build", "--features", "e2e", "--bin", "donutbrowser"], + path.join(projectRoot, "src-tauri"), + ); + run( + "cargo", + ["build", "--package", "tauri-cross-platform-webdriver"], + webdriverRoot, + ); +} + +function startFixtureServer(geoIpFixture) { + const server = http.createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + if (url.pathname === "/health") { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); + return; + } + if (url.pathname === "/api/echo") { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + response.writeHead(200, { + "content-type": "application/json", + "set-cookie": "donut_e2e=browser-ok; Path=/; SameSite=Lax", + }); + response.end( + JSON.stringify({ + method: request.method, + body: Buffer.concat(chunks).toString("utf8"), + userAgent: request.headers["user-agent"], + }), + ); + }); + return; + } + if (url.pathname.startsWith("/dns/")) { + response.writeHead(200, { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }); + response.end("ads.e2e.invalid\ntracker.e2e.invalid\n"); + return; + } + if (url.pathname === "/geoip.mmdb" && geoIpFixture) { + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-length": String(statSync(geoIpFixture).size), + }); + createReadStream(geoIpFixture).pipe(response); + return; + } + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + response.end(` + + Donut E2E Browser Fixture + +

Donut E2E Browser Fixture

+

${url.pathname}

+ + + + `); + }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + resolve({ server, port: server.address().port }); + }); + }); +} + +async function ensureGeoIpFixture() { + if (process.env.DONUT_E2E_GEOIP_FIXTURE) { + const fixture = path.resolve(process.env.DONUT_E2E_GEOIP_FIXTURE); + if (!existsSync(fixture)) { + throw new Error(`DONUT_E2E_GEOIP_FIXTURE does not exist: ${fixture}`); + } + return fixture; + } + const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools"); + const fixture = path.join(toolsDir, "GeoLite2-City.mmdb"); + await mkdir(toolsDir, { recursive: true }); + if (existsSync(fixture)) return fixture; + + log("Downloading GeoLite City E2E dependency"); + const releases = await fetch( + "https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases", + { + headers: { "user-agent": "donut-browser-e2e" }, + signal: AbortSignal.timeout(30_000), + }, + ).then((response) => { + if (!response.ok) { + throw new Error( + `GeoLite release lookup failed with HTTP ${response.status}`, + ); + } + return response.json(); + }); + const url = releases + .flatMap((release) => release.assets ?? []) + .find((asset) => asset.name.endsWith("-City.mmdb"))?.browser_download_url; + if (!url) throw new Error("No GeoLite City MMDB asset was found"); + const temporary = `${fixture}.${process.pid}.tmp`; + await download(url, temporary); + await rename(temporary, fixture); + return fixture; +} + +function minioUrl() { + const arch = os.arch() === "arm64" ? "arm64" : "amd64"; + if (process.platform === "darwin") { + return `https://dl.min.io/server/minio/release/darwin-${arch}/minio`; + } + if (process.platform === "linux") { + return `https://dl.min.io/server/minio/release/linux-${arch}/minio`; + } + if (process.platform === "win32") { + return "https://dl.min.io/server/minio/release/windows-amd64/minio.exe"; + } + throw new Error( + `Unsupported MinIO platform ${process.platform}-${os.arch()}`, + ); +} + +async function download(url, destination) { + const transport = url.startsWith("https:") ? https : http; + await new Promise((resolve, reject) => { + transport + .get(url, (response) => { + if ([301, 302, 307, 308].includes(response.statusCode)) { + response.resume(); + download( + new URL(response.headers.location, url).href, + destination, + ).then(resolve, reject); + return; + } + if (response.statusCode !== 200) { + response.resume(); + reject( + new Error(`Failed to download ${url}: HTTP ${response.statusCode}`), + ); + return; + } + const output = createWriteStream(destination, { mode: 0o755 }); + pipeline(response, output).then(resolve, reject); + }) + .on("error", reject); + }); +} + +async function ensureMinio() { + if (process.env.DONUT_E2E_MINIO_BIN) { + return path.resolve(process.env.DONUT_E2E_MINIO_BIN); + } + const existingHarnessBinary = path.join( + projectRoot, + ".cache", + "sync-test", + isWindows ? "minio.exe" : "minio", + ); + if (existsSync(existingHarnessBinary)) { + return existingHarnessBinary; + } + const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools"); + const binary = path.join( + toolsDir, + `minio-${process.platform}-${os.arch()}${executableSuffix}`, + ); + await mkdir(toolsDir, { recursive: true }); + if (!existsSync(binary)) { + log("Downloading isolated MinIO test dependency"); + const temporary = `${binary}.${process.pid}.tmp`; + await download(minioUrl(), temporary); + await chmod(temporary, 0o755); + await rm(binary, { force: true }); + await import("node:fs/promises").then(({ rename }) => + rename(temporary, binary), + ); + } + return binary; +} + +async function startSyncInfrastructure(runRoot, options, records) { + const minioBinary = await ensureMinio(); + const minioPort = await freePort(); + const minioConsolePort = await freePort(); + const syncPort = await freePort(); + const syncToken = "donut-e2e-sync-token-0123456789abcdef"; + const minio = startProcess( + "minio", + minioBinary, + [ + "server", + path.join(runRoot, "minio-data"), + "--address", + `127.0.0.1:${minioPort}`, + "--console-address", + `127.0.0.1:${minioConsolePort}`, + ], + { + cwd: projectRoot, + runRoot, + verbose: options.verbose, + env: { + ...process.env, + MINIO_ROOT_USER: "minioadmin", + MINIO_ROOT_PASSWORD: "minioadmin", + MINIO_BROWSER: "off", + }, + }, + ); + records.push(minio); + await waitForUrl( + `http://127.0.0.1:${minioPort}/minio/health/live`, + 30_000, + minio, + ); + + const syncRoot = path.join(projectRoot, "donut-sync"); + await rm(path.join(syncRoot, "tsconfig.build.tsbuildinfo"), { force: true }); + await rm(path.join(syncRoot, "dist"), { recursive: true, force: true }); + run("pnpm", ["build"], syncRoot); + const sync = startProcess("donut-sync", "node", ["dist/main.js"], { + cwd: syncRoot, + runRoot, + verbose: options.verbose, + env: { + ...process.env, + PORT: String(syncPort), + SYNC_TOKEN: syncToken, + S3_ENDPOINT: `http://127.0.0.1:${minioPort}`, + S3_REGION: "us-east-1", + S3_ACCESS_KEY_ID: "minioadmin", + S3_SECRET_ACCESS_KEY: "minioadmin", + S3_BUCKET: `donut-e2e-${process.pid}`, + S3_FORCE_PATH_STYLE: "true", + }, + }); + records.push(sync); + await waitForUrl(`http://127.0.0.1:${syncPort}/health`, 30_000, sync); + return { + minioUrl: `http://127.0.0.1:${minioPort}`, + syncUrl: `http://127.0.0.1:${syncPort}`, + syncToken, + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const runRoot = await mkdtemp(path.join(os.tmpdir(), "donut-e2e-")); + await mkdir(path.join(runRoot, "logs"), { recursive: true }); + const records = []; + let fixture; + let failed = false; + const cleanup = async () => { + await Promise.all(records.reverse().map(stopProcess)); + if (fixture) { + await new Promise((resolve) => fixture.server.close(resolve)); + } + if (!options.keep && !failed) { + await rm(runRoot, { recursive: true, force: true }); + } else { + log(`Artifacts retained at ${runRoot}`); + } + }; + for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + failed = true; + cleanup().finally(() => process.exit(signal === "SIGINT" ? 130 : 143)); + }); + } + + try { + log(`Run root: ${runRoot}`); + if (options.build) { + buildAll(); + } else if (!existsSync(appBinary) || !existsSync(driverBinary)) { + throw new Error( + "--no-build requested but the E2E app or driver binary is missing", + ); + } + + const driverPort = await freePort(); + const driver = startProcess( + "tauri-wd", + driverBinary, + [ + "--port", + String(driverPort), + "--max-sessions", + "4", + "--startup-timeout", + "120", + "--command-timeout", + "330", + "--log", + options.verbose ? "debug" : "info", + ], + { + cwd: webdriverRoot, + env: process.env, + runRoot, + verbose: options.verbose, + }, + ); + records.push(driver); + await waitForUrl(`http://127.0.0.1:${driverPort}/status`, 15_000, driver); + + const needsBrowser = + options.suite === "browser" || options.suite === "full"; + const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null; + fixture = await startFixtureServer(geoIpFixture); + let sync = {}; + if (options.suite === "sync" || options.suite === "full") { + sync = await startSyncInfrastructure(runRoot, options, records); + } + + const token = await loadLocalToken(); + if ((options.suite === "browser" || options.suite === "full") && !token) { + throw new Error("WAYFERN_TEST_TOKEN is required by the browser suite"); + } + + const files = suiteFiles[options.suite].map((file) => + path.join(dirname, "tests", file), + ); + const testArgs = [ + "--test", + "--test-concurrency=1", + "--test-reporter=spec", + ...files, + ]; + const child = spawn(process.execPath, testArgs, { + cwd: projectRoot, + env: { + ...process.env, + DONUT_E2E_RUN_ROOT: runRoot, + DONUT_E2E_PROJECT_ROOT: projectRoot, + DONUT_E2E_WEBDRIVER_ROOT: webdriverRoot, + DONUT_E2E_APP: appBinary, + DONUT_E2E_DRIVER_URL: `http://127.0.0.1:${driverPort}`, + DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`, + DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0", + WAYFERN_TEST_TOKEN: token, + DONUT_E2E_SYNC_URL: sync.syncUrl ?? "", + DONUT_E2E_SYNC_TOKEN: sync.syncToken ?? "", + DONUT_E2E_MINIO_URL: sync.minioUrl ?? "", + }, + stdio: "inherit", + }); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + resolve(code ?? (signal ? 1 : 0)); + }); + }); + if (exitCode !== 0) { + throw new Error( + `E2E suite ${options.suite} failed with status ${exitCode}`, + ); + } + log(`Suite ${options.suite} passed`); + } catch (error) { + failed = true; + process.stderr.write(`[donut-e2e] ERROR: ${error.stack ?? error}\n`); + process.exitCode = 1; + } finally { + await cleanup(); + } +} + +await main(); diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs new file mode 100644 index 0000000..58d0a3e --- /dev/null +++ b/e2e/tests/browser.test.mjs @@ -0,0 +1,450 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { appFromEnvironment } from "../lib/app.mjs"; +import { CdpClient } from "../lib/cdp.mjs"; +import { + defaultWayfernPath, + inspectWayfern, + seedWayfern, +} from "../lib/fixtures.mjs"; + +const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL; + +async function request(url, { method = "GET", token, body } = {}) { + const response = await fetch(url, { + method, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + let value = null; + if (text) { + try { + value = JSON.parse(text); + } catch { + value = text; + } + } + return { response, value }; +} + +async function prepareWayfern(app) { + const localBundle = defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT); + if (existsSync(localBundle)) { + const wayfern = inspectWayfern(localBundle); + await seedWayfern(app.dataRoot, wayfern); + return { version: wayfern.version, source: "local fixture" }; + } + + await app.start(); + const current = await app.invoke("fetch_browser_versions_with_count", { + browserStr: "wayfern", + }); + assert.ok( + current.versions.length > 0, + "No Wayfern build is published for this platform", + ); + const version = current.versions[0]; + await app.invoke("download_browser", { + browserStr: "wayfern", + version, + }); + return { version, source: "published download" }; +} + +function processExists(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitForProcessExit(app, pid) { + await app.waitFor(() => !processExists(pid), { + timeoutMs: 20_000, + description: `Wayfern process ${pid} to exit`, + }); +} + +function assertIdleResourceBounds(pid) { + if (process.platform === "win32") return; + const output = execFileSync("ps", ["-o", "rss=,%cpu=", "-p", String(pid)], { + encoding: "utf8", + }).trim(); + const [rssText, cpuText] = output.split(/\s+/); + const rssKiB = Number(rssText); + const cpuPercent = Number(cpuText); + assert.ok( + rssKiB > 0 && rssKiB < 2_000_000, + `Wayfern main process RSS is ${rssKiB} KiB`, + ); + assert.ok( + cpuPercent >= 0 && cpuPercent < 200, + `Wayfern main process CPU is ${cpuPercent}%`, + ); +} + +function realWayfernTermsPath() { + if (process.platform === "darwin") { + return path.join( + os.homedir(), + "Library", + "Application Support", + "Wayfern", + "license-accepted", + ); + } + if (process.platform === "win32") { + return path.join( + process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), + "Wayfern", + "license-accepted", + ); + } + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), + "Wayfern", + "license-accepted", + ); +} + +async function snapshotFile(file) { + try { + const [contents, metadata] = await Promise.all([ + readFile(file), + stat(file, { bigint: true }), + ]); + return { + exists: true, + contents: contents.toString("base64"), + size: metadata.size.toString(), + mtime: metadata.mtimeNs.toString(), + }; + } catch (error) { + if (error.code === "ENOENT") return { exists: false }; + throw error; + } +} + +async function createRealProfile(app, version, name, fingerprint = null) { + return app.invoke("create_browser_profile_new", { + name, + browserStr: "wayfern", + version, + releaseType: "stable", + proxyId: null, + vpnId: null, + wayfernConfig: { + fingerprint, + randomize_fingerprint_on_launch: false, + geoip: false, + }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); +} + +test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and process cleanup", async () => { + assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); + const realTermsFile = realWayfernTermsPath(); + const realTermsBefore = await snapshotFile(realTermsFile); + const hasLocalWayfern = existsSync( + defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT), + ); + const app = appFromEnvironment("browser-wayfern", { + seedVersionCache: hasLocalWayfern, + }); + let cdp; + let browserPid; + try { + const prepared = await prepareWayfern(app); + if (!app.session) await app.start(); + + assert.equal(await app.invoke("check_wayfern_downloaded"), true); + assert.equal(await app.invoke("check_wayfern_terms_accepted"), false); + await app.invoke("accept_wayfern_terms"); + assert.equal(await app.invoke("check_wayfern_terms_accepted"), true); + assert.ok( + ( + await app.invoke("get_downloaded_browser_versions", { + browserStr: "wayfern", + }) + ).includes(prepared.version), + ); + assert.equal( + await app.invoke("check_browser_exists", { + browserStr: "wayfern", + version: prepared.version, + }), + true, + ); + assert.deepEqual(await app.invoke("check_missing_binaries"), []); + assert.deepEqual(await app.invoke("ensure_all_binaries_exist"), []); + assert.deepEqual(await app.invoke("ensure_active_browsers_downloaded"), []); + assert.deepEqual(await app.invoke("get_supported_browsers"), ["wayfern"]); + assert.equal( + await app.invoke("is_browser_supported_on_platform", { + browserStr: "wayfern", + }), + true, + ); + assert.ok( + ( + await app.invoke("fetch_browser_versions_cached_first", { + browserStr: "wayfern", + }) + ).some((item) => item.version === prepared.version), + ); + assert.ok( + ( + await app.invoke("fetch_browser_versions_with_count_cached_first", { + browserStr: "wayfern", + }) + ).versions.includes(prepared.version), + ); + assert.equal( + (await app.invoke("get_browser_release_types", { browserStr: "wayfern" })) + .stable, + prepared.version, + ); + assert.match( + await app.invokeError("cancel_download", { + browserStr: "wayfern", + version: prepared.version, + }), + /No active download/, + ); + + const sample = await app.invoke("generate_sample_fingerprint", { + browser: "wayfern", + version: prepared.version, + configJson: JSON.stringify({ geoip: false }), + }); + const fingerprint = JSON.parse(sample); + assert.ok( + Object.keys(fingerprint).length >= 10, + "Wayfern returned an incomplete fingerprint", + ); + + const profile = await createRealProfile( + app, + prepared.version, + `Real Wayfern (${prepared.source})`, + ); + assert.ok(profile.wayfern_config.fingerprint); + assert.ok( + Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10, + ); + assert.equal(await app.invoke("check_missing_geoip_database"), true); + assert.equal(await app.invoke("is_geoip_database_available"), false); + await app.invoke("download_geoip_database"); + assert.equal(await app.invoke("is_geoip_database_available"), true); + assert.equal(await app.invoke("check_missing_geoip_database"), false); + await app.invoke("update_wayfern_config", { + profileId: profile.id, + config: profile.wayfern_config, + }); + await app.invoke("match_profile_fingerprint_to_exit", { + profileId: profile.id, + exitIp: "8.8.8.8", + }); + const consistency = await app.invoke( + "check_profile_fingerprint_consistency", + { + profileId: profile.id, + }, + ); + assert.equal(typeof consistency, "object"); + + const directProfile = (await app.invoke("list_browser_profiles")).find( + (item) => item.id === profile.id, + ); + const directLaunch = await app.invoke("launch_browser_profile", { + profile: directProfile, + url: `${fixtureUrl}/direct-command`, + }); + assert.ok(directLaunch.process_id); + await app.invoke("open_url_with_profile", { + profileId: profile.id, + url: `${fixtureUrl}/direct-open`, + }); + await app.invoke("kill_browser_profile", { profile: directLaunch }); + await waitForProcessExit(app, directLaunch.process_id); + + const settings = await app.invoke("get_app_settings"); + const saved = await app.invoke("save_app_settings", { + settings: { + ...settings, + api_enabled: true, + api_port: 0, + api_token: null, + onboarding_completed: true, + }, + }); + const port = await app.invoke("start_api_server", { port: 0 }); + const base = `http://127.0.0.1:${port}`; + const launched = await request(`${base}/v1/profiles/${profile.id}/run`, { + method: "POST", + token: saved.api_token, + body: { url: `${fixtureUrl}/wayfern`, headless: true }, + }); + assert.equal(launched.response.status, 200, JSON.stringify(launched.value)); + assert.equal(launched.value.headless, true); + + cdp = await CdpClient.connect(launched.value.remote_debugging_port); + await cdp.waitFor(`document.title === "Donut E2E Browser Fixture"`, { + description: "fixture page title", + }); + assert.equal( + await cdp.evaluate("document.querySelector('#path').textContent"), + "/wayfern", + ); + assert.equal( + await cdp.evaluate( + "document.querySelector('#fixture-button').click(); document.querySelector('#fixture-button').dataset.clicked", + ), + "yes", + ); + const echo = await cdp.evaluate( + `fetch(${JSON.stringify(`${fixtureUrl}/api/echo`)}, { + method: "POST", + body: "wayfern-cdp-body" + }).then((response) => response.json())`, + ); + assert.equal(echo.method, "POST"); + assert.equal(echo.body, "wayfern-cdp-body"); + assert.ok(echo.userAgent.length > 20); + assert.match(await cdp.evaluate("document.cookie"), /donut_e2e=browser-ok/); + + const runningProfile = (await app.invoke("list_browser_profiles")).find( + (item) => item.id === profile.id, + ); + browserPid = runningProfile.process_id; + assert.equal( + await app.invoke("check_browser_status", { profile: runningProfile }), + true, + ); + assertIdleResourceBounds(browserPid); + if (process.platform !== "win32") { + const command = execFileSync( + "ps", + ["-ww", "-o", "command=", "-p", String(browserPid)], + { + encoding: "utf8", + }, + ); + assert.match( + command, + new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + ); + } + + const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, { + method: "POST", + token: saved.api_token, + body: { url: `${fixtureUrl}/opened-via-api` }, + }); + assert.equal(opened.response.status, 200); + await app.waitFor( + async () => { + const targets = await fetch( + `http://127.0.0.1:${launched.value.remote_debugging_port}/json`, + ).then((response) => response.json()); + return targets.some((target) => target.url.includes("/opened-via-api")); + }, + { timeoutMs: 20_000, description: "API-opened Wayfern target" }, + ); + + const killed = await request(`${base}/v1/profiles/${profile.id}/kill`, { + method: "POST", + token: saved.api_token, + }); + assert.equal(killed.response.status, 204); + cdp.close(); + cdp = null; + await waitForProcessExit(app, browserPid); + const stoppedProfile = (await app.invoke("list_browser_profiles")).find( + (item) => item.id === profile.id, + ); + assert.equal( + await app.invoke("check_browser_status", { profile: stoppedProfile }), + false, + ); + + const batchProfile = await createRealProfile( + app, + prepared.version, + "Wayfern Batch Automation", + sample, + ); + const batchRun = await request(`${base}/v1/profiles/batch/run`, { + method: "POST", + token: saved.api_token, + body: { + profile_ids: [batchProfile.id], + url: `${fixtureUrl}/batch`, + headless: true, + }, + }); + assert.equal(batchRun.response.status, 200); + assert.equal( + batchRun.value.results[0].ok, + true, + batchRun.value.results[0].error, + ); + const batchCdp = await CdpClient.connect( + batchRun.value.results[0].remote_debugging_port, + ); + assert.equal( + await batchCdp.waitFor("window.__fixtureReady === true"), + true, + ); + batchCdp.close(); + const batchStop = await request(`${base}/v1/profiles/batch/stop`, { + method: "POST", + token: saved.api_token, + body: { profile_ids: [batchProfile.id] }, + }); + assert.equal(batchStop.response.status, 200); + assert.equal( + batchStop.value.results[0].ok, + true, + batchStop.value.results[0].error, + ); + + await app.invoke("stop_api_server"); + await app.invoke("delete_profile", { profileId: profile.id }); + await app.invoke("delete_profile", { profileId: batchProfile.id }); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + cdp?.close(); + if (app.session && browserPid && processExists(browserPid)) { + const profile = ( + await app.invoke("list_browser_profiles").catch(() => []) + ).find((item) => item.process_id === browserPid); + if (profile) + await app.invoke("kill_browser_profile", { profile }).catch(() => {}); + } + await app.close(); + assert.deepEqual( + await snapshotFile(realTermsFile), + realTermsBefore, + "the browser suite modified the real Wayfern terms marker", + ); + } +}); diff --git a/e2e/tests/coverage.test.mjs b/e2e/tests/coverage.test.mjs new file mode 100644 index 0000000..bab35a5 --- /dev/null +++ b/e2e/tests/coverage.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import test from "node:test"; +import { allCoveredCommands, commandCoverage } from "../coverage-map.mjs"; +import { WebDriverClient } from "../lib/webdriver.mjs"; + +function registeredCommands(source) { + const match = source.match( + /invoke_handler\(tauri::generate_handler!\[(.*?)\]\)/s, + ); + assert.ok(match, "Could not locate Tauri generate_handler! command registry"); + const withoutComments = match[1].replace(/\/\/[^\n]*/g, ""); + return [ + ...withoutComments.matchAll(/([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*,/g), + ].map((item) => item[1]); +} + +function commandHasExecutableEvidence(source, command) { + const name = command + .split("::") + .at(-1) + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + `(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`, + ).test(source); +} + +test("every Tauri command has exactly one E2E owner and evidence level", async () => { + const root = + process.env.DONUT_E2E_PROJECT_ROOT ?? + path.resolve(import.meta.dirname, "../.."); + const source = await readFile( + path.join(root, "src-tauri", "src", "lib.rs"), + "utf8", + ); + const registered = registeredCommands(source); + const covered = allCoveredCommands(); + assert.deepEqual( + [...new Set(covered)].sort(), + covered.slice().sort(), + "The E2E coverage map contains duplicate command ownership", + ); + assert.deepEqual(covered.slice().sort(), registered.slice().sort()); + + for (const [name, entry] of Object.entries(commandCoverage)) { + assert.ok( + ["integration", "contract", "host-mutating"].includes(entry.level), + name, + ); + assert.ok(entry.commands.length > 0, `${name} has no commands`); + if (entry.level === "host-mutating") { + assert.ok( + entry.reason?.length > 80, + `${name} needs an explicit safety reason`, + ); + continue; + } + + const suiteSource = await readFile( + path.join(root, "e2e", "tests", `${entry.suite}.test.mjs`), + "utf8", + ); + for (const command of entry.commands) { + assert.equal( + commandHasExecutableEvidence(suiteSource, command), + true, + `${command} is assigned to ${entry.suite} but has no executable invoke evidence`, + ); + } + } +}); + +test("WebDriver client preserves application values that contain an error field", async () => { + const server = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ value: { ok: false, error: "application error" } }), + ); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + const client = new WebDriverClient(`http://127.0.0.1:${address.port}`); + assert.deepEqual(await client.request("GET", "/value"), { + ok: false, + error: "application error", + }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs new file mode 100644 index 0000000..1ee1dd8 --- /dev/null +++ b/e2e/tests/entities.test.mjs @@ -0,0 +1,502 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { withApp } from "../lib/app.mjs"; +import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs"; + +async function createProfile(app, name = "Entity Profile") { + return app.invoke("create_browser_profile_new", { + name, + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + // CRUD-focused suites use a deterministic stored fingerprint. The browser + // suite separately exercises real Wayfern fingerprint generation. + wayfernConfig: { fingerprint: "{}" }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); +} + +test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => { + await withApp("entities-core", async (app) => { + await app.invoke("complete_onboarding"); + const group = await app.invoke("create_profile_group", { + name: "Research", + }); + assert.equal(group.name, "Research"); + const renamedGroup = await app.invoke("update_profile_group", { + groupId: group.id, + name: "Research Team", + }); + assert.equal(renamedGroup.name, "Research Team"); + + const duplicateError = await app.invokeError("create_profile_group", { + name: "Research Team", + }); + assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i); + + const proxy = await app.invoke("create_stored_proxy", { + name: "Local Dead Proxy", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 9, + username: "e2e-user", + password: "e2e-pass", + }, + }); + assert.equal(proxy.proxy_settings.password, "e2e-pass"); + const updatedProxy = await app.invoke("update_stored_proxy", { + proxyId: proxy.id, + name: "Updated Proxy", + proxySettings: { + proxy_type: "socks5", + host: "127.0.0.1", + port: 9, + username: null, + password: null, + }, + }); + assert.equal(updatedProxy.name, "Updated Proxy"); + assert.equal(updatedProxy.updated_at >= proxy.updated_at, true); + + const parsed = await app.invoke("parse_txt_proxies", { + content: [ + "http://one.example:8080", + "two.example:1080:user:pass", + "not a proxy", + ].join("\n"), + }); + assert.equal(parsed.length, 3); + assert.ok(parsed.some((result) => result.status === "parsed")); + assert.ok(parsed.some((result) => result.status === "invalid")); + const parsedProxy = parsed.find((result) => result.status === "parsed"); + const { status: _status, ...parsedProxyFields } = parsedProxy; + const parsedImport = await app.invoke("import_proxies_from_parsed", { + parsedProxies: [parsedProxyFields], + namePrefix: "Parsed", + }); + assert.equal(parsedImport.imported_count, 1); + + const validityError = await app.invokeError("check_proxy_validity", { + proxyId: proxy.id, + proxySettings: null, + }); + assert.match(validityError, /Proxy check failed|Could not connect/i); + const cachedValidity = await app.invoke("get_cached_proxy_check", { + proxyId: proxy.id, + }); + assert.ok(cachedValidity === null || cachedValidity.is_valid === false); + + const exported = JSON.parse( + await app.invoke("export_proxies", { format: "json" }), + ); + assert.equal(exported.proxies.length, 2); + assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy")); + assert.ok(exported.proxies.some((item) => item.name === "Parsed Proxy 1")); + const importResult = await app.invoke("import_proxies_json", { + content: JSON.stringify({ + version: "1", + source: "Donut Browser", + exported_at: new Date().toISOString(), + proxies: [ + { + name: "Imported Proxy", + type: "http", + host: "127.0.0.1", + port: 8081, + }, + ], + }), + }); + assert.equal(importResult.imported_count, 1); + + const profile = await createProfile(app); + assert.equal(profile.name, "Entity Profile"); + assert.equal( + ( + await app.invoke("update_profile_proxy", { + profileId: profile.id, + proxyId: proxy.id, + }) + ).proxy_id, + proxy.id, + ); + await app.invoke("assign_profiles_to_group", { + profileIds: [profile.id], + groupId: group.id, + }); + await app.invoke("rename_profile", { + profileId: profile.id, + newName: "Renamed Profile", + }); + await app.invoke("update_profile_tags", { + profileId: profile.id, + tags: ["alpha", "automation"], + }); + await app.invoke("update_profile_note", { + profileId: profile.id, + note: "Extensive E2E metadata", + }); + await app.invoke("update_profile_window_color", { + profileId: profile.id, + windowColor: "#123456", + }); + await app.invoke("update_profile_launch_hook", { + profileId: profile.id, + launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`, + }); + const invalidHook = await app.invokeError("update_profile_launch_hook", { + profileId: profile.id, + launchHook: "file:///etc/passwd", + }); + assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/); + await app.invoke("update_profile_proxy_bypass_rules", { + profileId: profile.id, + rules: ["localhost", "*.internal.example"], + }); + await app.invoke("update_profile_dns_blocklist", { + profileId: profile.id, + dnsBlocklist: "light", + }); + await app.invoke("update_profile_clear_on_close", { + profileId: profile.id, + clearOnClose: true, + }); + + const profiles = await app.invoke("list_browser_profiles"); + const changed = profiles.find((item) => item.id === profile.id); + assert.deepEqual(changed.tags, ["alpha", "automation"]); + assert.equal(changed.note, "Extensive E2E metadata"); + assert.equal(changed.window_color, "#123456"); + assert.equal(changed.group_id, group.id); + assert.deepEqual(changed.proxy_bypass_rules, [ + "localhost", + "*.internal.example", + ]); + assert.equal(changed.dns_blocklist, "light"); + assert.equal(changed.clear_on_close, true); + assert.deepEqual((await app.invoke("get_all_tags")).sort(), [ + "alpha", + "automation", + ]); + + assert.ok(Array.isArray(await app.invoke("detect_existing_profiles"))); + const importRoot = path.join(app.root, "profile-import-fixture"); + const importProfile = path.join(importRoot, "Default"); + await mkdir(importProfile, { recursive: true }); + await writeFile( + path.join(importProfile, "Preferences"), + JSON.stringify({ profile: { name: "Imported fixture" } }), + ); + const scanned = await app.invoke("scan_folder_for_profiles", { + folderPath: importRoot, + }); + assert.equal(scanned.length, 1); + assert.equal(scanned[0].mapped_browser, "wayfern"); + const importBatch = await app.invoke("import_browser_profiles", { + items: [ + { + source_path: scanned[0].path, + browser_type: scanned[0].browser, + new_profile_name: "Imported Profile", + proxy_id: null, + vpn_id: null, + }, + ], + groupId: null, + duplicateStrategy: "rename", + wayfernConfig: null, + }); + assert.equal(importBatch.imported_count + importBatch.failed_count, 1); + const archivePath = path.join(app.root, "profile-import-fixture.zip"); + await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64")); + const archiveScan = await app.invoke("scan_profile_archive", { + archivePath, + }); + assert.ok(Array.isArray(archiveScan.profiles)); + await app.invoke("cleanup_profile_import_scratch", { + extractedDir: archiveScan.extracted_dir, + }); + + const clone = await app.invoke("clone_profile", { + profileId: profile.id, + name: "Cloned Profile", + }); + assert.notEqual(clone.id, profile.id); + assert.equal(clone.name, "Cloned Profile"); + const counts = await app.invoke("get_groups_with_profile_counts"); + assert.equal(counts.find((item) => item.id === group.id).count, 2); + assert.equal((await app.invoke("get_profile_groups")).length, 1); + + await app.invoke("delete_selected_profiles", { + profileIds: [profile.id, clone.id], + }); + assert.deepEqual(await app.invoke("list_browser_profiles"), []); + await app.invoke("delete_profile_group", { groupId: group.id }); + await app.invoke("delete_stored_proxy", { proxyId: proxy.id }); + for (const importedProxy of (await app.invoke("get_stored_proxies")).filter( + (item) => + item.name === "Imported Proxy" || item.name.startsWith("Parsed Proxy"), + )) { + await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id }); + } + }); +}); + +test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => { + await withApp("entities-network-extension", async (app) => { + const profile = await createProfile(app, "Assignment Profile"); + const extension = await app.invoke("add_extension", { + name: "E2E Fixture Extension", + fileName: "fixture.zip", + fileData: [...Buffer.from(extensionZipBase64(), "base64")], + }); + assert.equal(extension.name, "Donut E2E Fixture"); + assert.equal(extension.version, "1.0.0"); + const extensionGroup = await app.invoke("create_extension_group", { + name: "Automation Extensions", + }); + const populated = await app.invoke("add_extension_to_group", { + groupId: extensionGroup.id, + extensionId: extension.id, + }); + assert.deepEqual(populated.extension_ids, [extension.id]); + await app.invoke("assign_extension_group_to_profile", { + profileId: profile.id, + extensionGroupId: extensionGroup.id, + }); + assert.equal( + ( + await app.invoke("get_extension_group_for_profile", { + profileId: profile.id, + }) + ).id, + extensionGroup.id, + ); + const renamed = await app.invoke("update_extension", { + extensionId: extension.id, + name: "Renamed Fixture Extension", + fileName: null, + fileData: null, + }); + assert.equal(renamed.name, "Renamed Fixture Extension"); + assert.equal( + await app.invoke("get_extension_icon", { extensionId: extension.id }), + null, + ); + const changedGroup = await app.invoke("update_extension_group", { + groupId: extensionGroup.id, + name: "Renamed Extension Group", + extensionIds: [extension.id], + }); + assert.equal(changedGroup.name, "Renamed Extension Group"); + assert.equal((await app.invoke("list_extensions")).length, 1); + assert.equal((await app.invoke("list_extension_groups")).length, 1); + await app.invoke("remove_extension_from_group", { + groupId: extensionGroup.id, + extensionId: extension.id, + }); + await app.invoke("assign_extension_group_to_profile", { + profileId: profile.id, + extensionGroupId: null, + }); + await app.invoke("delete_extension_group", { groupId: extensionGroup.id }); + await app.invoke("delete_extension", { extensionId: extension.id }); + + const vpn = await app.invoke("create_vpn_config_manual", { + name: "E2E WireGuard", + vpnType: "WireGuard", + configData: wireGuardFixture(), + }); + assert.equal(vpn.name, "E2E WireGuard"); + assert.equal( + (await app.invoke("get_vpn_config", { vpnId: vpn.id })).id, + vpn.id, + ); + assert.equal((await app.invoke("list_vpn_configs")).length, 1); + const updatedVpn = await app.invoke("update_vpn_config", { + vpnId: vpn.id, + name: "Updated WireGuard", + }); + assert.equal(updatedVpn.name, "Updated WireGuard"); + assert.equal( + (await app.invoke("get_vpn_status", { vpnId: vpn.id })).connected, + false, + ); + assert.equal( + ( + await app.invoke("update_profile_vpn", { + profileId: profile.id, + vpnId: vpn.id, + }) + ).vpn_id, + vpn.id, + ); + assert.deepEqual(await app.invoke("list_active_vpn_connections"), []); + await app.invoke("disconnect_vpn", { vpnId: vpn.id }); + const unknownVpnError = await app.invokeError("check_vpn_validity", { + vpnId: "missing-vpn", + }); + assert.match(unknownVpnError, /not found|Failed to start VPN worker/i); + const importedVpn = await app.invoke("import_vpn_config", { + content: wireGuardFixture(), + filename: "imported.conf", + name: "Imported WireGuard", + }); + assert.equal(importedVpn.success, true); + await app.invoke("delete_vpn_config", { vpnId: importedVpn.vpn_id }); + await app.invoke("delete_vpn_config", { vpnId: vpn.id }); + + const dns = await app.invoke("set_custom_dns_config", { + sources: [`${process.env.DONUT_E2E_FIXTURE_URL}/dns.txt`], + blockDomains: [" Ads.Example.com ", "tracker.example"], + allowDomains: ["safe.example"], + allowlistMode: false, + }); + assert.deepEqual(dns.block_domains, ["ads.example.com", "tracker.example"]); + const textExport = await app.invoke("export_custom_dns_rules", { + format: "txt", + }); + assert.match(textExport, /ads\.example\.com/); + await app.invoke("import_custom_dns_rules", { + format: "txt", + content: "||malware.example^\n@@||allowed.example^\n", + }); + const importedDns = await app.invoke("get_custom_dns_config"); + assert.ok(importedDns.block_domains.includes("malware.example")); + assert.ok(importedDns.allow_domains.includes("allowed.example")); + await app.invoke("refresh_dns_blocklists"); + const blocklistStatus = await app.invoke("get_dns_blocklist_cache_status"); + assert.equal(blocklistStatus.length, 5); + assert.ok( + blocklistStatus.every( + (entry) => entry.is_cached && entry.is_fresh && entry.entry_count === 2, + ), + ); + + await app.invoke("delete_profile", { profileId: profile.id }); + }); +}); + +test("cookie import/copy/export, profile encryption, and traffic-stat read/clear paths", async () => { + await withApp("entities-cookies-password", async (app) => { + const source = await createProfile(app, "Cookie Source"); + const target = await createProfile(app, "Cookie Target"); + const cookieJson = JSON.stringify([ + { + name: "session", + value: "isolated-secret-cookie", + domain: "fixture.local", + path: "/", + secure: false, + httpOnly: true, + sameSite: "lax", + expirationDate: 2_000_000_000, + }, + ]); + const imported = await app.invoke("import_cookies_from_file", { + profileId: source.id, + content: cookieJson, + }); + assert.equal(imported.cookies_imported, 1); + const cookies = await app.invoke("read_profile_cookies", { + profileId: source.id, + }); + assert.equal(cookies.total_count, 1); + assert.equal(cookies.domains[0].cookies[0].value, "isolated-secret-cookie"); + const stats = await app.invoke("get_profile_cookie_stats", { + profileId: source.id, + }); + assert.equal(stats.total_count, 1); + const copied = await app.invoke("copy_profile_cookies", { + request: { + source_profile_id: source.id, + target_profile_ids: [target.id], + selected_cookies: [{ domain: "fixture.local", name: "session" }], + }, + }); + assert.equal(copied[0].cookies_copied, 1); + assert.match( + await app.invoke("export_profile_cookies", { + profileId: target.id, + format: "json", + }), + /isolated-secret-cookie/, + ); + assert.match( + await app.invoke("export_profile_cookies", { + profileId: target.id, + format: "netscape", + }), + /fixture\.local/, + ); + + await app.invoke("set_profile_password", { + profileId: source.id, + password: "correct horse battery staple", + }); + assert.equal( + await app.invoke("is_profile_locked", { profileId: source.id }), + false, + ); + const wrong = await app.invokeError("verify_profile_password", { + profileId: source.id, + password: "wrong password", + }); + assert.match(wrong, /INCORRECT_PASSWORD/); + await app.invoke("verify_profile_password", { + profileId: source.id, + password: "correct horse battery staple", + }); + await app.invoke("change_profile_password", { + profileId: source.id, + oldPassword: "correct horse battery staple", + newPassword: "new correct horse battery staple", + }); + await app.invoke("lock_profile", { profileId: source.id }); + assert.equal( + await app.invoke("is_profile_locked", { profileId: source.id }), + true, + ); + await app.invoke("unlock_profile", { + profileId: source.id, + password: "new correct horse battery staple", + }); + await app.invoke("remove_profile_password", { + profileId: source.id, + password: "new correct horse battery staple", + }); + assert.equal( + await app.invoke("is_profile_locked", { profileId: source.id }), + false, + ); + + assert.deepEqual(await app.invoke("get_all_traffic_snapshots"), []); + assert.equal( + await app.invoke("get_profile_traffic_snapshot", { + profileId: source.id, + }), + null, + ); + assert.equal( + await app.invoke("get_traffic_stats_for_period", { + profileId: source.id, + seconds: 3600, + }), + null, + ); + await app.invoke("clear_profile_traffic_stats", { profileId: source.id }); + await app.invoke("clear_all_traffic_stats"); + + await app.invoke("delete_selected_profiles", { + profileIds: [source.id, target.id], + }); + }); +}); diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs new file mode 100644 index 0000000..ee9c1dc --- /dev/null +++ b/e2e/tests/integrations.test.mjs @@ -0,0 +1,403 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { withApp } from "../lib/app.mjs"; + +async function jsonRequest( + url, + { method = "GET", token, body, headers = {} } = {}, +) { + const response = await fetch(url, { + method, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { "content-type": "application/json" }), + ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + let value = null; + if (text) { + try { + value = JSON.parse(text); + } catch { + value = text; + } + } + return { response, value }; +} + +async function seedTerms(app) { + const home = path.join(app.root, "home"); + const directory = + process.platform === "darwin" + ? path.join(home, "Library", "Application Support", "Wayfern") + : process.platform === "win32" + ? path.join(app.root, "windows", "roaming", "Wayfern") + : path.join(app.root, "xdg", "config", "Wayfern"); + await mkdir(directory, { recursive: true }); + await writeFile( + path.join(directory, "license-accepted"), + String(Math.floor(Date.now() / 1000)), + ); +} + +async function invokeContract(app, command, args = {}) { + try { + return { ok: true, value: await app.invoke(command, args) }; + } catch (error) { + return { ok: false, error: String(error) }; + } +} + +test("authenticated REST API serves its complete OpenAPI contract and CRUD lifecycle", async () => { + await withApp("integrations-rest", async (app) => { + await seedTerms(app); + const settings = await app.invoke("get_app_settings"); + const saved = await app.invoke("save_app_settings", { + settings: { + ...settings, + api_enabled: true, + api_port: 0, + api_token: null, + onboarding_completed: true, + }, + }); + assert.ok(saved.api_token?.length >= 32); + const port = await app.invoke("start_api_server", { port: 0 }); + assert.equal(await app.invoke("get_api_server_status"), port); + const base = `http://127.0.0.1:${port}`; + + const openapi = await jsonRequest(`${base}/openapi.json`); + assert.equal(openapi.response.status, 200); + assert.equal(openapi.value.openapi.startsWith("3."), true); + const paths = Object.keys(openapi.value.paths); + for (const required of [ + "/v1/profiles", + "/v1/profiles/{id}/run", + "/v1/groups", + "/v1/proxies", + "/v1/vpns/{id}/export", + "/v1/extensions", + "/v1/browsers/{browser}/versions", + ]) { + assert.ok(paths.includes(required), `OpenAPI is missing ${required}`); + } + + const unauthorized = await jsonRequest(`${base}/v1/profiles`); + assert.equal(unauthorized.response.status, 401); + const wrongToken = await jsonRequest(`${base}/v1/profiles`, { + token: "wrong", + }); + assert.equal(wrongToken.response.status, 401); + + const groupsInitially = await jsonRequest(`${base}/v1/groups`, { + token: saved.api_token, + }); + assert.equal(groupsInitially.response.status, 200); + assert.deepEqual(groupsInitially.value, []); + const createdGroup = await jsonRequest(`${base}/v1/groups`, { + method: "POST", + token: saved.api_token, + body: { name: "REST Group" }, + }); + assert.equal(createdGroup.response.status, 200); + assert.equal(createdGroup.value.name, "REST Group"); + const groupId = createdGroup.value.id; + const updatedGroup = await jsonRequest(`${base}/v1/groups/${groupId}`, { + method: "PUT", + token: saved.api_token, + body: { name: "REST Group Updated" }, + }); + assert.equal(updatedGroup.value.name, "REST Group Updated"); + + const createdProxy = await jsonRequest(`${base}/v1/proxies`, { + method: "POST", + token: saved.api_token, + body: { + name: "REST Proxy", + proxy_settings: { + proxy_type: "http", + host: "127.0.0.1", + port: 8080, + username: null, + password: null, + }, + }, + }); + assert.equal(createdProxy.response.status, 200); + assert.equal(createdProxy.value.proxy_settings.port, 8080); + const proxyId = createdProxy.value.id; + const fetchedProxy = await jsonRequest(`${base}/v1/proxies/${proxyId}`, { + token: saved.api_token, + }); + assert.equal(fetchedProxy.value.name, "REST Proxy"); + const imported = await jsonRequest(`${base}/v1/proxies/import`, { + method: "POST", + token: saved.api_token, + body: { + format: "txt", + content: "http://127.0.0.1:8081", + name_prefix: "API", + }, + }); + assert.equal(imported.response.status, 200); + assert.equal(imported.value.imported_count, 1); + + const missing = await jsonRequest(`${base}/v1/groups/missing`, { + token: saved.api_token, + }); + assert.equal(missing.response.status, 404); + const invalidProfile = await jsonRequest(`${base}/v1/profiles`, { + method: "POST", + token: saved.api_token, + body: { name: "Bad", browser: "unsupported", version: "latest" }, + }); + assert.equal(invalidProfile.response.status, 400); + + assert.equal( + ( + await jsonRequest(`${base}/v1/proxies/${proxyId}`, { + method: "DELETE", + token: saved.api_token, + }) + ).response.status, + 204, + ); + for (const importedProxy of imported.value.proxies) { + await jsonRequest(`${base}/v1/proxies/${importedProxy.id}`, { + method: "DELETE", + token: saved.api_token, + }); + } + assert.equal( + ( + await jsonRequest(`${base}/v1/groups/${groupId}`, { + method: "DELETE", + token: saved.api_token, + }) + ).response.status, + 204, + ); + await app.invoke("stop_api_server"); + assert.equal(await app.invoke("get_api_server_status"), null); + }); +}); + +test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => { + await withApp("integrations-mcp", async (app) => { + await seedTerms(app); + const port = await app.invoke("start_mcp_server"); + assert.equal(await app.invoke("get_mcp_server_status"), true); + const config = await app.invoke("get_mcp_config"); + assert.equal(config.port, port); + assert.ok(config.token.length >= 32); + const base = `http://127.0.0.1:${port}`; + assert.equal((await fetch(`${base}/health`)).status, 200); + assert.equal( + ( + await jsonRequest(`${base}/mcp`, { + method: "POST", + body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + }) + ).response.status, + 401, + ); + + const initialized = await jsonRequest(`${base}/mcp/${config.token}`, { + method: "POST", + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "donut-e2e", version: "1" }, + }, + }, + }); + assert.equal(initialized.response.status, 200); + assert.equal(initialized.value.result.serverInfo.name, "donut-browser"); + const sessionId = initialized.response.headers.get("mcp-session-id"); + assert.ok(sessionId); + const mcpHeaders = { "mcp-session-id": sessionId }; + const notification = await jsonRequest(`${base}/mcp/${config.token}`, { + method: "POST", + headers: mcpHeaders, + body: { jsonrpc: "2.0", method: "notifications/initialized" }, + }); + assert.equal(notification.response.status, 202); + const tools = await jsonRequest(`${base}/mcp/${config.token}`, { + method: "POST", + headers: mcpHeaders, + body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + }); + assert.equal(tools.response.status, 200); + const names = tools.value.result.tools.map((tool) => tool.name); + for (const name of [ + "list_profiles", + "create_profile", + "run_profile", + "list_proxies", + "get_page_content", + "get_interactive_elements", + ]) { + assert.ok(names.includes(name), `MCP is missing ${name}`); + } + const listed = await jsonRequest(`${base}/mcp/${config.token}`, { + method: "POST", + headers: mcpHeaders, + body: { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "list_profiles", arguments: {} }, + }, + }); + assert.equal(listed.response.status, 200); + assert.equal(listed.value.error, undefined); + assert.ok(listed.value.result); + + const agents = await app.invoke("list_mcp_agents"); + assert.ok(agents.some((agent) => agent.id === "cursor")); + await app.invoke("add_mcp_to_agent", { agentId: "cursor" }); + assert.equal( + (await app.invoke("list_mcp_agents")).find( + (agent) => agent.id === "cursor", + ).connected, + true, + ); + await app.invoke("remove_mcp_from_agent", { agentId: "cursor" }); + assert.equal( + (await app.invoke("list_mcp_agents")).find( + (agent) => agent.id === "cursor", + ).connected, + false, + ); + assert.equal( + ( + await jsonRequest(`${base}/mcp/${config.token}`, { + method: "DELETE", + headers: mcpHeaders, + }) + ).response.status, + 200, + ); + await app.invoke("stop_mcp_server"); + assert.equal(await app.invoke("get_mcp_server_status"), false); + }); +}); + +test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => { + await withApp("integrations-contracts", async (app) => { + assert.equal(await app.invoke("cloud_get_user"), null); + assert.equal(await app.invoke("cloud_get_proxy_usage"), null); + assert.ok(await app.invoke("cloud_get_wayfern_token")); + assert.deepEqual(await app.invoke("get_team_locks"), []); + assert.equal( + await app.invoke("get_team_lock_status", { + profileId: "00000000-0000-0000-0000-000000000000", + }), + null, + ); + assert.deepEqual(await app.invoke("get_sync_sessions"), []); + const startResult = await invokeContract(app, "start_sync_session", { + leaderProfileId: "00000000-0000-0000-0000-000000000001", + followerProfileIds: ["00000000-0000-0000-0000-000000000002"], + }); + assert.equal(startResult.ok, false); + const stopError = await app.invokeError("stop_sync_session", { + sessionId: "missing", + }); + assert.match(stopError, /not found|session/i); + const removeError = await app.invokeError("remove_sync_follower", { + sessionId: "missing", + followerProfileId: "missing", + }); + assert.match(removeError, /not found|session/i); + + assert.equal(await app.invoke("check_for_app_updates"), null); + assert.equal(await app.invoke("check_for_app_updates_manual"), null); + assert.ok( + await invokeContract(app, "cloud_exchange_device_code", { + code: "DONUT-E2E-INVALID-CODE", + }), + ); + assert.ok(await invokeContract(app, "cloud_refresh_profile")); + assert.ok(await invokeContract(app, "cloud_get_countries")); + assert.ok( + await invokeContract(app, "cloud_get_regions", { + country: "ZZ", + }), + ); + assert.ok( + await invokeContract(app, "cloud_get_cities", { + country: "ZZ", + region: null, + }), + ); + assert.ok( + await invokeContract(app, "cloud_get_isps", { + country: "ZZ", + region: null, + city: null, + }), + ); + assert.ok( + await invokeContract(app, "create_cloud_location_proxy", { + name: "E2E unavailable cloud proxy", + country: "ZZ", + region: null, + city: null, + isp: null, + }), + ); + assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token")); + + assert.ok(await invokeContract(app, "trigger_manual_version_update")); + assert.ok(await invokeContract(app, "clear_all_version_cache_and_refetch")); + assert.ok(await invokeContract(app, "check_for_browser_updates")); + await app.invoke("dismiss_update_notification", { + notificationId: "missing-e2e-notification", + }); + assert.deepEqual( + await app.invoke("complete_browser_update_with_auto_update", { + browser: "wayfern", + newVersion: "150.0.7871.100", + }), + [], + ); + const prepareError = await app.invokeError( + "download_and_prepare_app_update", + { + updateInfo: { + current_version: "0.0.0", + new_version: "0.0.1-e2e", + release_notes: "E2E invalid update contract", + download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`, + is_nightly: false, + published_at: "2026-01-01T00:00:00Z", + manual_update_required: false, + release_page_url: null, + repo_update: false, + checksums_url: null, + asset_digest: null, + }, + }, + ); + assert.match(prepareError, /checksum|verif|Failed to download/i); + const versionStatus = await app.invoke("get_version_update_status"); + assert.ok(versionStatus && typeof versionStatus === "object"); + assert.equal(typeof (await app.invoke("is_default_browser")), "boolean"); + + const trial = await app.invoke("get_commercial_trial_status"); + assert.ok(trial && typeof trial === "object"); + await app.invoke("acknowledge_trial_expiration"); + assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true); + await app.invoke("cloud_logout"); + assert.equal(await app.invoke("cloud_get_user"), null); + }); +}); diff --git a/e2e/tests/smoke.test.mjs b/e2e/tests/smoke.test.mjs new file mode 100644 index 0000000..9a110c2 --- /dev/null +++ b/e2e/tests/smoke.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { appFromEnvironment, withApp } from "../lib/app.mjs"; + +test("fresh app renders, completes onboarding, persists settings, and never touches real app roots", async () => { + await withApp("smoke-fresh", async (app) => { + assert.equal(typeof (await app.session.title()), "string"); + assert.match(await app.bodyText(), /New/); + await app.waitForText("No profiles yet"); + + const initial = await app.invoke("get_app_settings"); + assert.equal(typeof initial.onboarding_completed, "boolean"); + await app.invoke("complete_onboarding"); + assert.equal(await app.invoke("get_onboarding_completed"), true); + await app.invoke("dismiss_window_resize_warning"); + assert.equal(await app.invoke("get_window_resize_warning_dismissed"), true); + + const saved = await app.invoke("save_app_settings", { + settings: { + ...initial, + theme: "dark", + language: "en", + onboarding_completed: true, + disable_auto_updates: true, + }, + }); + assert.equal(saved.theme, "dark"); + assert.equal(saved.language, "en"); + + await app.invoke("save_table_sorting_settings", { + sorting: { column: "browser", direction: "desc" }, + }); + assert.deepEqual(await app.invoke("get_table_sorting_settings"), { + column: "browser", + direction: "desc", + }); + assert.ok((await app.invoke("get_system_language")).length >= 2); + const system = await app.invoke("get_system_info"); + assert.ok(system && typeof system === "object"); + assert.equal(typeof (await app.invoke("read_log_files")), "string"); + + await app.restart(); + const afterRestart = await app.invoke("get_app_settings"); + assert.equal(afterRestart.theme, "dark"); + assert.equal(afterRestart.language, "en"); + assert.equal(afterRestart.onboarding_completed, true); + + const settingsFile = path.join( + app.dataRoot, + "data", + "settings", + "app_settings.json", + ); + await access(settingsFile); + const persisted = JSON.parse(await readFile(settingsFile, "utf8")); + assert.equal(persisted.api_token, null); + assert.equal(persisted.mcp_token, null); + }); +}); + +test("two isolated sessions run concurrently and do not share frontend or backend state", async () => { + const first = appFromEnvironment("smoke-isolation-a"); + const second = appFromEnvironment("smoke-isolation-b"); + try { + await Promise.all([first.start(), second.start()]); + const firstSettings = await first.invoke("get_app_settings"); + await first.invoke("save_app_settings", { + settings: { ...firstSettings, theme: "dark", onboarding_completed: true }, + }); + const secondSettings = await second.invoke("get_app_settings"); + assert.equal(secondSettings.theme, "system"); + assert.notEqual(secondSettings.theme, "dark"); + + await first.execute("localStorage.setItem('donut-e2e-only-a', 'yes');"); + assert.equal( + await second.execute("return localStorage.getItem('donut-e2e-only-a');"), + null, + "native WebView data leaked across sessions", + ); + } catch (error) { + await Promise.all([first.capture("failure"), second.capture("failure")]); + throw error; + } finally { + await Promise.all([first.close(), second.close()]); + } +}); + +test("keyboard command palette and major navigation surfaces are operable through native WebDriver", async () => { + await withApp("smoke-ui", async (app) => { + await app.invoke("complete_onboarding"); + const modifier = + process.platform === "darwin" ? { meta: true } : { ctrl: true }; + await app.pressShortcut({ key: "k", ...modifier }); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector("[cmdk-input][placeholder='Type a command or search...']"));`, + ), + { description: "open command palette" }, + ); + + const input = await app.session.findCss("[cmdk-input]"); + await app.session.sendKeys(input, "settings"); + const body = await app.bodyText(); + assert.match(body, /Settings/i); + + // Exercise native WebDriver element marshalling and click, not just script execution. + const close = await app.execute( + `return [...document.querySelectorAll("button")].find( + (button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "") + ) ?? null;`, + ); + if (close) { + await app.session.click(close); + } else { + await app.pressShortcut({ key: "Escape" }); + } + }); +}); + +test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle", async () => { + const app = appFromEnvironment("smoke-lifecycle"); + try { + await app.start(); + await app.invoke("update_tray_menu", { + showLabel: "Show Donut E2E", + quitLabel: "Quit Donut E2E", + }); + await app.invoke("hide_to_tray"); + assert.equal( + typeof (await app.invoke("get_onboarding_completed")), + "boolean", + ); + + await app.restart(); + const exitingSession = app.session; + await app + .execute( + `window.__TAURI_INTERNALS__.invoke("confirm_quit").catch(() => {}); + return true;`, + ) + .catch(() => {}); + await app.waitFor( + async () => { + try { + await exitingSession.title(); + return false; + } catch { + return true; + } + }, + { timeoutMs: 10_000, description: "confirmed app exit" }, + ); + app.session = null; + await exitingSession.close().catch(() => {}); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + await app.close(); + } +}); diff --git a/e2e/tests/sync.test.mjs b/e2e/tests/sync.test.mjs new file mode 100644 index 0000000..2259dd6 --- /dev/null +++ b/e2e/tests/sync.test.mjs @@ -0,0 +1,577 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { appFromEnvironment } from "../lib/app.mjs"; +import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs"; + +const syncUrl = process.env.DONUT_E2E_SYNC_URL; +const syncToken = process.env.DONUT_E2E_SYNC_TOKEN; + +async function syncRequest(endpoint, body) { + const response = await fetch(`${syncUrl}/v1/objects/${endpoint}`, { + method: "POST", + headers: { + authorization: `Bearer ${syncToken}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `Sync ${endpoint} failed with HTTP ${response.status}: ${text}`, + ); + } + return text ? JSON.parse(text) : null; +} + +async function listRemote(prefix = "") { + const result = await syncRequest("list", { + prefix, + maxKeys: 1000, + continuationToken: null, + }); + return result.objects; +} + +async function downloadRemote(key) { + const presigned = await syncRequest("presign-download", { + key, + expiresIn: 300, + }); + const response = await fetch(presigned.url); + assert.equal(response.status, 200, `Could not download remote object ${key}`); + return Buffer.from(await response.arrayBuffer()); +} + +async function configureSync(app) { + const saved = await app.invoke("save_sync_settings", { + syncServerUrl: syncUrl, + syncToken, + }); + assert.equal(saved.sync_server_url, syncUrl); + assert.equal(saved.sync_token, syncToken); + assert.deepEqual(await app.invoke("get_sync_settings"), saved); + await app.invoke("restart_sync_service"); + await new Promise((resolve) => setTimeout(resolve, 750)); +} + +async function createProfile(app, name) { + return app.invoke("create_browser_profile_new", { + name, + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + // Keep sync tests deterministic and network-free; browser.test.mjs covers + // generation through the real Wayfern binary. + wayfernConfig: { fingerprint: "{}" }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); +} + +async function waitFor(app, callback, description, timeoutMs = 45_000) { + return app.waitFor(callback, { description, timeoutMs, intervalMs: 250 }); +} + +test("two real app devices reconcile profile files and every config entity with last-write-wins", async () => { + assert.ok(syncUrl && syncToken, "Sync infrastructure was not started"); + const deviceA = appFromEnvironment("sync-regular-a"); + const deviceB = appFromEnvironment("sync-regular-b"); + try { + await Promise.all([deviceA.start(), deviceB.start()]); + await Promise.all([configureSync(deviceA), configureSync(deviceB)]); + + const group = await deviceA.invoke("create_profile_group", { + name: "Synced Group A", + }); + const proxy = await deviceA.invoke("create_stored_proxy", { + name: "Synced Proxy A", + proxySettings: { + proxy_type: "http", + host: "127.0.0.1", + port: 8089, + username: null, + password: null, + }, + }); + const vpn = await deviceA.invoke("create_vpn_config_manual", { + name: "Synced VPN A", + vpnType: "WireGuard", + configData: wireGuardFixture(), + }); + const extension = await deviceA.invoke("add_extension", { + name: "Synced Extension A", + fileName: "synced-fixture.zip", + fileData: [...Buffer.from(extensionZipBase64(), "base64")], + }); + const extensionGroup = await deviceA.invoke("create_extension_group", { + name: "Synced Extension Group A", + }); + await deviceA.invoke("add_extension_to_group", { + groupId: extensionGroup.id, + extensionId: extension.id, + }); + + await Promise.all([ + deviceA.invoke("set_group_sync_enabled", { + groupId: group.id, + enabled: true, + }), + deviceA.invoke("set_proxy_sync_enabled", { + proxyId: proxy.id, + enabled: true, + }), + deviceA.invoke("set_vpn_sync_enabled", { vpnId: vpn.id, enabled: true }), + deviceA.invoke("set_extension_sync_enabled", { + extensionId: extension.id, + enabled: true, + }), + deviceA.invoke("set_extension_group_sync_enabled", { + extensionGroupId: extensionGroup.id, + enabled: true, + }), + ]); + + const profile = await createProfile(deviceA, "Synced Profile A"); + const profileData = path.join( + deviceA.dataRoot, + "data", + "profiles", + profile.id, + "profile", + "Default", + ); + await mkdir(profileData, { recursive: true }); + await writeFile( + path.join(profileData, "Preferences"), + JSON.stringify({ donutE2E: "regular-profile-payload" }), + ); + await deviceA.invoke("update_profile_tags", { + profileId: profile.id, + tags: ["sync", "device-a"], + }); + await deviceA.invoke("update_profile_note", { + profileId: profile.id, + note: "regular sync metadata", + }); + await deviceA.invoke("set_profile_sync_mode", { + profileId: profile.id, + syncMode: "Regular", + }); + await deviceA.invoke("request_profile_sync", { profileId: profile.id }); + assert.equal( + await deviceA.invoke("cancel_profile_sync", { + profileId: "not-running-sync", + }), + false, + ); + + await waitFor( + deviceA, + async () => { + const keys = (await listRemote("")).map((object) => object.key); + return [ + `groups/${group.id}.json`, + `proxies/${proxy.id}.json`, + `vpns/${vpn.id}.json`, + `extensions/${extension.id}.json`, + `extension_groups/${extensionGroup.id}.json`, + `profiles/${profile.id}/manifest.json`, + `profiles/${profile.id}/files/profile/Default/Preferences`, + ].every((key) => keys.includes(key)); + }, + "all regular entities uploaded", + ); + + await deviceB.invoke("restart_sync_service"); + await waitFor( + deviceB, + async () => { + const [profiles, groups, proxies, vpns, extensions, extensionGroups] = + await Promise.all([ + deviceB.invoke("list_browser_profiles"), + deviceB.invoke("get_profile_groups"), + deviceB.invoke("get_stored_proxies"), + deviceB.invoke("list_vpn_configs"), + deviceB.invoke("list_extensions"), + deviceB.invoke("list_extension_groups"), + ]); + return ( + profiles.some((item) => item.id === profile.id) && + groups.some((item) => item.id === group.id) && + proxies.some((item) => item.id === proxy.id) && + vpns.some((item) => item.id === vpn.id) && + extensions.some((item) => item.id === extension.id) && + extensionGroups.some((item) => item.id === extensionGroup.id) + ); + }, + "device B receives every entity", + ); + const downloadedPreferences = path.join( + deviceB.dataRoot, + "data", + "profiles", + profile.id, + "profile", + "Default", + "Preferences", + ); + await waitFor( + deviceB, + async () => + ( + await readFile(downloadedPreferences, "utf8").catch(() => "") + ).includes("regular-profile-payload"), + "device B receives profile browser files", + ); + + // updated_at has one-second resolution. Make the device-B edits + // unambiguously newer, then verify last-write-wins in both directions. + await new Promise((resolve) => setTimeout(resolve, 1_100)); + await deviceB.invoke("update_stored_proxy", { + proxyId: proxy.id, + name: "Synced Proxy B Wins", + proxySettings: null, + }); + await deviceB.invoke("rename_profile", { + profileId: profile.id, + newName: "Synced Profile B Wins", + }); + await deviceB.invoke("request_profile_sync", { profileId: profile.id }); + await deviceA.invoke("restart_sync_service"); + await waitFor( + deviceA, + async () => { + const proxies = await deviceA.invoke("get_stored_proxies"); + const profiles = await deviceA.invoke("list_browser_profiles"); + return ( + proxies.find((item) => item.id === proxy.id)?.name === + "Synced Proxy B Wins" && + profiles.find((item) => item.id === profile.id)?.name === + "Synced Profile B Wins" + ); + }, + "newer device-B edits win on device A", + ); + + assert.equal( + await deviceA.invoke("is_proxy_in_use_by_synced_profile", { + proxyId: proxy.id, + }), + false, + ); + assert.equal( + await deviceA.invoke("is_group_in_use_by_synced_profile", { + groupId: group.id, + }), + false, + ); + assert.equal( + await deviceA.invoke("is_vpn_in_use_by_synced_profile", { + vpnId: vpn.id, + }), + false, + ); + const counts = await deviceA.invoke("get_unsynced_entity_counts"); + assert.equal(typeof counts.proxies, "number"); + await deviceA.invoke("enable_sync_for_all_entities"); + + await Promise.all([ + deviceB.invoke("delete_extension_group", { + groupId: extensionGroup.id, + }), + deviceB.invoke("delete_extension", { extensionId: extension.id }), + deviceB.invoke("delete_vpn_config", { vpnId: vpn.id }), + deviceB.invoke("delete_profile_group", { groupId: group.id }), + deviceB.invoke("delete_stored_proxy", { proxyId: proxy.id }), + deviceB.invoke("delete_profile", { profileId: profile.id }), + ]); + await waitFor( + deviceB, + async () => { + const keys = (await listRemote("")).map((object) => object.key); + return [ + `tombstones/groups/${group.id}.json`, + `tombstones/proxies/${proxy.id}.json`, + `tombstones/vpns/${vpn.id}.json`, + `tombstones/extensions/${extension.id}.json`, + `tombstones/extension_groups/${extensionGroup.id}.json`, + `tombstones/profiles/${profile.id}.json`, + ].every((key) => keys.includes(key)); + }, + "deletions create every remote tombstone", + ); + await waitFor( + deviceA, + async () => { + const [profiles, groups, proxies, vpns, extensions, extensionGroups] = + await Promise.all([ + deviceA.invoke("list_browser_profiles"), + deviceA.invoke("get_profile_groups"), + deviceA.invoke("get_stored_proxies"), + deviceA.invoke("list_vpn_configs"), + deviceA.invoke("list_extensions"), + deviceA.invoke("list_extension_groups"), + ]); + return ( + !profiles.some((item) => item.id === profile.id) && + !groups.some((item) => item.id === group.id) && + !proxies.some((item) => item.id === proxy.id) && + !vpns.some((item) => item.id === vpn.id) && + !extensions.some((item) => item.id === extension.id) && + !extensionGroups.some((item) => item.id === extensionGroup.id) + ); + }, + "remote tombstones delete every entity from device A", + ); + } catch (error) { + await Promise.all([deviceA.capture("failure"), deviceB.capture("failure")]); + throw error; + } finally { + await Promise.all([deviceA.close(), deviceB.close()]); + } +}); + +test("global config sealing and encrypted profile sync reject a wrong password, round-trip with the right one, and roll over", async () => { + const source = appFromEnvironment("sync-encrypted-source"); + const receiver = appFromEnvironment("sync-encrypted-receiver"); + const rolloverReceiver = appFromEnvironment( + "sync-encrypted-rollover-receiver", + ); + try { + await Promise.all([source.start(), receiver.start()]); + await Promise.all([configureSync(source), configureSync(receiver)]); + await source.invoke("set_e2e_password", { + password: "shared encryption password", + }); + await receiver.invoke("set_e2e_password", { + password: "intentionally wrong password", + }); + assert.equal(await source.invoke("check_has_e2e_password"), true); + assert.equal( + await source.invoke("verify_e2e_password", { + password: "shared encryption password", + }), + true, + ); + assert.equal( + await source.invoke("verify_e2e_password", { password: "wrong" }), + false, + ); + + const sealedProxy = await source.invoke("create_stored_proxy", { + name: "SECRET-CONFIG-MARKER", + proxySettings: { + proxy_type: "http", + host: "secret-proxy.invalid", + port: 8443, + username: "secret-user", + password: "secret-password", + }, + }); + await source.invoke("set_proxy_sync_enabled", { + proxyId: sealedProxy.id, + enabled: true, + }); + + const encryptedProfile = await createProfile(source, "Encrypted Profile"); + const encryptedData = path.join( + source.dataRoot, + "data", + "profiles", + encryptedProfile.id, + "profile", + ); + await mkdir(encryptedData, { recursive: true }); + await writeFile( + path.join(encryptedData, "Local State"), + "SECRET-PROFILE-MARKER that must never appear remotely", + ); + await source.invoke("set_profile_sync_mode", { + profileId: encryptedProfile.id, + syncMode: "Encrypted", + }); + await source.invoke("request_profile_sync", { + profileId: encryptedProfile.id, + }); + + const proxyKey = `proxies/${sealedProxy.id}.json`; + const profileMetadataKey = `profiles/${encryptedProfile.id}/metadata.json`; + const profileFileKey = `profiles/${encryptedProfile.id}/files/profile/Local State`; + await waitFor( + source, + async () => { + const keys = (await listRemote("")).map((object) => object.key); + return ( + keys.includes(proxyKey) && + keys.includes(profileMetadataKey) && + keys.includes(profileFileKey) + ); + }, + "sealed config and encrypted profile uploaded", + ); + const sealedBefore = await downloadRemote(proxyKey); + const metadataBefore = await downloadRemote(profileMetadataKey); + const encryptedFile = await downloadRemote(profileFileKey); + assert.equal( + sealedBefore.includes(Buffer.from("SECRET-CONFIG-MARKER")), + false, + ); + assert.equal(sealedBefore.includes(Buffer.from("secret-password")), false); + assert.equal( + encryptedFile.includes(Buffer.from("SECRET-PROFILE-MARKER")), + false, + ); + const envelope = JSON.parse(sealedBefore.toString("utf8")); + assert.equal(envelope.v, 1); + assert.ok(envelope.salt && envelope.ct); + + await receiver.invoke("restart_sync_service"); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + assert.equal( + (await receiver.invoke("get_stored_proxies")).some( + (item) => item.id === sealedProxy.id, + ), + false, + "wrong password must not materialize sealed config", + ); + assert.equal( + (await receiver.invoke("list_browser_profiles")).some( + (item) => item.id === encryptedProfile.id, + ), + false, + "wrong password must not materialize encrypted profiles", + ); + + await receiver.invoke("set_e2e_password", { + password: "shared encryption password", + }); + await receiver.invoke("restart_sync_service"); + await waitFor( + receiver, + async () => + (await receiver.invoke("get_stored_proxies")).some( + (item) => + item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER", + ) && + (await receiver.invoke("list_browser_profiles")).some( + (item) => item.id === encryptedProfile.id, + ), + "correct password decrypts config and profile metadata", + ); + const receiverFile = path.join( + receiver.dataRoot, + "data", + "profiles", + encryptedProfile.id, + "profile", + "Local State", + ); + await waitFor( + receiver, + async () => + (await readFile(receiverFile, "utf8").catch(() => "")).includes( + "SECRET-PROFILE-MARKER", + ), + "correct password decrypts profile browser file", + ); + + await source.invoke("set_e2e_password", { + password: "rolled encryption password", + }); + await source.invoke("rollover_encryption_for_all_entities"); + await waitFor( + source, + async () => { + const [proxy, metadata] = await Promise.all([ + downloadRemote(proxyKey), + downloadRemote(profileMetadataKey), + ]); + return !proxy.equals(sealedBefore) && !metadata.equals(metadataBefore); + }, + "password rollover rewrites sealed config and profile metadata", + ); + const sealedAfter = await downloadRemote(proxyKey); + assert.equal( + sealedAfter.includes(Buffer.from("SECRET-CONFIG-MARKER")), + false, + ); + await receiver.invoke("set_e2e_password", { + password: "rolled encryption password", + }); + await receiver.invoke("restart_sync_service"); + await waitFor( + receiver, + async () => + (await receiver.invoke("get_stored_proxies")).some( + (item) => + item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER", + ), + "receiver accepts rolled password", + ); + + await rolloverReceiver.start(); + await rolloverReceiver.invoke("set_e2e_password", { + password: "rolled encryption password", + }); + await configureSync(rolloverReceiver); + await waitFor( + rolloverReceiver, + async () => + (await rolloverReceiver.invoke("get_stored_proxies")).some( + (item) => + item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER", + ) && + (await rolloverReceiver.invoke("list_browser_profiles")).some( + (item) => item.id === encryptedProfile.id, + ), + "fresh receiver decrypts rolled config and profile metadata", + ); + const rolloverFile = path.join( + rolloverReceiver.dataRoot, + "data", + "profiles", + encryptedProfile.id, + "profile", + "Local State", + ); + await waitFor( + rolloverReceiver, + async () => + (await readFile(rolloverFile, "utf8").catch(() => "")).includes( + "SECRET-PROFILE-MARKER", + ), + "fresh receiver decrypts rolled profile browser file", + ); + + await source.invoke("set_profile_sync_mode", { + profileId: encryptedProfile.id, + syncMode: "Disabled", + }); + await source.invoke("delete_e2e_password"); + assert.equal(await source.invoke("check_has_e2e_password"), false); + const missingPassword = await source.invokeError("verify_e2e_password", { + password: "rolled encryption password", + }); + assert.match(missingPassword, /NO_E2E_PASSWORD_SET/); + } catch (error) { + await Promise.all([ + source.capture("failure"), + receiver.capture("failure"), + rolloverReceiver.capture("failure"), + ]); + throw error; + } finally { + await Promise.all([ + source.close(), + receiver.close(), + rolloverReceiver.close(), + ]); + } +}); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs new file mode 100644 index 0000000..b311edd --- /dev/null +++ b/e2e/tests/ui.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { withApp } from "../lib/app.mjs"; + +async function dismissSurface(app) { + await app.pressShortcut({ key: "Escape" }); + await new Promise((resolve) => setTimeout(resolve, 100)); +} + +test("all primary navigation buttons and sub-page tabs render and remain interactive", async () => { + await withApp("ui-navigation", async (app) => { + await app.invoke("complete_onboarding"); + await app.restart(); + const surfaces = [ + ["Settings", /General|Appearance|Sync/i], + ["Network", /Proxies|VPNs|DNS/i], + ["Extensions", /Extensions|Groups/i], + ["Integrations", /API|MCP/i], + ["Account", /Account|Sign in/i], + ]; + for (const [label, expected] of surfaces) { + await app.clickSelector(`[aria-label="${label}"]`); + await app.waitFor(async () => expected.test(await app.bodyText()), { + description: `${label} surface`, + }); + assert.match(await app.bodyText(), expected); + await dismissSurface(app); + } + + await app.clickSelector('[aria-label="Groups"]'); + await app.waitForText("Create"); + await dismissSurface(app); + + await app.clickSelector('[aria-label="More"]'); + await app.waitFor( + () => + app.execute(`return Boolean(document.querySelector("[role='menu']"));`), + { description: "More menu" }, + ); + await dismissSurface(app); + + await app.clickSelector('[aria-label="Profiles"]'); + await app.clickText("New"); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector("[role='dialog']"));`, + ), + { description: "new profile dialog" }, + ); + assert.match(await app.bodyText(), /profile/i); + await dismissSurface(app); + }); +}); + +test("settings tabs, command palette filtering, and responsive layout survive resize", async () => { + await withApp("ui-settings-responsive", async (app) => { + await app.invoke("complete_onboarding"); + await app.restart(); + await app.clickSelector('[aria-label="Settings"]'); + await app.waitForText("Appearance"); + for (const tab of ["Appearance", "Sync", "Encryption"]) { + const exists = await app.execute( + `return [...document.querySelectorAll("[role='tab']")].some( + (node) => (node.textContent || "").trim() === arguments[0] + );`, + [tab], + ); + if (exists) { + await app.clickText(tab, { roles: ["tab"] }); + await app.waitFor( + () => + app.execute( + `return [...document.querySelectorAll("[role='tab']")].some( + (node) => (node.textContent || "").trim() === arguments[0] && + node.getAttribute("data-state") === "active" + );`, + [tab], + ), + { description: `${tab} settings tab` }, + ); + } + } + await dismissSurface(app); + + const modifier = + process.platform === "darwin" ? { meta: true } : { ctrl: true }; + await app.pressShortcut({ key: "k", ...modifier }); + await app.waitFor( + () => + app.execute(`return Boolean(document.querySelector("[cmdk-input]"));`), + { description: "command palette" }, + ); + const input = await app.session.findCss("[cmdk-input]"); + await app.session.sendKeys(input, "proxy vpn"); + assert.match(await app.bodyText(), /Network|Proxy|VPN/i); + await dismissSurface(app); + + // The native driver owns the top-level window. Resize through the WebDriver + // protocol and assert the app still has usable controls at the minimum size. + await app.session.command("POST", "/window/rect", { + width: 640, + height: 400, + }); + const viewport = await app.execute( + "return { width: innerWidth, height: innerHeight };", + ); + assert.ok(viewport.width >= 600); + assert.ok(viewport.height >= 350); + assert.equal( + await app.execute( + `return document.querySelector('[aria-label="Settings"]').getBoundingClientRect().width > 0;`, + ), + true, + ); + }); +}); diff --git a/package.json b/package.json index 80f32da..da62f0f 100644 --- a/package.json +++ b/package.json @@ -12,15 +12,22 @@ "test:rust": "cd src-tauri && cargo test", "test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration", "test:sync-e2e": "node scripts/sync-test-harness.mjs", + "e2e": "node e2e/run.mjs --suite=full", + "e2e:smoke": "node e2e/run.mjs --suite=smoke", + "e2e:ui": "node e2e/run.mjs --suite=ui", + "e2e:entities": "node e2e/run.mjs --suite=entities", + "e2e:integrations": "node e2e/run.mjs --suite=integrations", + "e2e:sync": "node e2e/run.mjs --suite=sync", + "e2e:browser": "node e2e/run.mjs --suite=browser", "lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell", - "lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit", + "lint:js": "biome check src/ e2e/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit", "lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", "lint:spell": "typos .", "tauri": "node scripts/run-with-env.mjs tauri", "shadcn:add": "pnpm dlx shadcn@latest add", "prepare": "husky && husky install", "format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", - "format:js": "biome check src/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe", + "format:js": "biome check src/ e2e/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe", "format": "pnpm format:js && pnpm format:rust", "build:sync": "cd donut-sync && pnpm build", "cargo": "cd src-tauri && cargo", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e22738c..3945336 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -442,7 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" dependencies = [ "atk-sys", - "glib", + "glib 0.18.5", "libc", ] @@ -452,10 +452,10 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -912,7 +912,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ "bitflags 2.13.0", "cairo-sys-rs", - "glib", + "glib 0.18.5", "libc", "once_cell", "thiserror 1.0.69", @@ -924,9 +924,9 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" dependencies = [ - "glib-sys", + "glib-sys 0.18.1", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1027,7 +1027,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon 0.13.5", ] [[package]] @@ -1857,6 +1867,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-cross-platform-webdriver", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", @@ -2470,7 +2481,7 @@ dependencies = [ "gdk-pixbuf", "gdk-sys", "gio", - "glib", + "glib 0.18.5", "libc", "pango", ] @@ -2483,7 +2494,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" dependencies = [ "gdk-pixbuf-sys", "gio", - "glib", + "glib 0.18.5", "libc", "once_cell", ] @@ -2494,11 +2505,11 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -2509,13 +2520,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -2525,11 +2536,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" dependencies = [ "gdk-sys", - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -2541,7 +2552,7 @@ dependencies = [ "gdk", "gdkx11-sys", "gio", - "glib", + "glib 0.18.5", "libc", "x11", ] @@ -2553,9 +2564,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" dependencies = [ "gdk-sys", - "glib-sys", + "glib-sys 0.18.1", "libc", - "system-deps", + "system-deps 6.2.2", "x11", ] @@ -2653,8 +2664,8 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "gio-sys", - "glib", + "gio-sys 0.18.1", + "glib 0.18.5", "libc", "once_cell", "pin-project-lite", @@ -2668,13 +2679,26 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", "winapi", ] +[[package]] +name = "gio-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" +dependencies = [ + "glib-sys 0.21.5", + "gobject-sys 0.21.5", + "libc", + "system-deps 7.0.8", + "windows-sys 0.61.2", +] + [[package]] name = "glib" version = "0.18.5" @@ -2687,10 +2711,10 @@ dependencies = [ "futures-executor", "futures-task", "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-macros 0.18.5", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "memchr", "once_cell", @@ -2698,6 +2722,27 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "glib" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys 0.21.5", + "glib-macros 0.21.5", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", + "libc", + "memchr", + "smallvec", +] + [[package]] name = "glib-macros" version = "0.18.5" @@ -2712,6 +2757,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "glib-macros" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" +dependencies = [ + "heck 0.5.0", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "glib-sys" version = "0.18.1" @@ -2719,7 +2777,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" dependencies = [ "libc", - "system-deps", + "system-deps 6.2.2", +] + +[[package]] +name = "glib-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +dependencies = [ + "libc", + "system-deps 7.0.8", ] [[package]] @@ -2747,9 +2815,20 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" dependencies = [ - "glib-sys", + "glib-sys 0.18.1", "libc", - "system-deps", + "system-deps 6.2.2", +] + +[[package]] +name = "gobject-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +dependencies = [ + "glib-sys 0.21.5", + "libc", + "system-deps 7.0.8", ] [[package]] @@ -2765,7 +2844,7 @@ dependencies = [ "gdk", "gdk-pixbuf", "gio", - "glib", + "glib 0.18.5", "gtk-sys", "gtk3-macros", "libc", @@ -2783,12 +2862,12 @@ dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "pango-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3427,7 +3506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" dependencies = [ "bitflags 1.3.2", - "glib", + "glib 0.18.5", "javascriptcore-rs-sys", ] @@ -3437,10 +3516,10 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3596,7 +3675,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" dependencies = [ - "glib", + "glib 0.18.5", "gtk", "gtk-sys", "libappindicator-sys", @@ -4278,6 +4357,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-open-directory" version = "0.3.2" @@ -4301,6 +4390,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -4344,6 +4444,8 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-javascript-core", + "objc2-security", ] [[package]] @@ -4461,7 +4563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" dependencies = [ "gio", - "glib", + "glib 0.18.5", "libc", "once_cell", "pango-sys", @@ -4473,10 +4575,10 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -5290,8 +5392,8 @@ checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ "block2", "dispatch2", - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "gtk-sys", "js-sys", "log", @@ -6149,7 +6251,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" dependencies = [ "futures-channel", "gio", - "glib", + "glib 0.18.5", "libc", "soup3-sys", ] @@ -6160,11 +6262,11 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -6363,13 +6465,26 @@ version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "cfg-expr", + "cfg-expr 0.15.8", "heck 0.5.0", "pkg-config", "toml 0.8.2", "version-compare", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr 0.20.8", + "heck 0.5.0", + "pkg-config", + "toml 1.1.2+spec-1.1.0", + "version-compare", +] + [[package]] name = "tao" version = "0.35.3" @@ -6444,6 +6559,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tauri" version = "2.11.5" @@ -6589,6 +6710,36 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-cross-platform-webdriver" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "block2", + "cairo-rs", + "glib 0.21.5", + "gtk", + "javascriptcore-rs", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-web-kit", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tempfile", + "tokio", + "tracing", + "uuid", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.4.9" @@ -7032,9 +7183,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -7694,9 +7845,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.4" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7964,10 +8115,10 @@ dependencies = [ "gdk", "gdk-sys", "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib 0.18.5", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "gtk", "gtk-sys", "javascriptcore-rs", @@ -7986,15 +8137,15 @@ dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "gtk-sys", "javascriptcore-rs-sys", "libc", "pkg-config", "soup3-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7f58b1b..27729db 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,7 @@ tauri-plugin-macos-permissions = "2" tauri-plugin-log = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-window-state = "2" +tauri-plugin-cross-platform-webdriver = { path = "../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver", optional = true } log = "0.4" env_logger = "0.11" @@ -186,3 +187,4 @@ default = ["custom-protocol"] # this feature is used used for production builds where `devPath` points to the filesystem # DO NOT remove this custom-protocol = ["tauri/custom-protocol"] +e2e = ["dep:tauri-plugin-cross-platform-webdriver"] diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 0869612..a92fc54 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -2037,6 +2037,14 @@ rm "{}" #[tauri::command] pub async fn check_for_app_updates() -> Result, String> { + #[cfg(feature = "e2e")] + if tauri_plugin_cross_platform_webdriver::automation_enabled() + && std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some() + { + log::info!("E2E: skipping automatic app update check"); + return Ok(None); + } + if crate::app_dirs::is_portable() { log::info!("App auto-updates disabled in portable mode"); return Ok(None); @@ -2090,6 +2098,14 @@ pub async fn restart_application() -> Result<(), String> { #[tauri::command] pub async fn check_for_app_updates_manual() -> Result, String> { + #[cfg(feature = "e2e")] + if tauri_plugin_cross_platform_webdriver::automation_enabled() + && std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some() + { + log::info!("E2E: skipping manual app update check"); + return Ok(None); + } + log::info!("Manual app update check triggered"); let updater = AppAutoUpdater::instance(); updater diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 2726bda..8969d40 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -779,6 +779,13 @@ impl CloudAuthManager { /// Launch/drive profiles programmatically (local API + MCP automation). pub async fn can_use_browser_automation(&self) -> bool { + #[cfg(feature = "e2e")] + if crate::e2e_automation_enabled() + && std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty()) + { + return true; + } + self .entitlements() .await @@ -788,6 +795,13 @@ impl CloudAuthManager { /// Edit fingerprints / use a non-native OS fingerprint. pub async fn can_use_cross_os_fingerprints(&self) -> bool { + #[cfg(feature = "e2e")] + if crate::e2e_automation_enabled() + && std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty()) + { + return true; + } + self .entitlements() .await @@ -1224,6 +1238,16 @@ impl CloudAuthManager { /// Get the current wayfern token, if any. pub async fn get_wayfern_token(&self) -> Option { + #[cfg(feature = "e2e")] + if tauri_plugin_cross_platform_webdriver::automation_enabled() { + if let Some(token) = std::env::var_os("WAYFERN_TEST_TOKEN") + .filter(|token| !token.is_empty()) + .and_then(|token| token.into_string().ok()) + { + return Some(token); + } + } + let wt = self.wayfern_token.lock().await; wt.clone() } diff --git a/src-tauri/src/dns_blocklist.rs b/src-tauri/src/dns_blocklist.rs index 28660d4..25e0088 100644 --- a/src-tauri/src/dns_blocklist.rs +++ b/src-tauri/src/dns_blocklist.rs @@ -295,9 +295,23 @@ impl BlocklistManager { } pub async fn fetch_blocklist(level: BlocklistLevel) -> Result { - let url = level + let production_url = level .url() .ok_or_else(|| format!("No URL for level {:?}", level))?; + #[cfg(feature = "e2e")] + let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL") + .ok() + .filter(|base| !base.is_empty()) + .map(|base| { + format!( + "{}/{}", + base.trim_end_matches('/'), + level.filename().unwrap_or("blocklist.txt") + ) + }) + .unwrap_or_else(|| production_url.to_string()); + #[cfg(not(feature = "e2e"))] + let url = production_url.to_string(); let path = Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?; @@ -311,7 +325,7 @@ impl BlocklistManager { ); let response = HTTP_CLIENT - .get(url) + .get(&url) .send() .await .map_err(|e| format!("Failed to fetch blocklist: {e}"))?; diff --git a/src-tauri/src/downloaded_browsers_registry.rs b/src-tauri/src/downloaded_browsers_registry.rs index d21d090..856e719 100644 --- a/src-tauri/src/downloaded_browsers_registry.rs +++ b/src-tauri/src/downloaded_browsers_registry.rs @@ -1261,6 +1261,14 @@ mod tests { pub async fn ensure_active_browsers_downloaded( app_handle: tauri::AppHandle, ) -> Result, String> { + #[cfg(feature = "e2e")] + if tauri_plugin_cross_platform_webdriver::automation_enabled() + && std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some() + { + log::info!("E2E: skipping proactive browser download"); + return Ok(Vec::new()); + } + let registry = DownloadedBrowsersRegistry::instance(); let version_manager = crate::browser_version_manager::BrowserVersionManager::instance(); let mut downloaded = Vec::new(); @@ -1410,6 +1418,14 @@ pub async fn check_missing_binaries() -> Result, S pub async fn ensure_all_binaries_exist( app_handle: tauri::AppHandle, ) -> Result, String> { + #[cfg(feature = "e2e")] + if tauri_plugin_cross_platform_webdriver::automation_enabled() + && std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some() + { + log::info!("E2E: skipping proactive binary and GeoIP downloads"); + return Ok(Vec::new()); + } + let registry = DownloadedBrowsersRegistry::instance(); registry .ensure_all_binaries_exist(&app_handle) diff --git a/src-tauri/src/geoip_downloader.rs b/src-tauri/src/geoip_downloader.rs index 58cc2d0..48771c1 100644 --- a/src-tauri/src/geoip_downloader.rs +++ b/src-tauri/src/geoip_downloader.rs @@ -141,13 +141,22 @@ impl GeoIPDownloader { }, ); - // Fetch latest release from GitHub - let releases = self.fetch_geoip_releases().await?; - let latest_release = releases.first().ok_or("No GeoIP database releases found")?; + #[cfg(feature = "e2e")] + let fixture_url = std::env::var("DONUT_E2E_GEOIP_DOWNLOAD_URL") + .ok() + .filter(|url| !url.is_empty()); + #[cfg(not(feature = "e2e"))] + let fixture_url: Option = None; - let download_url = self - .find_city_mmdb_asset(latest_release) - .ok_or("No compatible GeoIP database asset found")?; + let download_url = if let Some(url) = fixture_url { + url + } else { + let releases = self.fetch_geoip_releases().await?; + let latest_release = releases.first().ok_or("No GeoIP database releases found")?; + self + .find_city_mmdb_asset(latest_release) + .ok_or("No compatible GeoIP database asset found")? + }; // Create cache directory let cache_dir = Self::get_cache_dir(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93609d3..b6c4a4e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use std::env; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; +#[cfg(not(feature = "e2e"))] use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_log::{Target, TargetKind}; @@ -14,6 +15,17 @@ static PENDING_URLS: Mutex> = Mutex::new(Vec::new()); // to the confirmation dialog. static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false); +fn e2e_automation_enabled() -> bool { + #[cfg(feature = "e2e")] + { + tauri_plugin_cross_platform_webdriver::automation_enabled() + } + #[cfg(not(feature = "e2e"))] + { + false + } +} + mod api_client; mod api_server; mod app_auto_updater; @@ -1263,6 +1275,7 @@ fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> { Ok(()) } +#[cfg(not(feature = "e2e"))] fn show_main_window(app_handle: &tauri::AppHandle) { if let Some(window) = app_handle.get_webview_window("main") { let _ = window.show(); @@ -1302,6 +1315,7 @@ fn update_tray_menu( /// Build the system tray. Best-effort: on Linux the tray depends on /// libayatana-appindicator at runtime, so any failure here must not abort app /// startup — the caller logs and continues without a tray. +#[cfg(not(feature = "e2e"))] fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box> { use std::sync::atomic::Ordering; use tauri::menu::{MenuBuilder, MenuItemBuilder}; @@ -1391,54 +1405,65 @@ pub fn run() { }), }; - tauri::Builder::default() - .plugin( - tauri_plugin_log::Builder::new() - .clear_targets() // Clear default targets to avoid duplicates - .target(Target::new(TargetKind::Stdout)) - .target(Target::new(TargetKind::Webview)) - .target(file_log_target) - // 5 MB per rotated file × KeepAll — the previous 100 KB limit - // truncated useful context in customer support reports; 50 MB - // turned out to be excessive disk pressure. - .max_file_size(5 * 1024 * 1024) - .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) - .level(log::LevelFilter::Info) - .format(|out, message, record| { - use chrono::Local; - let now = Local::now(); - let timestamp = format!( - "{}.{:03}", - now.format("%Y-%m-%d %H:%M:%S"), - now.timestamp_subsec_millis() - ); - out.finish(format_args!( - "[{}][{}][{}] {}", - timestamp, - record.target(), - record.level(), - message - )) - }) - .build(), - ) - .plugin(tauri_plugin_single_instance::init( - |app_handle, args, _cwd| { - log::info!("Single instance triggered with args: {args:?}"); - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - let _ = window.unminimize(); - } - }, - )) + let builder = tauri::Builder::default(); + + #[cfg(feature = "e2e")] + let builder = builder.plugin(tauri_plugin_cross_platform_webdriver::init()); + + let builder = builder.plugin( + tauri_plugin_log::Builder::new() + .clear_targets() // Clear default targets to avoid duplicates + .target(Target::new(TargetKind::Stdout)) + .target(Target::new(TargetKind::Webview)) + .target(file_log_target) + // 5 MB per rotated file × KeepAll — the previous 100 KB limit + // truncated useful context in customer support reports; 50 MB + // turned out to be excessive disk pressure. + .max_file_size(5 * 1024 * 1024) + .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) + .level(log::LevelFilter::Info) + .format(|out, message, record| { + use chrono::Local; + let now = Local::now(); + let timestamp = format!( + "{}.{:03}", + now.format("%Y-%m-%d %H:%M:%S"), + now.timestamp_subsec_millis() + ); + out.finish(format_args!( + "[{}][{}][{}] {}", + timestamp, + record.target(), + record.level(), + message + )) + }) + .build(), + ); + + #[cfg(not(feature = "e2e"))] + let builder = builder.plugin(tauri_plugin_single_instance::init( + |app_handle, args, _cwd| { + log::info!("Single instance triggered with args: {args:?}"); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.unminimize(); + } + }, + )); + + let builder = builder .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_macos_permissions::init()) - .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_clipboard_manager::init()); + + #[cfg(not(feature = "e2e"))] + let builder = builder // Persist window size/position across restarts. VISIBLE is excluded // because the app hides to tray: restoring visibility would otherwise // relaunch with an invisible window after quitting from the tray while @@ -1453,8 +1478,9 @@ pub fn run() { & !tauri_plugin_window_state::StateFlags::FULLSCREEN, ) .build(), - ) - .setup(|app| { + ); + + builder.setup(|app| { // Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk) ephemeral_dirs::recover_ephemeral_dirs(); @@ -1476,6 +1502,20 @@ pub fn run() { .focused(true) .visible(true); + #[cfg(feature = "e2e")] + let win_builder = + match tauri_plugin_cross_platform_webdriver::automation_profile_dir() { + Some(profile_dir) => win_builder + .data_directory(profile_dir.join("webview")) + // WKWebView ignores data_directory on macOS. Incognito gives every + // launched app process a non-persistent data store there, and also + // prevents WebView2/WebKitGTK caches from escaping the session on + // the other platforms. Durable app state is still exercised via + // DONUTBROWSER_DATA_ROOT; only browser-engine storage is ephemeral. + .incognito(true), + None => win_builder, + }; + #[cfg(target_os = "windows")] let win_builder = win_builder.decorations(false); @@ -1486,8 +1526,11 @@ pub fn run() { // dialog's "Minimize" action hides the window. Best-effort: a tray // failure (e.g. missing libayatana-appindicator on Linux) must never // prevent the app from launching, so we log and continue without it. - if let Err(e) = setup_system_tray(app.handle()) { - log::warn!("System tray unavailable, continuing without it: {e}"); + #[cfg(not(feature = "e2e"))] + { + if let Err(e) = setup_system_tray(app.handle()) { + log::warn!("System tray unavailable, continuing without it: {e}"); + } } // Intercept the window close so the frontend can ask the user whether @@ -1530,7 +1573,7 @@ pub fn run() { log::warn!("Failed to set global event emitter: {e}"); } - #[cfg(windows)] + #[cfg(all(windows, not(feature = "e2e")))] { // For Windows, register all deep links at runtime if let Err(e) = app.deep_link().register_all() { @@ -1538,7 +1581,7 @@ pub fn run() { } } - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", not(feature = "e2e")))] { // On macOS, try to register deep links for development builds if let Err(e) = app.deep_link().register_all() { @@ -1548,28 +1591,28 @@ pub fn run() { } } - app.deep_link().on_open_url({ - let handle = handle.clone(); - move |event| { - let urls = event.urls(); - log::info!("Deep link event received with {} URLs", urls.len()); + #[cfg(not(feature = "e2e"))] + { + app.deep_link().on_open_url({ + let handle = handle.clone(); + move |event| { + let urls = event.urls(); + log::info!("Deep link event received with {} URLs", urls.len()); - for url in urls { - let url_string = url.to_string(); - log::info!("Deep link received: {url_string}"); + for url in urls { + let url_string = url.to_string(); + log::info!("Deep link received: {url_string}"); + let handle_clone = handle.clone(); - // Clone the handle for each async task - let handle_clone = handle.clone(); - - // Handle the URL asynchronously - tauri::async_runtime::spawn(async move { - if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await { - log::error!("Failed to handle deep link URL: {e}"); - } - }); + tauri::async_runtime::spawn(async move { + if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await { + log::error!("Failed to handle deep link URL: {e}"); + } + }); + } } - } - }); + }); + } if let Some(startup_url) = startup_url { let handle_clone = handle.clone(); @@ -1581,30 +1624,29 @@ pub fn run() { }); } - // Initialize and start background version updater - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - let version_updater = get_version_updater(); + if !e2e_automation_enabled() { + // Initialize and start background version updater + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let version_updater = get_version_updater(); - // Set the app handle - { - let mut updater_guard = version_updater.lock().await; - updater_guard.set_app_handle(app_handle); - } - - // Run startup check without holding the lock - { - let updater_guard = version_updater.lock().await; - if let Err(e) = updater_guard.start_background_updates().await { - log::error!("Failed to start background updates: {e}"); + { + let mut updater_guard = version_updater.lock().await; + updater_guard.set_app_handle(app_handle); } - } - }); - // Start the background update task separately - tauri::async_runtime::spawn(async move { - version_updater::VersionUpdater::run_background_task().await; - }); + { + let updater_guard = version_updater.lock().await; + if let Err(e) = updater_guard.start_background_updates().await { + log::error!("Failed to start background updates: {e}"); + } + } + }); + + tauri::async_runtime::spawn(async move { + version_updater::VersionUpdater::run_background_task().await; + }); + } // Auto-start MCP server if it was previously enabled. Always log the // decision so customer logs reveal whether MCP is actually running — @@ -1765,12 +1807,12 @@ pub fn run() { } } - let app_handle_auto_updater = app.handle().clone(); - - // Start the auto-update check task separately - tauri::async_runtime::spawn(async move { - auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await; - }); + if !e2e_automation_enabled() { + let app_handle_auto_updater = app.handle().clone(); + tauri::async_runtime::spawn(async move { + auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await; + }); + } // Handle any pending URLs that were received before the window was ready let handle_pending = handle.clone(); @@ -1793,107 +1835,85 @@ pub fn run() { } }); - // Start periodic cleanup task for unused binaries - // Only runs when sync is not in progress to avoid deleting browsers - // that might be needed for profiles being synced from the cloud - tauri::async_runtime::spawn(async move { - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours - - loop { - interval.tick().await; - - // Check if sync is in progress before running cleanup - if let Some(scheduler) = sync::get_global_scheduler() { - if scheduler.is_sync_in_progress().await { - log::debug!("Skipping cleanup: sync is in progress"); - continue; - } - } - - let registry = - crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); - if let Err(e) = registry.cleanup_unused_binaries() { - log::error!("Periodic cleanup failed: {e}"); - } else { - log::debug!("Periodic cleanup completed successfully"); - } - } - }); - - // DNS blocklist refresh task (every 12 hours) - tauri::async_runtime::spawn(async move { - let manager = dns_blocklist::BlocklistManager::instance(); - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); - interval.tick().await; // Skip the immediate first tick - loop { - interval.tick().await; - manager.refresh_all_stale().await; - } - }); - - tauri::async_runtime::spawn(async move { - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60)); - - loop { - interval.tick().await; - - log::info!("Checking for app updates..."); - // Route through check_for_app_updates (not the raw check_for_updates) - // so the background loop respects portable mode and the - // disable_auto_updates setting. Previously it bypassed both, so a - // portable install would auto-download and run the NSIS installer, - // clobbering the portable folder instead of updating in place. - match app_auto_updater::check_for_app_updates().await { - Ok(Some(update_info)) => { - log::info!( - "App update available: {} -> {}", - update_info.current_version, - update_info.new_version - ); - if let Err(e) = events::emit("app-update-available", &update_info) { - log::error!("Failed to emit app update event: {e}"); + if !e2e_automation_enabled() { + // Start periodic cleanup task for unused binaries. + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); + loop { + interval.tick().await; + if let Some(scheduler) = sync::get_global_scheduler() { + if scheduler.is_sync_in_progress().await { + log::debug!("Skipping cleanup: sync is in progress"); + continue; } } - Ok(None) => { - log::debug!("No app updates available"); - } - Err(e) => { - log::error!("Failed to check for app updates: {e}"); - } - } - } - }); - // Check and download GeoIP database at startup if needed - let app_handle_geoip = app.handle().clone(); - tauri::async_runtime::spawn(async move { - // Wait a bit for the app to fully initialize - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - - let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance(); - match geoip_downloader.check_missing_geoip_database() { - Ok(true) => { - log::info!( - "GeoIP database is missing for Wayfern profiles, downloading at startup..." - ); - let geoip_downloader = GeoIPDownloader::instance(); - if let Err(e) = geoip_downloader - .download_geoip_database(&app_handle_geoip) - .await - { - log::error!("Failed to download GeoIP database at startup: {e}"); + let registry = + crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); + if let Err(e) = registry.cleanup_unused_binaries() { + log::error!("Periodic cleanup failed: {e}"); } else { - log::info!("GeoIP database downloaded successfully at startup"); + log::debug!("Periodic cleanup completed successfully"); } } - Ok(false) => { - // No Wayfern profiles or GeoIP database already available + }); + + tauri::async_runtime::spawn(async move { + let manager = dns_blocklist::BlocklistManager::instance(); + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); + interval.tick().await; + loop { + interval.tick().await; + manager.refresh_all_stale().await; } - Err(e) => { - log::error!("Failed to check GeoIP database status at startup: {e}"); + }); + + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60)); + loop { + interval.tick().await; + log::info!("Checking for app updates..."); + match app_auto_updater::check_for_app_updates().await { + Ok(Some(update_info)) => { + log::info!( + "App update available: {} -> {}", + update_info.current_version, + update_info.new_version + ); + if let Err(e) = events::emit("app-update-available", &update_info) { + log::error!("Failed to emit app update event: {e}"); + } + } + Ok(None) => log::debug!("No app updates available"), + Err(e) => log::error!("Failed to check for app updates: {e}"), + } } - } - }); + }); + + let app_handle_geoip = app.handle().clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance(); + match geoip_downloader.check_missing_geoip_database() { + Ok(true) => { + log::info!( + "GeoIP database is missing for Wayfern profiles, downloading at startup..." + ); + let geoip_downloader = GeoIPDownloader::instance(); + if let Err(e) = geoip_downloader + .download_geoip_database(&app_handle_geoip) + .await + { + log::error!("Failed to download GeoIP database at startup: {e}"); + } else { + log::info!("GeoIP database downloaded successfully at startup"); + } + } + Ok(false) => {} + Err(e) => log::error!("Failed to check GeoIP database status at startup: {e}"), + } + }); + } // Start proxy cleanup task for dead browser processes let app_handle_proxy_cleanup = app.handle().clone(); diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 92e91bf..cd155b3 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -492,6 +492,9 @@ impl SyncEngine { return Ok(()); } + let reconciled_profile = self.reconcile_profile_metadata(profile).await?; + let profile = &reconciled_profile; + // Derive encryption key if encrypted sync let encryption_key = if profile.is_encrypted_sync() { let password = encryption::load_e2e_password() @@ -697,11 +700,6 @@ impl SyncEngine { log::debug!("Deleted remote file: {}", path); } - // Upload metadata.json (sanitized profile) - self - .upload_profile_metadata(&profile_id, profile, &key_prefix) - .await?; - // If this sync changed the local profile directory (downloaded files and/or // deleted local files), the manifest generated at the START of the sync is // now stale. Uploading it would advertise wrong hashes/mtimes for the files @@ -747,37 +745,18 @@ impl SyncEngine { let _ = self.sync_vpn(vpn_id, Some(app_handle)).await; } - // Download remote metadata and merge changes (name, tags, notes, etc.) - let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); - if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await { - let mut updated_profile = profile.clone(); - // Merge fields that can be changed on other devices - updated_profile.name = remote_meta.name; - updated_profile.tags = remote_meta.tags; - updated_profile.note = remote_meta.note; - updated_profile.proxy_id = remote_meta.proxy_id; - updated_profile.vpn_id = remote_meta.vpn_id; - updated_profile.group_id = remote_meta.group_id; - updated_profile.extension_group_id = remote_meta.extension_group_id; - updated_profile.window_color = remote_meta.window_color; - updated_profile.last_sync = Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - let _ = profile_manager.save_profile(&updated_profile); - } else { - // Fallback: just update last_sync - let mut updated_profile = profile.clone(); - updated_profile.last_sync = Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - let _ = profile_manager.save_profile(&updated_profile); - } + let mut updated_profile = profile.clone(); + updated_profile.last_sync = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + profile_manager + .save_profile(&updated_profile) + .map_err(|e| { + SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}")) + })?; let _ = events::emit("profiles-changed", ()); let _ = events::emit( @@ -881,6 +860,45 @@ impl SyncEngine { Ok(profile) } + async fn reconcile_profile_metadata( + &self, + profile: &BrowserProfile, + ) -> SyncResult { + let profile_id = profile.id.to_string(); + let key_prefix = Self::get_team_key_prefix(profile).await; + let remote_key = format!("{key_prefix}profiles/{profile_id}/metadata.json"); + let stat = self.client.stat(&remote_key).await?; + + if !stat.exists { + self + .upload_profile_metadata(&profile_id, profile, &key_prefix) + .await?; + return Ok(profile.clone()); + } + + let local_updated = profile.updated_at.unwrap_or(0); + let remote_updated = self.remote_updated_at(&stat, &remote_key).await; + if local_updated > remote_updated { + self + .upload_profile_metadata(&profile_id, profile, &key_prefix) + .await?; + return Ok(profile.clone()); + } + if remote_updated <= local_updated { + return Ok(profile.clone()); + } + + let mut remote = self.download_profile_metadata(&remote_key).await?; + // Process state is device-local and deliberately stripped from uploads. + remote.process_id = profile.process_id; + remote.last_launch = profile.last_launch; + remote.last_sync = profile.last_sync; + ProfileManager::instance() + .save_profile(&remote) + .map_err(|e| SyncError::IoError(format!("Failed to save remote profile metadata: {e}")))?; + Ok(remote) + } + /// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups). /// No browser files are synced. async fn sync_cross_os_metadata( @@ -889,34 +907,8 @@ impl SyncEngine { profile: &BrowserProfile, ) -> SyncResult<()> { let profile_id = profile.id.to_string(); - let key_prefix = Self::get_team_key_prefix(profile).await; - let profile_manager = ProfileManager::instance(); - - // Upload our metadata - self - .upload_profile_metadata(&profile_id, profile, &key_prefix) - .await?; - - // Download remote metadata and merge if remote has changes - let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); - if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await { - let mut updated = profile.clone(); - updated.name = remote_meta.name; - updated.tags = remote_meta.tags; - updated.note = remote_meta.note; - updated.proxy_id = remote_meta.proxy_id; - updated.vpn_id = remote_meta.vpn_id; - updated.group_id = remote_meta.group_id; - updated.extension_group_id = remote_meta.extension_group_id; - updated.window_color = remote_meta.window_color; - updated.last_sync = Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - let _ = profile_manager.save_profile(&updated); - } + let reconciled_profile = self.reconcile_profile_metadata(profile).await?; + let profile = &reconciled_profile; // Sync associated entities if let Some(proxy_id) = &profile.proxy_id { @@ -954,18 +946,9 @@ impl SyncEngine { let json = serde_json::to_string_pretty(&sanitized) .map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?; - let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes()) - .map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?; - let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); - let presign = self - .client - .presign_upload(&remote_key, Some(content_type)) - .await?; - self - .client - .upload_bytes(&presign.url, &payload, Some(content_type)) + .upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0)) .await?; Ok(()) @@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities( ) -> Result<(), String> { let _ = events::emit("e2e-rollover-started", ()); + let internal_error = |detail: String| { + serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": detail } }).to_string() + }; + let engine = SyncEngine::create_from_settings(&app_handle) + .await + .map_err(&internal_error)?; let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() - .map_err(|e| format!("Failed to list profiles: {e}"))?; + .map_err(|e| internal_error(format!("Failed to list profiles: {e}")))?; let synced_profiles: Vec<_> = profiles .iter() .filter(|p| p.sync_mode != SyncMode::Disabled) .collect(); - let total_profiles = synced_profiles.len(); - let mut running_profile_ids: std::collections::HashSet = - std::collections::HashSet::new(); + if synced_profiles + .iter() + .any(|profile| profile.process_id.is_some()) + { + return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string()); + } + let total_profiles = synced_profiles.len(); for (i, profile) in synced_profiles.iter().enumerate() { - if profile.process_id.is_some() { - running_profile_ids.insert(profile.id); - } let id_str = profile.id.to_string(); - if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await { - log::warn!("Rollover: profile {} re-sync failed: {e}", id_str); - } + // The remote manifest may be encrypted with the previous password. Delete + // only that manifest so the normal sync path treats every local file as an + // upload and rewrites it with the current password. Existing remote files + // remain available until their replacements have uploaded. + let key_prefix = SyncEngine::get_team_key_prefix(profile).await; + engine + .upload_profile_metadata(&id_str, profile, &key_prefix) + .await + .map_err(|e| { + internal_error(format!( + "Failed to roll over profile metadata {id_str}: {e}" + )) + })?; + let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json"); + engine + .client + .delete(&manifest_key, None) + .await + .map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?; + engine + .sync_profile(&app_handle, profile) + .await + .map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?; let _ = events::emit( "e2e-rollover-progress", serde_json::json!({ @@ -4055,37 +4065,14 @@ pub async fn rollover_encryption_for_all_entities( ); } - // Determine which entity ids are referenced by running profiles, so we can - // defer their re-upload (changing their files mid-session would cause the - // running browser to see a different proxy/extension config than what it - // launched with). - let mut deferred_proxy_ids: std::collections::HashSet = std::collections::HashSet::new(); - let mut deferred_vpn_ids: std::collections::HashSet = std::collections::HashSet::new(); - let mut deferred_group_ids: std::collections::HashSet = std::collections::HashSet::new(); - for p in &profiles { - if running_profile_ids.contains(&p.id) { - if let Some(id) = &p.proxy_id { - deferred_proxy_ids.insert(id.clone()); - } - if let Some(id) = &p.vpn_id { - deferred_vpn_ids.insert(id.clone()); - } - if let Some(id) = &p.group_id { - deferred_group_ids.insert(id.clone()); - } - } - } - let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies(); let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect(); let total_proxies = synced_proxies.len(); - let mut deferred = Vec::new(); for (i, proxy) in synced_proxies.iter().enumerate() { - if deferred_proxy_ids.contains(&proxy.id) { - deferred.push(proxy.id.clone()); - } else if let Some(scheduler) = super::get_global_scheduler() { - scheduler.queue_proxy_sync(proxy.id.clone()).await; - } + engine + .upload_proxy(proxy) + .await + .map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?; let _ = events::emit( "e2e-rollover-progress", serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}), @@ -4095,17 +4082,15 @@ pub async fn rollover_encryption_for_all_entities( let groups = { let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap(); gm.get_all_groups() - .map_err(|e| format!("Failed to get groups: {e}"))? + .map_err(|e| internal_error(format!("Failed to get groups: {e}")))? }; let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect(); let total_groups = synced_groups.len(); - let mut deferred_groups = Vec::new(); for (i, group) in synced_groups.iter().enumerate() { - if deferred_group_ids.contains(&group.id) { - deferred_groups.push(group.id.clone()); - } else if let Some(scheduler) = super::get_global_scheduler() { - scheduler.queue_group_sync(group.id.clone()).await; - } + engine + .upload_group(group) + .await + .map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?; let _ = events::emit( "e2e-rollover-progress", serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}), @@ -4116,17 +4101,15 @@ pub async fn rollover_encryption_for_all_entities( let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); storage .list_configs() - .map_err(|e| format!("Failed to list VPN configs: {e}"))? + .map_err(|e| internal_error(format!("Failed to list VPN configs: {e}")))? }; let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect(); let total_vpns = synced_vpns.len(); - let mut deferred_vpns = Vec::new(); for (i, config) in synced_vpns.iter().enumerate() { - if deferred_vpn_ids.contains(&config.id) { - deferred_vpns.push(config.id.clone()); - } else if let Some(scheduler) = super::get_global_scheduler() { - scheduler.queue_vpn_sync(config.id.clone()).await; - } + engine + .upload_vpn(config) + .await + .map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?; let _ = events::emit( "e2e-rollover-progress", serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}), @@ -4136,14 +4119,15 @@ pub async fn rollover_encryption_for_all_entities( let extensions = { let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); em.list_extensions() - .map_err(|e| format!("Failed to list extensions: {e}"))? + .map_err(|e| internal_error(format!("Failed to list extensions: {e}")))? }; let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect(); let total_exts = synced_exts.len(); for (i, ext) in synced_exts.iter().enumerate() { - if let Some(scheduler) = super::get_global_scheduler() { - scheduler.queue_extension_sync(ext.id.clone()).await; - } + engine + .upload_extension(ext) + .await + .map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?; let _ = events::emit( "e2e-rollover-progress", serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}), @@ -4153,37 +4137,23 @@ pub async fn rollover_encryption_for_all_entities( let ext_groups = { let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); em.list_groups() - .map_err(|e| format!("Failed to list extension groups: {e}"))? + .map_err(|e| internal_error(format!("Failed to list extension groups: {e}")))? }; let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect(); let total_eg = synced_ext_groups.len(); for (i, group) in synced_ext_groups.iter().enumerate() { - if let Some(scheduler) = super::get_global_scheduler() { - scheduler.queue_extension_group_sync(group.id.clone()).await; - } + engine.upload_extension_group(group).await.map_err(|e| { + internal_error(format!( + "Failed to roll over extension group {}: {e}", + group.id + )) + })?; let _ = events::emit( "e2e-rollover-progress", serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}), ); } - if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() { - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - if let Some(scheduler) = super::get_global_scheduler() { - for id in deferred { - scheduler.queue_proxy_sync(id).await; - } - for id in deferred_groups { - scheduler.queue_group_sync(id).await; - } - for id in deferred_vpns { - scheduler.queue_vpn_sync(id).await; - } - } - }); - } - let _ = events::emit("e2e-rollover-completed", ()); Ok(()) } diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index d45bd0d..42fbcf4 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -8,7 +8,6 @@ use std::path::Path; use std::time::SystemTime; use super::types::{SyncError, SyncResult}; -use crate::profile::types::BrowserProfile; /// Default exclude patterns for volatile browser profile files. /// Patterns use `**/` prefix to match at any directory depth, since the sync @@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "**/DawnWebGPUCache/**", "**/BrowserMetrics*", "**/.DS_Store", + // Profile metadata is a separately reconciled LWW config object. Including + // it in the browser-file manifest creates two competing sync mechanisms and + // lets a stale in-memory profile overwrite a metadata download. + "metadata.json", ".donut-sync/**", // Orphaned local-only marker from earlier rollover-based fingerprint // regeneration. Keep excluding it so any markers left on disk from @@ -224,39 +227,6 @@ fn hash_file(path: &Path) -> Result, SyncError> { Ok(Some(hasher.finalize().to_hex().to_string())) } -/// Compute blake3 hash of metadata.json after sanitizing volatile fields. -/// This prevents infinite sync loops where updating last_sync triggers a new sync. -fn hash_sanitized_metadata(path: &Path) -> Result, SyncError> { - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { - return Err(SyncError::IoError(format!( - "Failed to read metadata at {}: {e}", - path.display() - ))); - } - }; - - let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| { - SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}")) - })?; - - // Sanitize volatile fields that should not trigger a re-sync - profile.last_sync = None; - profile.process_id = None; - profile.last_launch = None; - - let sanitized_json = serde_json::to_string(&profile).map_err(|e| { - SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}")) - })?; - - let mut hasher = blake3::Hasher::new(); - hasher.update(sanitized_json.as_bytes()); - - Ok(Some(hasher.finalize().to_hex().to_string())) -} - /// Get mtime as unix timestamp /// Returns None if the file doesn't exist (was deleted) fn get_mtime(path: &Path) -> Result, SyncError> { @@ -372,19 +342,7 @@ pub fn generate_manifest( *max_mtime = (*max_mtime).max(mtime); // Check cache for existing hash - let hash = if relative_path == "metadata.json" { - // Special case: sanitize metadata.json before hashing to prevent sync loops - match hash_sanitized_metadata(&path)? { - Some(computed_hash) => computed_hash, - None => { - log::debug!( - "File disappeared during manifest generation, skipping: {}", - path.display() - ); - continue; - } - } - } else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) { + let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) { cached_hash.to_string() } else { match hash_file(&path)? { @@ -651,21 +609,15 @@ mod tests { fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap(); fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap(); - // metadata.json at root - let profile = BrowserProfile::default(); - fs::write( - profile_dir.join("metadata.json"), - serde_json::to_string(&profile).unwrap(), - ) - .unwrap(); + fs::write(profile_dir.join("metadata.json"), "{}").unwrap(); let mut cache = HashCache::default(); let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap(); let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect(); assert!( - paths.contains(&"metadata.json"), - "metadata.json should be synced" + !paths.contains(&"metadata.json"), + "metadata.json is reconciled separately from browser files" ); assert!( paths.contains(&"profile/Default/Cookies"), @@ -865,85 +817,4 @@ mod tests { assert!(diff.files_to_delete_remote.is_empty()); assert!(diff.files_to_delete_local.is_empty()); } - - #[test] - fn test_generate_manifest_sanitizes_metadata() { - let temp_dir = TempDir::new().unwrap(); - let profile_dir = temp_dir.path().join("profile"); - fs::create_dir_all(&profile_dir).unwrap(); - - let profile_id = uuid::Uuid::new_v4(); - let metadata_path = profile_dir.join("metadata.json"); - - let profile = BrowserProfile { - id: profile_id, - name: "test-profile".to_string(), - last_sync: Some(100), - process_id: Some(1234), - ..Default::default() - }; - - fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap(); - - let mut cache = HashCache::default(); - let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap(); - let hash1 = manifest1 - .files - .iter() - .find(|f| f.path == "metadata.json") - .unwrap() - .hash - .clone(); - - // Update volatile fields - let profile2 = BrowserProfile { - id: profile_id, - name: "test-profile".to_string(), - last_sync: Some(200), - process_id: Some(5678), - ..Default::default() - }; - - fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap(); - - let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap(); - let hash2 = manifest2 - .files - .iter() - .find(|f| f.path == "metadata.json") - .unwrap() - .hash - .clone(); - - // Hash should be identical because volatile fields are sanitized - assert_eq!( - hash1, hash2, - "Metadata hash should be stable across last_sync/process_id updates" - ); - - // Change a non-volatile field - let profile3 = BrowserProfile { - id: profile_id, - name: "changed-name".to_string(), - last_sync: Some(200), - ..Default::default() - }; - - fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap(); - - let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap(); - let hash3 = manifest3 - .files - .iter() - .find(|f| f.path == "metadata.json") - .unwrap() - .hash - .clone(); - - // Hash should be different because name changed - assert_ne!( - hash1, hash3, - "Metadata hash should change when non-volatile fields change" - ); - } } diff --git a/src/components/rail-nav.tsx b/src/components/rail-nav.tsx index 4d5da2f..0ba57f2 100644 --- a/src/components/rail-nav.tsx +++ b/src/components/rail-nav.tsx @@ -243,6 +243,17 @@ export function RailNav({ handleClick, } = useLogoEasterEgg({ currentPage, onNavigate }); + useEffect(() => { + if (!moreOpen) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMoreOpen(false); + } + }; + document.addEventListener("keydown", closeOnEscape); + return () => document.removeEventListener("keydown", closeOnEscape); + }, [moreOpen]); + return (