chore: add cross-platform webdriver tests

This commit is contained in:
zhom
2026-07-20 03:26:31 +04:00
parent 8fe38453d4
commit f7daf68b52
29 changed files with 4997 additions and 558 deletions
+179
View File
@@ -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
+9 -2
View File
@@ -45,15 +45,22 @@ jobs:
permissions:
contents: read
app-e2e:
name: Cross-platform App E2E
uses: ./.github/workflows/app-e2e.yml
secrets: inherit
permissions:
contents: read
pr-status:
name: PR Status Check
runs-on: ubuntu-latest
needs: [lint-js, lint-rust, security-scan, sync-e2e]
needs: [lint-js, lint-rust, security-scan, sync-e2e, app-e2e]
if: always()
steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" || "${{ needs.app-e2e.result }}" != "success" ]]; then
echo "One or more checks failed"
exit 1
fi
+31 -1
View File
@@ -51,7 +51,9 @@ donutbrowser/
│ └── Cargo.toml # Rust dependencies
├── donut-sync/ # NestJS sync server (self-hostable)
│ └── src/ # Controllers, services, auth, S3 sync
├── docs/ # Documentation (self-hosting guide)
├── e2e/ # Isolated native UI/sync/Wayfern E2E system
│ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers
│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites
├── flake.nix # Nix development environment
└── .github/workflows/ # CI/CD pipelines
```
@@ -64,6 +66,34 @@ donutbrowser/
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
### Native app E2E tests are mandatory for affected behavior
The native suites use the sibling `../tauri-cross-platform-webdriver/` repository and launch an
`e2e`-feature build. Every session gets its own temporary Donut data/cache/log root, home directory,
WebView store, ports, and sync bucket. Never point a suite at production or development data.
After a behavior change, run the smallest affected subset below in addition to the standard
format/lint/unit-test command. A code change is not considered verified until its affected native
suite passes:
| Changed area | Required command |
| --- | --- |
| Startup, settings, persistence, window state, shortcuts, navigation | `pnpm e2e:smoke` |
| React components, dialogs, responsive layout, accessibility, onboarding | `pnpm e2e:ui` |
| Profiles, imports, groups, proxies, VPNs, extensions, DNS, cookies, passwords, traffic | `pnpm e2e:entities` |
| REST API/OpenAPI, MCP, cloud/update contracts, team locks, real-time synchronizer | `pnpm e2e:integrations` |
| Sync client/server, manifests, timestamps, deletion, encryption, password rollover | `pnpm e2e:sync` |
| Wayfern download/terms/fingerprint, browser runner, CDP, automation endpoints, process cleanup | `pnpm e2e:browser` |
| E2E harness, WebDriver plugin/driver, app isolation hooks, or changes spanning multiple rows | Run every affected row; use `pnpm e2e` for cross-cutting changes |
`e2e:browser` and the full suite require `WAYFERN_TEST_TOKEN` in the environment or the local
`.env`; all other suites must run without credentials. Use `--no-build` only when the frontend,
Rust app, sidecar, and WebDriver binaries are already current. Keep failed artifacts and inspect the
per-session app/driver logs and screenshot before changing assertions.
When adding a Tauri command, assign it exactly once in `e2e/coverage-map.mjs` and add executable
evidence to the owning suite. `e2e:smoke` fails if command registration and the coverage map drift.
## Logs (when debugging a running app)
Three log surfaces, in order of usefulness:
+68
View File
@@ -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.
+286
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+147
View File
@@ -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";
}
+178
View File
@@ -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
View File
@@ -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();
+450
View File
@@ -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",
);
}
});
+96
View File
@@ -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));
}
});
+502
View File
@@ -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],
});
});
});
+403
View File
@@ -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);
});
});
+164
View File
@@ -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();
}
});
+577
View File
@@ -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(),
]);
}
});
+117
View File
@@ -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
View File
@@ -12,15 +12,22 @@
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
"e2e": "node e2e/run.mjs --suite=full",
"e2e:smoke": "node e2e/run.mjs --suite=smoke",
"e2e:ui": "node e2e/run.mjs --suite=ui",
"e2e:entities": "node e2e/run.mjs --suite=entities",
"e2e:integrations": "node e2e/run.mjs --suite=integrations",
"e2e:sync": "node e2e/run.mjs --suite=sync",
"e2e:browser": "node e2e/run.mjs --suite=browser",
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:js": "biome check src/ e2e/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "node scripts/run-with-env.mjs tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"format:js": "biome check src/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
"format:js": "biome check src/ e2e/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
"format": "pnpm format:js && pnpm format:rust",
"build:sync": "cd donut-sync && pnpm build",
"cargo": "cd src-tauri && cargo",
+221 -70
View File
@@ -442,7 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib",
"glib 0.18.5",
"libc",
]
@@ -452,10 +452,10 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -912,7 +912,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.13.0",
"cairo-sys-rs",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
"thiserror 1.0.69",
@@ -924,9 +924,9 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -1027,7 +1027,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [
"smallvec",
"target-lexicon",
"target-lexicon 0.12.16",
]
[[package]]
name = "cfg-expr"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon 0.13.5",
]
[[package]]
@@ -1857,6 +1867,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-cross-platform-webdriver",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
@@ -2470,7 +2481,7 @@ dependencies = [
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"pango",
]
@@ -2483,7 +2494,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
]
@@ -2494,11 +2505,11 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2509,13 +2520,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2525,11 +2536,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
dependencies = [
"gdk-sys",
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pkg-config",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2541,7 +2552,7 @@ dependencies = [
"gdk",
"gdkx11-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"x11",
]
@@ -2553,9 +2564,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
dependencies = [
"gdk-sys",
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
"x11",
]
@@ -2653,8 +2664,8 @@ dependencies = [
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"gio-sys 0.18.1",
"glib 0.18.5",
"libc",
"once_cell",
"pin-project-lite",
@@ -2668,13 +2679,26 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
"winapi",
]
[[package]]
name = "gio-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22"
dependencies = [
"glib-sys 0.21.5",
"gobject-sys 0.21.5",
"libc",
"system-deps 7.0.8",
"windows-sys 0.61.2",
]
[[package]]
name = "glib"
version = "0.18.5"
@@ -2687,10 +2711,10 @@ dependencies = [
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-macros 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"memchr",
"once_cell",
@@ -2698,6 +2722,27 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "glib"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [
"bitflags 2.13.0",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys 0.21.5",
"glib-macros 0.21.5",
"glib-sys 0.21.5",
"gobject-sys 0.21.5",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.18.5"
@@ -2712,6 +2757,19 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "glib-macros"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17"
dependencies = [
"heck 0.5.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
@@ -2719,7 +2777,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
dependencies = [
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
name = "glib-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c"
dependencies = [
"libc",
"system-deps 7.0.8",
]
[[package]]
@@ -2747,9 +2815,20 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
dependencies = [
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
name = "gobject-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294"
dependencies = [
"glib-sys 0.21.5",
"libc",
"system-deps 7.0.8",
]
[[package]]
@@ -2765,7 +2844,7 @@ dependencies = [
"gdk",
"gdk-pixbuf",
"gio",
"glib",
"glib 0.18.5",
"gtk-sys",
"gtk3-macros",
"libc",
@@ -2783,12 +2862,12 @@ dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pango-sys",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -3427,7 +3506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
dependencies = [
"bitflags 1.3.2",
"glib",
"glib 0.18.5",
"javascriptcore-rs-sys",
]
@@ -3437,10 +3516,10 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -3596,7 +3675,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
dependencies = [
"glib",
"glib 0.18.5",
"gtk",
"gtk-sys",
"libappindicator-sys",
@@ -4278,6 +4357,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-open-directory"
version = "0.3.2"
@@ -4301,6 +4390,17 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -4344,6 +4444,8 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
@@ -4461,7 +4563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
dependencies = [
"gio",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
"pango-sys",
@@ -4473,10 +4575,10 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -5290,8 +5392,8 @@ checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk-sys",
"js-sys",
"log",
@@ -6149,7 +6251,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
dependencies = [
"futures-channel",
"gio",
"glib",
"glib 0.18.5",
"libc",
"soup3-sys",
]
@@ -6160,11 +6262,11 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -6363,13 +6465,26 @@ version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr",
"cfg-expr 0.15.8",
"heck 0.5.0",
"pkg-config",
"toml 0.8.2",
"version-compare",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr 0.20.8",
"heck 0.5.0",
"pkg-config",
"toml 1.1.2+spec-1.1.0",
"version-compare",
]
[[package]]
name = "tao"
version = "0.35.3"
@@ -6444,6 +6559,12 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
version = "2.11.5"
@@ -6589,6 +6710,36 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-cross-platform-webdriver"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"block2",
"cairo-rs",
"glib 0.21.5",
"gtk",
"javascriptcore-rs",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tempfile",
"tokio",
"tracing",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
@@ -7032,9 +7183,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"bytes",
"libc",
@@ -7694,9 +7845,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.4"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -7964,10 +8115,10 @@ dependencies = [
"gdk",
"gdk-sys",
"gio",
"gio-sys",
"glib",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk",
"gtk-sys",
"javascriptcore-rs",
@@ -7986,15 +8137,15 @@ dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk-sys",
"javascriptcore-rs-sys",
"libc",
"pkg-config",
"soup3-sys",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
+2
View File
@@ -42,6 +42,7 @@ tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-window-state = "2"
tauri-plugin-cross-platform-webdriver = { path = "../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver", optional = true }
log = "0.4"
env_logger = "0.11"
@@ -186,3 +187,4 @@ default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
e2e = ["dep:tauri-plugin-cross-platform-webdriver"]
+16
View File
@@ -2037,6 +2037,14 @@ rm "{}"
#[tauri::command]
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() {
log::info!("App auto-updates disabled in portable mode");
return Ok(None);
@@ -2090,6 +2098,14 @@ pub async fn restart_application() -> Result<(), String> {
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<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");
let updater = AppAutoUpdater::instance();
updater
+24
View File
@@ -779,6 +779,13 @@ impl CloudAuthManager {
/// Launch/drive profiles programmatically (local API + MCP automation).
pub async fn can_use_browser_automation(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self
.entitlements()
.await
@@ -788,6 +795,13 @@ impl CloudAuthManager {
/// Edit fingerprints / use a non-native OS fingerprint.
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self
.entitlements()
.await
@@ -1224,6 +1238,16 @@ impl CloudAuthManager {
/// Get the current wayfern token, if any.
pub async fn get_wayfern_token(&self) -> Option<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;
wt.clone()
}
+16 -2
View File
@@ -295,9 +295,23 @@ impl BlocklistManager {
}
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
let url = level
let production_url = level
.url()
.ok_or_else(|| format!("No URL for level {:?}", level))?;
#[cfg(feature = "e2e")]
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
.ok()
.filter(|base| !base.is_empty())
.map(|base| {
format!(
"{}/{}",
base.trim_end_matches('/'),
level.filename().unwrap_or("blocklist.txt")
)
})
.unwrap_or_else(|| production_url.to_string());
#[cfg(not(feature = "e2e"))]
let url = production_url.to_string();
let path =
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
@@ -311,7 +325,7 @@ impl BlocklistManager {
);
let response = HTTP_CLIENT
.get(url)
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
@@ -1261,6 +1261,14 @@ mod tests {
pub async fn ensure_active_browsers_downloaded(
app_handle: tauri::AppHandle,
) -> 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 version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
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(
app_handle: tauri::AppHandle,
) -> 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();
registry
.ensure_all_binaries_exist(&app_handle)
+15 -6
View File
@@ -141,13 +141,22 @@ impl GeoIPDownloader {
},
);
// Fetch latest release from GitHub
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
#[cfg(feature = "e2e")]
let fixture_url = std::env::var("DONUT_E2E_GEOIP_DOWNLOAD_URL")
.ok()
.filter(|url| !url.is_empty());
#[cfg(not(feature = "e2e"))]
let fixture_url: Option<String> = None;
let download_url = self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?;
let download_url = if let Some(url) = fixture_url {
url
} else {
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?
};
// Create cache directory
let cache_dir = Self::get_cache_dir();
+207 -187
View File
@@ -3,6 +3,7 @@ use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
#[cfg(not(feature = "e2e"))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
@@ -14,6 +15,17 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
tauri_plugin_cross_platform_webdriver::automation_enabled()
}
#[cfg(not(feature = "e2e"))]
{
false
}
}
mod api_client;
mod api_server;
mod app_auto_updater;
@@ -1263,6 +1275,7 @@ fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
#[cfg(not(feature = "e2e"))]
fn show_main_window(app_handle: &tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
@@ -1302,6 +1315,7 @@ fn update_tray_menu(
/// Build the system tray. Best-effort: on Linux the tray depends on
/// libayatana-appindicator at runtime, so any failure here must not abort app
/// startup — the caller logs and continues without a tray.
#[cfg(not(feature = "e2e"))]
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
@@ -1391,54 +1405,65 @@ pub fn run() {
}),
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
let now = Local::now();
let timestamp = format!(
"{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis()
);
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
record.level(),
message
))
})
.build(),
)
.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
},
))
let builder = tauri::Builder::default();
#[cfg(feature = "e2e")]
let builder = builder.plugin(tauri_plugin_cross_platform_webdriver::init());
let builder = builder.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
let now = Local::now();
let timestamp = format!(
"{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis()
);
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
record.level(),
message
))
})
.build(),
);
#[cfg(not(feature = "e2e"))]
let builder = builder.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
},
));
let builder = builder
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_clipboard_manager::init());
#[cfg(not(feature = "e2e"))]
let builder = builder
// Persist window size/position across restarts. VISIBLE is excluded
// because the app hides to tray: restoring visibility would otherwise
// relaunch with an invisible window after quitting from the tray while
@@ -1453,8 +1478,9 @@ pub fn run() {
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
)
.build(),
)
.setup(|app| {
);
builder.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -1476,6 +1502,20 @@ pub fn run() {
.focused(true)
.visible(true);
#[cfg(feature = "e2e")]
let win_builder =
match tauri_plugin_cross_platform_webdriver::automation_profile_dir() {
Some(profile_dir) => win_builder
.data_directory(profile_dir.join("webview"))
// WKWebView ignores data_directory on macOS. Incognito gives every
// launched app process a non-persistent data store there, and also
// prevents WebView2/WebKitGTK caches from escaping the session on
// the other platforms. Durable app state is still exercised via
// DONUTBROWSER_DATA_ROOT; only browser-engine storage is ephemeral.
.incognito(true),
None => win_builder,
};
#[cfg(target_os = "windows")]
let win_builder = win_builder.decorations(false);
@@ -1486,8 +1526,11 @@ pub fn run() {
// dialog's "Minimize" action hides the window. Best-effort: a tray
// failure (e.g. missing libayatana-appindicator on Linux) must never
// prevent the app from launching, so we log and continue without it.
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
#[cfg(not(feature = "e2e"))]
{
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
}
}
// Intercept the window close so the frontend can ask the user whether
@@ -1530,7 +1573,7 @@ pub fn run() {
log::warn!("Failed to set global event emitter: {e}");
}
#[cfg(windows)]
#[cfg(all(windows, not(feature = "e2e")))]
{
// For Windows, register all deep links at runtime
if let Err(e) = app.deep_link().register_all() {
@@ -1538,7 +1581,7 @@ pub fn run() {
}
}
#[cfg(target_os = "macos")]
#[cfg(all(target_os = "macos", not(feature = "e2e")))]
{
// On macOS, try to register deep links for development builds
if let Err(e) = app.deep_link().register_all() {
@@ -1548,28 +1591,28 @@ pub fn run() {
}
}
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
#[cfg(not(feature = "e2e"))]
{
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
for url in urls {
let url_string = url.to_string();
log::info!("Deep link received: {url_string}");
for url in urls {
let url_string = url.to_string();
log::info!("Deep link received: {url_string}");
let handle_clone = handle.clone();
// Clone the handle for each async task
let handle_clone = handle.clone();
// Handle the URL asynchronously
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
log::error!("Failed to handle deep link URL: {e}");
}
});
tauri::async_runtime::spawn(async move {
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
log::error!("Failed to handle deep link URL: {e}");
}
});
}
}
}
});
});
}
if let Some(startup_url) = startup_url {
let handle_clone = handle.clone();
@@ -1581,30 +1624,29 @@ pub fn run() {
});
}
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
if !e2e_automation_enabled() {
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
// Set the app handle
{
let mut updater_guard = version_updater.lock().await;
updater_guard.set_app_handle(app_handle);
}
// Run startup check without holding the lock
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
log::error!("Failed to start background updates: {e}");
{
let mut updater_guard = version_updater.lock().await;
updater_guard.set_app_handle(app_handle);
}
}
});
// Start the background update task separately
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
log::error!("Failed to start background updates: {e}");
}
}
});
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
}
// Auto-start MCP server if it was previously enabled. Always log the
// decision so customer logs reveal whether MCP is actually running —
@@ -1765,12 +1807,12 @@ pub fn run() {
}
}
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
if !e2e_automation_enabled() {
let app_handle_auto_updater = app.handle().clone();
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
}
// Handle any pending URLs that were received before the window was ready
let handle_pending = handle.clone();
@@ -1793,107 +1835,85 @@ pub fn run() {
}
});
// Start periodic cleanup task for unused binaries
// Only runs when sync is not in progress to avoid deleting browsers
// that might be needed for profiles being synced from the cloud
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours
loop {
interval.tick().await;
// Check if sync is in progress before running cleanup
if let Some(scheduler) = sync::get_global_scheduler() {
if scheduler.is_sync_in_progress().await {
log::debug!("Skipping cleanup: sync is in progress");
continue;
}
}
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::error!("Periodic cleanup failed: {e}");
} else {
log::debug!("Periodic cleanup completed successfully");
}
}
});
// DNS blocklist refresh task (every 12 hours)
tauri::async_runtime::spawn(async move {
let manager = dns_blocklist::BlocklistManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
interval.tick().await; // Skip the immediate first tick
loop {
interval.tick().await;
manager.refresh_all_stale().await;
}
});
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
// Route through check_for_app_updates (not the raw check_for_updates)
// so the background loop respects portable mode and the
// disable_auto_updates setting. Previously it bypassed both, so a
// portable install would auto-download and run the NSIS installer,
// clobbering the portable folder instead of updating in place.
match app_auto_updater::check_for_app_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
if !e2e_automation_enabled() {
// Start periodic cleanup task for unused binaries.
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
loop {
interval.tick().await;
if let Some(scheduler) = sync::get_global_scheduler() {
if scheduler.is_sync_in_progress().await {
log::debug!("Skipping cleanup: sync is in progress");
continue;
}
}
Ok(None) => {
log::debug!("No app updates available");
}
Err(e) => {
log::error!("Failed to check for app updates: {e}");
}
}
}
});
// Check and download GeoIP database at startup if needed
let app_handle_geoip = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Wait a bit for the app to fully initialize
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
log::info!(
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
);
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
log::error!("Failed to download GeoIP database at startup: {e}");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::error!("Periodic cleanup failed: {e}");
} else {
log::info!("GeoIP database downloaded successfully at startup");
log::debug!("Periodic cleanup completed successfully");
}
}
Ok(false) => {
// No Wayfern profiles or GeoIP database already available
});
tauri::async_runtime::spawn(async move {
let manager = dns_blocklist::BlocklistManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
interval.tick().await;
loop {
interval.tick().await;
manager.refresh_all_stale().await;
}
Err(e) => {
log::error!("Failed to check GeoIP database status at startup: {e}");
});
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
match app_auto_updater::check_for_app_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
}
}
Ok(None) => log::debug!("No app updates available"),
Err(e) => log::error!("Failed to check for app updates: {e}"),
}
}
}
});
});
let app_handle_geoip = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
log::info!(
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
);
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
log::error!("Failed to download GeoIP database at startup: {e}");
} else {
log::info!("GeoIP database downloaded successfully at startup");
}
}
Ok(false) => {}
Err(e) => log::error!("Failed to check GeoIP database status at startup: {e}"),
}
});
}
// Start proxy cleanup task for dead browser processes
let app_handle_proxy_cleanup = app.handle().clone();
+120 -150
View File
@@ -492,6 +492,9 @@ impl SyncEngine {
return Ok(());
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Derive encryption key if encrypted sync
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
@@ -697,11 +700,6 @@ impl SyncEngine {
log::debug!("Deleted remote file: {}", path);
}
// Upload metadata.json (sanitized profile)
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// If this sync changed the local profile directory (downloaded files and/or
// deleted local files), the manifest generated at the START of the sync is
// now stale. Uploading it would advertise wrong hashes/mtimes for the files
@@ -747,37 +745,18 @@ impl SyncEngine {
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
}
// Download remote metadata and merge changes (name, tags, notes, etc.)
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated_profile = profile.clone();
// Merge fields that can be changed on other devices
updated_profile.name = remote_meta.name;
updated_profile.tags = remote_meta.tags;
updated_profile.note = remote_meta.note;
updated_profile.proxy_id = remote_meta.proxy_id;
updated_profile.vpn_id = remote_meta.vpn_id;
updated_profile.group_id = remote_meta.group_id;
updated_profile.extension_group_id = remote_meta.extension_group_id;
updated_profile.window_color = remote_meta.window_color;
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
} else {
// Fallback: just update last_sync
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
}
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
profile_manager
.save_profile(&updated_profile)
.map_err(|e| {
SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}"))
})?;
let _ = events::emit("profiles-changed", ());
let _ = events::emit(
@@ -881,6 +860,45 @@ impl SyncEngine {
Ok(profile)
}
async fn reconcile_profile_metadata(
&self,
profile: &BrowserProfile,
) -> SyncResult<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).
/// No browser files are synced.
async fn sync_cross_os_metadata(
@@ -889,34 +907,8 @@ impl SyncEngine {
profile: &BrowserProfile,
) -> SyncResult<()> {
let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await;
let profile_manager = ProfileManager::instance();
// Upload our metadata
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// Download remote metadata and merge if remote has changes
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated = profile.clone();
updated.name = remote_meta.name;
updated.tags = remote_meta.tags;
updated.note = remote_meta.note;
updated.proxy_id = remote_meta.proxy_id;
updated.vpn_id = remote_meta.vpn_id;
updated.group_id = remote_meta.group_id;
updated.extension_group_id = remote_meta.extension_group_id;
updated.window_color = remote_meta.window_color;
updated.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated);
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Sync associated entities
if let Some(proxy_id) = &profile.proxy_id {
@@ -954,18 +946,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&sanitized)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0))
.await?;
Ok(())
@@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities(
) -> Result<(), String> {
let _ = events::emit("e2e-rollover-started", ());
let internal_error = |detail: String| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": detail } }).to_string()
};
let engine = SyncEngine::create_from_settings(&app_handle)
.await
.map_err(&internal_error)?;
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
.map_err(|e| internal_error(format!("Failed to list profiles: {e}")))?;
let synced_profiles: Vec<_> = profiles
.iter()
.filter(|p| p.sync_mode != SyncMode::Disabled)
.collect();
let total_profiles = synced_profiles.len();
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
std::collections::HashSet::new();
if synced_profiles
.iter()
.any(|profile| profile.process_id.is_some())
{
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
}
let total_profiles = synced_profiles.len();
for (i, profile) in synced_profiles.iter().enumerate() {
if profile.process_id.is_some() {
running_profile_ids.insert(profile.id);
}
let id_str = profile.id.to_string();
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
}
// The remote manifest may be encrypted with the previous password. Delete
// only that manifest so the normal sync path treats every local file as an
// upload and rewrites it with the current password. Existing remote files
// remain available until their replacements have uploaded.
let key_prefix = SyncEngine::get_team_key_prefix(profile).await;
engine
.upload_profile_metadata(&id_str, profile, &key_prefix)
.await
.map_err(|e| {
internal_error(format!(
"Failed to roll over profile metadata {id_str}: {e}"
))
})?;
let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json");
engine
.client
.delete(&manifest_key, None)
.await
.map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?;
engine
.sync_profile(&app_handle, profile)
.await
.map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({
@@ -4055,37 +4065,14 @@ pub async fn rollover_encryption_for_all_entities(
);
}
// Determine which entity ids are referenced by running profiles, so we can
// defer their re-upload (changing their files mid-session would cause the
// running browser to see a different proxy/extension config than what it
// launched with).
let mut deferred_proxy_ids: std::collections::HashSet<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 synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
let total_proxies = synced_proxies.len();
let mut deferred = Vec::new();
for (i, proxy) in synced_proxies.iter().enumerate() {
if deferred_proxy_ids.contains(&proxy.id) {
deferred.push(proxy.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_proxy_sync(proxy.id.clone()).await;
}
engine
.upload_proxy(proxy)
.await
.map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
@@ -4095,17 +4082,15 @@ pub async fn rollover_encryption_for_all_entities(
let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))?
.map_err(|e| internal_error(format!("Failed to get groups: {e}")))?
};
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
let total_groups = synced_groups.len();
let mut deferred_groups = Vec::new();
for (i, group) in synced_groups.iter().enumerate() {
if deferred_group_ids.contains(&group.id) {
deferred_groups.push(group.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_group_sync(group.id.clone()).await;
}
engine
.upload_group(group)
.await
.map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
@@ -4116,17 +4101,15 @@ pub async fn rollover_encryption_for_all_entities(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
.map_err(|e| internal_error(format!("Failed to list VPN configs: {e}")))?
};
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
let total_vpns = synced_vpns.len();
let mut deferred_vpns = Vec::new();
for (i, config) in synced_vpns.iter().enumerate() {
if deferred_vpn_ids.contains(&config.id) {
deferred_vpns.push(config.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_vpn_sync(config.id.clone()).await;
}
engine
.upload_vpn(config)
.await
.map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
@@ -4136,14 +4119,15 @@ pub async fn rollover_encryption_for_all_entities(
let extensions = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_extensions()
.map_err(|e| format!("Failed to list extensions: {e}"))?
.map_err(|e| internal_error(format!("Failed to list extensions: {e}")))?
};
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
let total_exts = synced_exts.len();
for (i, ext) in synced_exts.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_sync(ext.id.clone()).await;
}
engine
.upload_extension(ext)
.await
.map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
@@ -4153,37 +4137,23 @@ pub async fn rollover_encryption_for_all_entities(
let ext_groups = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_groups()
.map_err(|e| format!("Failed to list extension groups: {e}"))?
.map_err(|e| internal_error(format!("Failed to list extension groups: {e}")))?
};
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
let total_eg = synced_ext_groups.len();
for (i, group) in synced_ext_groups.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_group_sync(group.id.clone()).await;
}
engine.upload_extension_group(group).await.map_err(|e| {
internal_error(format!(
"Failed to roll over extension group {}: {e}",
group.id
))
})?;
let _ = events::emit(
"e2e-rollover-progress",
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
);
}
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(scheduler) = super::get_global_scheduler() {
for id in deferred {
scheduler.queue_proxy_sync(id).await;
}
for id in deferred_groups {
scheduler.queue_group_sync(id).await;
}
for id in deferred_vpns {
scheduler.queue_vpn_sync(id).await;
}
}
});
}
let _ = events::emit("e2e-rollover-completed", ());
Ok(())
}
+8 -137
View File
@@ -8,7 +8,6 @@ use std::path::Path;
use std::time::SystemTime;
use super::types::{SyncError, SyncResult};
use crate::profile::types::BrowserProfile;
/// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync
@@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/DawnWebGPUCache/**",
"**/BrowserMetrics*",
"**/.DS_Store",
// Profile metadata is a separately reconciled LWW config object. Including
// it in the browser-file manifest creates two competing sync mechanisms and
// lets a stale in-memory profile overwrite a metadata download.
"metadata.json",
".donut-sync/**",
// Orphaned local-only marker from earlier rollover-based fingerprint
// regeneration. Keep excluding it so any markers left on disk from
@@ -224,39 +227,6 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
fn hash_sanitized_metadata(path: &Path) -> Result<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
/// Returns None if the file doesn't exist (was deleted)
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
@@ -372,19 +342,7 @@ pub fn generate_manifest(
*max_mtime = (*max_mtime).max(mtime);
// Check cache for existing hash
let hash = if relative_path == "metadata.json" {
// Special case: sanitize metadata.json before hashing to prevent sync loops
match hash_sanitized_metadata(&path)? {
Some(computed_hash) => computed_hash,
None => {
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
}
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
cached_hash.to_string()
} else {
match hash_file(&path)? {
@@ -651,21 +609,15 @@ mod tests {
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root
let profile = BrowserProfile::default();
fs::write(
profile_dir.join("metadata.json"),
serde_json::to_string(&profile).unwrap(),
)
.unwrap();
fs::write(profile_dir.join("metadata.json"), "{}").unwrap();
let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
assert!(
paths.contains(&"metadata.json"),
"metadata.json should be synced"
!paths.contains(&"metadata.json"),
"metadata.json is reconciled separately from browser files"
);
assert!(
paths.contains(&"profile/Default/Cookies"),
@@ -865,85 +817,4 @@ mod tests {
assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty());
}
#[test]
fn test_generate_manifest_sanitizes_metadata() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
let profile_id = uuid::Uuid::new_v4();
let metadata_path = profile_dir.join("metadata.json");
let profile = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(100),
process_id: Some(1234),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
let mut cache = HashCache::default();
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash1 = manifest1
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Update volatile fields
let profile2 = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(200),
process_id: Some(5678),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash2 = manifest2
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be identical because volatile fields are sanitized
assert_eq!(
hash1, hash2,
"Metadata hash should be stable across last_sync/process_id updates"
);
// Change a non-volatile field
let profile3 = BrowserProfile {
id: profile_id,
name: "changed-name".to_string(),
last_sync: Some(200),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash3 = manifest3
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be different because name changed
assert_ne!(
hash1, hash3,
"Metadata hash should change when non-volatile fields change"
);
}
}
+18 -1
View File
@@ -243,6 +243,17 @@ export function RailNav({
handleClick,
} = useLogoEasterEgg({ currentPage, onNavigate });
useEffect(() => {
if (!moreOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMoreOpen(false);
}
};
document.addEventListener("keydown", closeOnEscape);
return () => document.removeEventListener("keydown", closeOnEscape);
}, [moreOpen]);
return (
<nav className="relative flex w-10 shrink-0 flex-col items-center gap-1 border-r border-border bg-background py-2">
{!isHidden ? (
@@ -379,11 +390,16 @@ export function RailNav({
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 }) => (
<button
key={page}
type="button"
role="menuitem"
onClick={() => {
setMoreOpen(false);
onNavigate(page);
@@ -405,6 +421,7 @@ export function RailNav({
))}
<button
type="button"
role="menuitem"
onClick={() => {
setMoreOpen(false);
onOpenAbout();