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
+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,
);
});
});