mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-26 19:22:09 +02:00
chore: add cross-platform webdriver tests
This commit is contained in:
@@ -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
|
||||||
@@ -45,15 +45,22 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
app-e2e:
|
||||||
|
name: Cross-platform App E2E
|
||||||
|
uses: ./.github/workflows/app-e2e.yml
|
||||||
|
secrets: inherit
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
pr-status:
|
pr-status:
|
||||||
name: PR Status Check
|
name: PR Status Check
|
||||||
runs-on: ubuntu-latest
|
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()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- name: Check all jobs succeeded
|
- name: Check all jobs succeeded
|
||||||
run: |
|
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"
|
echo "One or more checks failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ donutbrowser/
|
|||||||
│ └── Cargo.toml # Rust dependencies
|
│ └── Cargo.toml # Rust dependencies
|
||||||
├── donut-sync/ # NestJS sync server (self-hostable)
|
├── donut-sync/ # NestJS sync server (self-hostable)
|
||||||
│ └── src/ # Controllers, services, auth, S3 sync
|
│ └── 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
|
├── flake.nix # Nix development environment
|
||||||
└── .github/workflows/ # CI/CD pipelines
|
└── .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:
|
- 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.
|
`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)
|
## Logs (when debugging a running app)
|
||||||
|
|
||||||
Three log surfaces, in order of usefulness:
|
Three log surfaces, in order of usefulness:
|
||||||
|
|||||||
@@ -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=<name>` 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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+383
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+600
@@ -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(`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Donut E2E Browser Fixture</title></head>
|
||||||
|
<body>
|
||||||
|
<h1 id="fixture-title">Donut E2E Browser Fixture</h1>
|
||||||
|
<p id="path">${url.pathname}</p>
|
||||||
|
<button id="fixture-button" onclick="this.dataset.clicked='yes'; this.textContent='Clicked'">Click fixture</button>
|
||||||
|
<script>window.__fixtureReady = true;</script>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
});
|
||||||
|
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();
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-2
@@ -12,15 +12,22 @@
|
|||||||
"test:rust": "cd src-tauri && cargo test",
|
"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: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",
|
"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": "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:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||||
"lint:spell": "typos .",
|
"lint:spell": "typos .",
|
||||||
"tauri": "node scripts/run-with-env.mjs tauri",
|
"tauri": "node scripts/run-with-env.mjs tauri",
|
||||||
"shadcn:add": "pnpm dlx shadcn@latest add",
|
"shadcn:add": "pnpm dlx shadcn@latest add",
|
||||||
"prepare": "husky && husky install",
|
"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: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",
|
"format": "pnpm format:js && pnpm format:rust",
|
||||||
"build:sync": "cd donut-sync && pnpm build",
|
"build:sync": "cd donut-sync && pnpm build",
|
||||||
"cargo": "cd src-tauri && cargo",
|
"cargo": "cd src-tauri && cargo",
|
||||||
|
|||||||
Generated
+221
-70
@@ -442,7 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
|
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atk-sys",
|
"atk-sys",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -452,10 +452,10 @@ version = "0.18.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
|
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -912,7 +912,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"cairo-sys-rs",
|
"cairo-sys-rs",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -924,9 +924,9 @@ version = "0.18.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
|
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1027,7 +1027,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"smallvec",
|
"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]]
|
[[package]]
|
||||||
@@ -1857,6 +1867,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
|
"tauri-plugin-cross-platform-webdriver",
|
||||||
"tauri-plugin-deep-link",
|
"tauri-plugin-deep-link",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
@@ -2470,7 +2481,7 @@ dependencies = [
|
|||||||
"gdk-pixbuf",
|
"gdk-pixbuf",
|
||||||
"gdk-sys",
|
"gdk-sys",
|
||||||
"gio",
|
"gio",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
"pango",
|
"pango",
|
||||||
]
|
]
|
||||||
@@ -2483,7 +2494,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gdk-pixbuf-sys",
|
"gdk-pixbuf-sys",
|
||||||
"gio",
|
"gio",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
@@ -2494,11 +2505,11 @@ version = "0.18.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
|
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2509,13 +2520,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cairo-sys-rs",
|
"cairo-sys-rs",
|
||||||
"gdk-pixbuf-sys",
|
"gdk-pixbuf-sys",
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"pango-sys",
|
"pango-sys",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2525,11 +2536,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
|
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gdk-sys",
|
"gdk-sys",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2541,7 +2552,7 @@ dependencies = [
|
|||||||
"gdk",
|
"gdk",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gio",
|
"gio",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
"x11",
|
"x11",
|
||||||
]
|
]
|
||||||
@@ -2553,9 +2564,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
|
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gdk-sys",
|
"gdk-sys",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
"x11",
|
"x11",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2653,8 +2664,8 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -2668,13 +2679,26 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
|
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "glib"
|
name = "glib"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
@@ -2687,10 +2711,10 @@ dependencies = [
|
|||||||
"futures-executor",
|
"futures-executor",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib-macros",
|
"glib-macros 0.18.5",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -2698,6 +2722,27 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"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]]
|
[[package]]
|
||||||
name = "glib-macros"
|
name = "glib-macros"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
@@ -2712,6 +2757,19 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "glib-sys"
|
name = "glib-sys"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -2719,7 +2777,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
|
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
@@ -2747,9 +2815,20 @@ version = "0.18.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
|
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
@@ -2765,7 +2844,7 @@ dependencies = [
|
|||||||
"gdk",
|
"gdk",
|
||||||
"gdk-pixbuf",
|
"gdk-pixbuf",
|
||||||
"gio",
|
"gio",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"gtk3-macros",
|
"gtk3-macros",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2783,12 +2862,12 @@ dependencies = [
|
|||||||
"cairo-sys-rs",
|
"cairo-sys-rs",
|
||||||
"gdk-pixbuf-sys",
|
"gdk-pixbuf-sys",
|
||||||
"gdk-sys",
|
"gdk-sys",
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"pango-sys",
|
"pango-sys",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3427,7 +3506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
|
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"javascriptcore-rs-sys",
|
"javascriptcore-rs-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3437,10 +3516,10 @@ version = "1.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
|
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3596,7 +3675,7 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
|
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"gtk",
|
"gtk",
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"libappindicator-sys",
|
"libappindicator-sys",
|
||||||
@@ -4278,6 +4357,16 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-open-directory"
|
name = "objc2-open-directory"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -4301,6 +4390,17 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-ui-kit"
|
name = "objc2-ui-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -4344,6 +4444,8 @@ dependencies = [
|
|||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-javascript-core",
|
||||||
|
"objc2-security",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4461,7 +4563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
|
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gio",
|
"gio",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"pango-sys",
|
"pango-sys",
|
||||||
@@ -4473,10 +4575,10 @@ version = "0.18.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
|
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5290,8 +5392,8 @@ checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block2",
|
"block2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
@@ -6149,7 +6251,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"gio",
|
"gio",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"libc",
|
"libc",
|
||||||
"soup3-sys",
|
"soup3-sys",
|
||||||
]
|
]
|
||||||
@@ -6160,11 +6262,11 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
|
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6363,13 +6465,26 @@ version = "6.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-expr",
|
"cfg-expr 0.15.8",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"version-compare",
|
"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]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.35.3"
|
version = "0.35.3"
|
||||||
@@ -6444,6 +6559,12 @@ version = "0.12.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "target-lexicon"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri"
|
name = "tauri"
|
||||||
version = "2.11.5"
|
version = "2.11.5"
|
||||||
@@ -6589,6 +6710,36 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-deep-link"
|
name = "tauri-plugin-deep-link"
|
||||||
version = "2.4.9"
|
version = "2.4.9"
|
||||||
@@ -7032,9 +7183,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.53.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -7694,9 +7845,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.4"
|
version = "1.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -7964,10 +8115,10 @@ dependencies = [
|
|||||||
"gdk",
|
"gdk",
|
||||||
"gdk-sys",
|
"gdk-sys",
|
||||||
"gio",
|
"gio",
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib",
|
"glib 0.18.5",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"gtk",
|
"gtk",
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
@@ -7986,15 +8137,15 @@ dependencies = [
|
|||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"cairo-sys-rs",
|
"cairo-sys-rs",
|
||||||
"gdk-sys",
|
"gdk-sys",
|
||||||
"gio-sys",
|
"gio-sys 0.18.1",
|
||||||
"glib-sys",
|
"glib-sys 0.18.1",
|
||||||
"gobject-sys",
|
"gobject-sys 0.18.0",
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"javascriptcore-rs-sys",
|
"javascriptcore-rs-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"soup3-sys",
|
"soup3-sys",
|
||||||
"system-deps",
|
"system-deps 6.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ tauri-plugin-macos-permissions = "2"
|
|||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
tauri-plugin-window-state = "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"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
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
|
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||||
# DO NOT remove this
|
# DO NOT remove this
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
e2e = ["dep:tauri-plugin-cross-platform-webdriver"]
|
||||||
|
|||||||
@@ -2037,6 +2037,14 @@ rm "{}"
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, 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() {
|
if crate::app_dirs::is_portable() {
|
||||||
log::info!("App auto-updates disabled in portable mode");
|
log::info!("App auto-updates disabled in portable mode");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -2090,6 +2098,14 @@ pub async fn restart_application() -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
|
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, 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");
|
log::info!("Manual app update check triggered");
|
||||||
let updater = AppAutoUpdater::instance();
|
let updater = AppAutoUpdater::instance();
|
||||||
updater
|
updater
|
||||||
|
|||||||
@@ -779,6 +779,13 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||||
pub async fn can_use_browser_automation(&self) -> bool {
|
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
|
self
|
||||||
.entitlements()
|
.entitlements()
|
||||||
.await
|
.await
|
||||||
@@ -788,6 +795,13 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
/// Edit fingerprints / use a non-native OS fingerprint.
|
/// Edit fingerprints / use a non-native OS fingerprint.
|
||||||
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
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
|
self
|
||||||
.entitlements()
|
.entitlements()
|
||||||
.await
|
.await
|
||||||
@@ -1224,6 +1238,16 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
/// Get the current wayfern token, if any.
|
/// Get the current wayfern token, if any.
|
||||||
pub async fn get_wayfern_token(&self) -> Option<String> {
|
pub async fn get_wayfern_token(&self) -> Option<String> {
|
||||||
|
#[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;
|
let wt = self.wayfern_token.lock().await;
|
||||||
wt.clone()
|
wt.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,9 +295,23 @@ impl BlocklistManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||||
let url = level
|
let production_url = level
|
||||||
.url()
|
.url()
|
||||||
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
.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 =
|
let path =
|
||||||
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
||||||
|
|
||||||
@@ -311,7 +325,7 @@ impl BlocklistManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let response = HTTP_CLIENT
|
let response = HTTP_CLIENT
|
||||||
.get(url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
||||||
|
|||||||
@@ -1261,6 +1261,14 @@ mod tests {
|
|||||||
pub async fn ensure_active_browsers_downloaded(
|
pub async fn ensure_active_browsers_downloaded(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, 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 registry = DownloadedBrowsersRegistry::instance();
|
||||||
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
||||||
let mut downloaded = Vec::new();
|
let mut downloaded = Vec::new();
|
||||||
@@ -1410,6 +1418,14 @@ pub async fn check_missing_binaries() -> Result<Vec<(String, String, String)>, S
|
|||||||
pub async fn ensure_all_binaries_exist(
|
pub async fn ensure_all_binaries_exist(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, 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();
|
let registry = DownloadedBrowsersRegistry::instance();
|
||||||
registry
|
registry
|
||||||
.ensure_all_binaries_exist(&app_handle)
|
.ensure_all_binaries_exist(&app_handle)
|
||||||
|
|||||||
@@ -141,13 +141,22 @@ impl GeoIPDownloader {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch latest release from GitHub
|
#[cfg(feature = "e2e")]
|
||||||
let releases = self.fetch_geoip_releases().await?;
|
let fixture_url = std::env::var("DONUT_E2E_GEOIP_DOWNLOAD_URL")
|
||||||
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
|
.ok()
|
||||||
|
.filter(|url| !url.is_empty());
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
|
let fixture_url: Option<String> = None;
|
||||||
|
|
||||||
let download_url = self
|
let download_url = if let Some(url) = fixture_url {
|
||||||
.find_city_mmdb_asset(latest_release)
|
url
|
||||||
.ok_or("No compatible GeoIP database asset found")?;
|
} 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
|
// Create cache directory
|
||||||
let cache_dir = Self::get_cache_dir();
|
let cache_dir = Self::get_cache_dir();
|
||||||
|
|||||||
+207
-187
@@ -3,6 +3,7 @@ use std::env;
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
use tauri_plugin_deep_link::DeepLinkExt;
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
use tauri_plugin_log::{Target, TargetKind};
|
use tauri_plugin_log::{Target, TargetKind};
|
||||||
|
|
||||||
@@ -14,6 +15,17 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
|||||||
// to the confirmation dialog.
|
// to the confirmation dialog.
|
||||||
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
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_client;
|
||||||
mod api_server;
|
mod api_server;
|
||||||
mod app_auto_updater;
|
mod app_auto_updater;
|
||||||
@@ -1263,6 +1275,7 @@ fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
fn show_main_window(app_handle: &tauri::AppHandle) {
|
fn show_main_window(app_handle: &tauri::AppHandle) {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
@@ -1302,6 +1315,7 @@ fn update_tray_menu(
|
|||||||
/// Build the system tray. Best-effort: on Linux the tray depends on
|
/// Build the system tray. Best-effort: on Linux the tray depends on
|
||||||
/// libayatana-appindicator at runtime, so any failure here must not abort app
|
/// libayatana-appindicator at runtime, so any failure here must not abort app
|
||||||
/// startup — the caller logs and continues without a tray.
|
/// startup — the caller logs and continues without a tray.
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||||
@@ -1391,54 +1405,65 @@ pub fn run() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
let builder = tauri::Builder::default();
|
||||||
.plugin(
|
|
||||||
tauri_plugin_log::Builder::new()
|
#[cfg(feature = "e2e")]
|
||||||
.clear_targets() // Clear default targets to avoid duplicates
|
let builder = builder.plugin(tauri_plugin_cross_platform_webdriver::init());
|
||||||
.target(Target::new(TargetKind::Stdout))
|
|
||||||
.target(Target::new(TargetKind::Webview))
|
let builder = builder.plugin(
|
||||||
.target(file_log_target)
|
tauri_plugin_log::Builder::new()
|
||||||
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
|
.clear_targets() // Clear default targets to avoid duplicates
|
||||||
// truncated useful context in customer support reports; 50 MB
|
.target(Target::new(TargetKind::Stdout))
|
||||||
// turned out to be excessive disk pressure.
|
.target(Target::new(TargetKind::Webview))
|
||||||
.max_file_size(5 * 1024 * 1024)
|
.target(file_log_target)
|
||||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
|
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
|
||||||
.level(log::LevelFilter::Info)
|
// truncated useful context in customer support reports; 50 MB
|
||||||
.format(|out, message, record| {
|
// turned out to be excessive disk pressure.
|
||||||
use chrono::Local;
|
.max_file_size(5 * 1024 * 1024)
|
||||||
let now = Local::now();
|
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
|
||||||
let timestamp = format!(
|
.level(log::LevelFilter::Info)
|
||||||
"{}.{:03}",
|
.format(|out, message, record| {
|
||||||
now.format("%Y-%m-%d %H:%M:%S"),
|
use chrono::Local;
|
||||||
now.timestamp_subsec_millis()
|
let now = Local::now();
|
||||||
);
|
let timestamp = format!(
|
||||||
out.finish(format_args!(
|
"{}.{:03}",
|
||||||
"[{}][{}][{}] {}",
|
now.format("%Y-%m-%d %H:%M:%S"),
|
||||||
timestamp,
|
now.timestamp_subsec_millis()
|
||||||
record.target(),
|
);
|
||||||
record.level(),
|
out.finish(format_args!(
|
||||||
message
|
"[{}][{}][{}] {}",
|
||||||
))
|
timestamp,
|
||||||
})
|
record.target(),
|
||||||
.build(),
|
record.level(),
|
||||||
)
|
message
|
||||||
.plugin(tauri_plugin_single_instance::init(
|
))
|
||||||
|app_handle, args, _cwd| {
|
})
|
||||||
log::info!("Single instance triggered with args: {args:?}");
|
.build(),
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
);
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
#[cfg(not(feature = "e2e"))]
|
||||||
let _ = window.unminimize();
|
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_deep_link::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_macos_permissions::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
|
// Persist window size/position across restarts. VISIBLE is excluded
|
||||||
// because the app hides to tray: restoring visibility would otherwise
|
// because the app hides to tray: restoring visibility would otherwise
|
||||||
// relaunch with an invisible window after quitting from the tray while
|
// relaunch with an invisible window after quitting from the tray while
|
||||||
@@ -1453,8 +1478,9 @@ pub fn run() {
|
|||||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||||
)
|
)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
);
|
||||||
.setup(|app| {
|
|
||||||
|
builder.setup(|app| {
|
||||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||||
ephemeral_dirs::recover_ephemeral_dirs();
|
ephemeral_dirs::recover_ephemeral_dirs();
|
||||||
|
|
||||||
@@ -1476,6 +1502,20 @@ pub fn run() {
|
|||||||
.focused(true)
|
.focused(true)
|
||||||
.visible(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")]
|
#[cfg(target_os = "windows")]
|
||||||
let win_builder = win_builder.decorations(false);
|
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
|
// dialog's "Minimize" action hides the window. Best-effort: a tray
|
||||||
// failure (e.g. missing libayatana-appindicator on Linux) must never
|
// failure (e.g. missing libayatana-appindicator on Linux) must never
|
||||||
// prevent the app from launching, so we log and continue without it.
|
// prevent the app from launching, so we log and continue without it.
|
||||||
if let Err(e) = setup_system_tray(app.handle()) {
|
#[cfg(not(feature = "e2e"))]
|
||||||
log::warn!("System tray unavailable, continuing without it: {e}");
|
{
|
||||||
|
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
|
// 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}");
|
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
|
// For Windows, register all deep links at runtime
|
||||||
if let Err(e) = app.deep_link().register_all() {
|
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
|
// On macOS, try to register deep links for development builds
|
||||||
if let Err(e) = app.deep_link().register_all() {
|
if let Err(e) = app.deep_link().register_all() {
|
||||||
@@ -1548,28 +1591,28 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.deep_link().on_open_url({
|
#[cfg(not(feature = "e2e"))]
|
||||||
let handle = handle.clone();
|
{
|
||||||
move |event| {
|
app.deep_link().on_open_url({
|
||||||
let urls = event.urls();
|
let handle = handle.clone();
|
||||||
log::info!("Deep link event received with {} URLs", urls.len());
|
move |event| {
|
||||||
|
let urls = event.urls();
|
||||||
|
log::info!("Deep link event received with {} URLs", urls.len());
|
||||||
|
|
||||||
for url in urls {
|
for url in urls {
|
||||||
let url_string = url.to_string();
|
let url_string = url.to_string();
|
||||||
log::info!("Deep link received: {url_string}");
|
log::info!("Deep link received: {url_string}");
|
||||||
|
let handle_clone = handle.clone();
|
||||||
|
|
||||||
// Clone the handle for each async task
|
tauri::async_runtime::spawn(async move {
|
||||||
let handle_clone = handle.clone();
|
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
|
||||||
|
log::error!("Failed to handle deep link URL: {e}");
|
||||||
// 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}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
if let Some(startup_url) = startup_url {
|
if let Some(startup_url) = startup_url {
|
||||||
let handle_clone = handle.clone();
|
let handle_clone = handle.clone();
|
||||||
@@ -1581,30 +1624,29 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize and start background version updater
|
if !e2e_automation_enabled() {
|
||||||
let app_handle = app.handle().clone();
|
// Initialize and start background version updater
|
||||||
tauri::async_runtime::spawn(async move {
|
let app_handle = app.handle().clone();
|
||||||
let version_updater = get_version_updater();
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let version_updater = get_version_updater();
|
||||||
|
|
||||||
// Set the app handle
|
{
|
||||||
{
|
let mut updater_guard = version_updater.lock().await;
|
||||||
let mut updater_guard = version_updater.lock().await;
|
updater_guard.set_app_handle(app_handle);
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the background update task separately
|
{
|
||||||
tauri::async_runtime::spawn(async move {
|
let updater_guard = version_updater.lock().await;
|
||||||
version_updater::VersionUpdater::run_background_task().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
|
// Auto-start MCP server if it was previously enabled. Always log the
|
||||||
// decision so customer logs reveal whether MCP is actually running —
|
// 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();
|
if !e2e_automation_enabled() {
|
||||||
|
let app_handle_auto_updater = app.handle().clone();
|
||||||
// Start the auto-update check task separately
|
tauri::async_runtime::spawn(async move {
|
||||||
tauri::async_runtime::spawn(async move {
|
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
|
||||||
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Handle any pending URLs that were received before the window was ready
|
// Handle any pending URLs that were received before the window was ready
|
||||||
let handle_pending = handle.clone();
|
let handle_pending = handle.clone();
|
||||||
@@ -1793,107 +1835,85 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start periodic cleanup task for unused binaries
|
if !e2e_automation_enabled() {
|
||||||
// Only runs when sync is not in progress to avoid deleting browsers
|
// Start periodic cleanup task for unused binaries.
|
||||||
// that might be needed for profiles being synced from the cloud
|
tauri::async_runtime::spawn(async move {
|
||||||
tauri::async_runtime::spawn(async move {
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
loop {
|
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||||
interval.tick().await;
|
if scheduler.is_sync_in_progress().await {
|
||||||
|
log::debug!("Skipping cleanup: sync is in progress");
|
||||||
// Check if sync is in progress before running cleanup
|
continue;
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 registry =
|
||||||
let app_handle_geoip = app.handle().clone();
|
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||||
tauri::async_runtime::spawn(async move {
|
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||||
// Wait a bit for the app to fully initialize
|
log::error!("Periodic cleanup failed: {e}");
|
||||||
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 {
|
} 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
|
// Start proxy cleanup task for dead browser processes
|
||||||
let app_handle_proxy_cleanup = app.handle().clone();
|
let app_handle_proxy_cleanup = app.handle().clone();
|
||||||
|
|||||||
+120
-150
@@ -492,6 +492,9 @@ impl SyncEngine {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
|
||||||
|
let profile = &reconciled_profile;
|
||||||
|
|
||||||
// Derive encryption key if encrypted sync
|
// Derive encryption key if encrypted sync
|
||||||
let encryption_key = if profile.is_encrypted_sync() {
|
let encryption_key = if profile.is_encrypted_sync() {
|
||||||
let password = encryption::load_e2e_password()
|
let password = encryption::load_e2e_password()
|
||||||
@@ -697,11 +700,6 @@ impl SyncEngine {
|
|||||||
log::debug!("Deleted remote file: {}", path);
|
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
|
// 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
|
// 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
|
// 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;
|
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download remote metadata and merge changes (name, tags, notes, etc.)
|
let mut updated_profile = profile.clone();
|
||||||
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
updated_profile.last_sync = Some(
|
||||||
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
|
std::time::SystemTime::now()
|
||||||
let mut updated_profile = profile.clone();
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
// Merge fields that can be changed on other devices
|
.unwrap()
|
||||||
updated_profile.name = remote_meta.name;
|
.as_secs(),
|
||||||
updated_profile.tags = remote_meta.tags;
|
);
|
||||||
updated_profile.note = remote_meta.note;
|
profile_manager
|
||||||
updated_profile.proxy_id = remote_meta.proxy_id;
|
.save_profile(&updated_profile)
|
||||||
updated_profile.vpn_id = remote_meta.vpn_id;
|
.map_err(|e| {
|
||||||
updated_profile.group_id = remote_meta.group_id;
|
SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}"))
|
||||||
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 _ = events::emit("profiles-changed", ());
|
let _ = events::emit("profiles-changed", ());
|
||||||
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
@@ -881,6 +860,45 @@ impl SyncEngine {
|
|||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reconcile_profile_metadata(
|
||||||
|
&self,
|
||||||
|
profile: &BrowserProfile,
|
||||||
|
) -> SyncResult<BrowserProfile> {
|
||||||
|
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).
|
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
|
||||||
/// No browser files are synced.
|
/// No browser files are synced.
|
||||||
async fn sync_cross_os_metadata(
|
async fn sync_cross_os_metadata(
|
||||||
@@ -889,34 +907,8 @@ impl SyncEngine {
|
|||||||
profile: &BrowserProfile,
|
profile: &BrowserProfile,
|
||||||
) -> SyncResult<()> {
|
) -> SyncResult<()> {
|
||||||
let profile_id = profile.id.to_string();
|
let profile_id = profile.id.to_string();
|
||||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile = &reconciled_profile;
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync associated entities
|
// Sync associated entities
|
||||||
if let Some(proxy_id) = &profile.proxy_id {
|
if let Some(proxy_id) = &profile.proxy_id {
|
||||||
@@ -954,18 +946,9 @@ impl SyncEngine {
|
|||||||
let json = serde_json::to_string_pretty(&sanitized)
|
let json = serde_json::to_string_pretty(&sanitized)
|
||||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
|
.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 remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
||||||
let presign = self
|
|
||||||
.client
|
|
||||||
.presign_upload(&remote_key, Some(content_type))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self
|
self
|
||||||
.client
|
.upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0))
|
||||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let _ = events::emit("e2e-rollover-started", ());
|
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 profile_manager = ProfileManager::instance();
|
||||||
let profiles = profile_manager
|
let profiles = profile_manager
|
||||||
.list_profiles()
|
.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
|
let synced_profiles: Vec<_> = profiles
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.sync_mode != SyncMode::Disabled)
|
.filter(|p| p.sync_mode != SyncMode::Disabled)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let total_profiles = synced_profiles.len();
|
if synced_profiles
|
||||||
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
|
.iter()
|
||||||
std::collections::HashSet::new();
|
.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() {
|
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();
|
let id_str = profile.id.to_string();
|
||||||
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
|
// The remote manifest may be encrypted with the previous password. Delete
|
||||||
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
|
// 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(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({
|
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<String> = std::collections::HashSet::new();
|
|
||||||
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
let mut deferred_group_ids: std::collections::HashSet<String> = 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 proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
||||||
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
|
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
|
||||||
let total_proxies = synced_proxies.len();
|
let total_proxies = synced_proxies.len();
|
||||||
let mut deferred = Vec::new();
|
|
||||||
for (i, proxy) in synced_proxies.iter().enumerate() {
|
for (i, proxy) in synced_proxies.iter().enumerate() {
|
||||||
if deferred_proxy_ids.contains(&proxy.id) {
|
engine
|
||||||
deferred.push(proxy.id.clone());
|
.upload_proxy(proxy)
|
||||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
.await
|
||||||
scheduler.queue_proxy_sync(proxy.id.clone()).await;
|
.map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?;
|
||||||
}
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
|
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 groups = {
|
||||||
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||||
gm.get_all_groups()
|
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 synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
|
||||||
let total_groups = synced_groups.len();
|
let total_groups = synced_groups.len();
|
||||||
let mut deferred_groups = Vec::new();
|
|
||||||
for (i, group) in synced_groups.iter().enumerate() {
|
for (i, group) in synced_groups.iter().enumerate() {
|
||||||
if deferred_group_ids.contains(&group.id) {
|
engine
|
||||||
deferred_groups.push(group.id.clone());
|
.upload_group(group)
|
||||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
.await
|
||||||
scheduler.queue_group_sync(group.id.clone()).await;
|
.map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?;
|
||||||
}
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
|
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();
|
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||||
storage
|
storage
|
||||||
.list_configs()
|
.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 synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
|
||||||
let total_vpns = synced_vpns.len();
|
let total_vpns = synced_vpns.len();
|
||||||
let mut deferred_vpns = Vec::new();
|
|
||||||
for (i, config) in synced_vpns.iter().enumerate() {
|
for (i, config) in synced_vpns.iter().enumerate() {
|
||||||
if deferred_vpn_ids.contains(&config.id) {
|
engine
|
||||||
deferred_vpns.push(config.id.clone());
|
.upload_vpn(config)
|
||||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
.await
|
||||||
scheduler.queue_vpn_sync(config.id.clone()).await;
|
.map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?;
|
||||||
}
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
|
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 extensions = {
|
||||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
em.list_extensions()
|
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 synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
|
||||||
let total_exts = synced_exts.len();
|
let total_exts = synced_exts.len();
|
||||||
for (i, ext) in synced_exts.iter().enumerate() {
|
for (i, ext) in synced_exts.iter().enumerate() {
|
||||||
if let Some(scheduler) = super::get_global_scheduler() {
|
engine
|
||||||
scheduler.queue_extension_sync(ext.id.clone()).await;
|
.upload_extension(ext)
|
||||||
}
|
.await
|
||||||
|
.map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?;
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
|
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 ext_groups = {
|
||||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
em.list_groups()
|
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 synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
|
||||||
let total_eg = synced_ext_groups.len();
|
let total_eg = synced_ext_groups.len();
|
||||||
for (i, group) in synced_ext_groups.iter().enumerate() {
|
for (i, group) in synced_ext_groups.iter().enumerate() {
|
||||||
if let Some(scheduler) = super::get_global_scheduler() {
|
engine.upload_extension_group(group).await.map_err(|e| {
|
||||||
scheduler.queue_extension_group_sync(group.id.clone()).await;
|
internal_error(format!(
|
||||||
}
|
"Failed to roll over extension group {}: {e}",
|
||||||
|
group.id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
|
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", ());
|
let _ = events::emit("e2e-rollover-completed", ());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use std::path::Path;
|
|||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use super::types::{SyncError, SyncResult};
|
use super::types::{SyncError, SyncResult};
|
||||||
use crate::profile::types::BrowserProfile;
|
|
||||||
|
|
||||||
/// Default exclude patterns for volatile browser profile files.
|
/// Default exclude patterns for volatile browser profile files.
|
||||||
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
||||||
@@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
|||||||
"**/DawnWebGPUCache/**",
|
"**/DawnWebGPUCache/**",
|
||||||
"**/BrowserMetrics*",
|
"**/BrowserMetrics*",
|
||||||
"**/.DS_Store",
|
"**/.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/**",
|
".donut-sync/**",
|
||||||
// Orphaned local-only marker from earlier rollover-based fingerprint
|
// Orphaned local-only marker from earlier rollover-based fingerprint
|
||||||
// regeneration. Keep excluding it so any markers left on disk from
|
// regeneration. Keep excluding it so any markers left on disk from
|
||||||
@@ -224,39 +227,6 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
|
|||||||
Ok(Some(hasher.finalize().to_hex().to_string()))
|
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<Option<String>, 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
|
/// Get mtime as unix timestamp
|
||||||
/// Returns None if the file doesn't exist (was deleted)
|
/// Returns None if the file doesn't exist (was deleted)
|
||||||
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
|
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
|
||||||
@@ -372,19 +342,7 @@ pub fn generate_manifest(
|
|||||||
*max_mtime = (*max_mtime).max(mtime);
|
*max_mtime = (*max_mtime).max(mtime);
|
||||||
|
|
||||||
// Check cache for existing hash
|
// Check cache for existing hash
|
||||||
let hash = if relative_path == "metadata.json" {
|
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
|
||||||
// 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) {
|
|
||||||
cached_hash.to_string()
|
cached_hash.to_string()
|
||||||
} else {
|
} else {
|
||||||
match hash_file(&path)? {
|
match hash_file(&path)? {
|
||||||
@@ -651,21 +609,15 @@ mod tests {
|
|||||||
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
|
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
|
||||||
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
|
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
|
||||||
|
|
||||||
// metadata.json at root
|
fs::write(profile_dir.join("metadata.json"), "{}").unwrap();
|
||||||
let profile = BrowserProfile::default();
|
|
||||||
fs::write(
|
|
||||||
profile_dir.join("metadata.json"),
|
|
||||||
serde_json::to_string(&profile).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cache = HashCache::default();
|
let mut cache = HashCache::default();
|
||||||
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
|
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();
|
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
|
||||||
assert!(
|
assert!(
|
||||||
paths.contains(&"metadata.json"),
|
!paths.contains(&"metadata.json"),
|
||||||
"metadata.json should be synced"
|
"metadata.json is reconciled separately from browser files"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
paths.contains(&"profile/Default/Cookies"),
|
paths.contains(&"profile/Default/Cookies"),
|
||||||
@@ -865,85 +817,4 @@ mod tests {
|
|||||||
assert!(diff.files_to_delete_remote.is_empty());
|
assert!(diff.files_to_delete_remote.is_empty());
|
||||||
assert!(diff.files_to_delete_local.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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,17 @@ export function RailNav({
|
|||||||
handleClick,
|
handleClick,
|
||||||
} = useLogoEasterEgg({ currentPage, onNavigate });
|
} = 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 (
|
return (
|
||||||
<nav className="relative flex w-10 shrink-0 flex-col items-center gap-1 border-r border-border bg-background py-2">
|
<nav className="relative flex w-10 shrink-0 flex-col items-center gap-1 border-r border-border bg-background py-2">
|
||||||
{!isHidden ? (
|
{!isHidden ? (
|
||||||
@@ -379,11 +390,16 @@ export function RailNav({
|
|||||||
setMoreOpen(false);
|
setMoreOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label={t("rail.more.label")}
|
||||||
|
className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1"
|
||||||
|
>
|
||||||
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
||||||
<button
|
<button
|
||||||
key={page}
|
key={page}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMoreOpen(false);
|
setMoreOpen(false);
|
||||||
onNavigate(page);
|
onNavigate(page);
|
||||||
@@ -405,6 +421,7 @@ export function RailNav({
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMoreOpen(false);
|
setMoreOpen(false);
|
||||||
onOpenAbout();
|
onOpenAbout();
|
||||||
|
|||||||
Reference in New Issue
Block a user