mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-24 13:20:57 +02:00
chore: add cross-platform webdriver tests
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { CdpClient } from "../lib/cdp.mjs";
|
||||
import {
|
||||
defaultWayfernPath,
|
||||
inspectWayfern,
|
||||
seedWayfern,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
|
||||
async function request(url, { method = "GET", token, body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let value = null;
|
||||
if (text) {
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
value = text;
|
||||
}
|
||||
}
|
||||
return { response, value };
|
||||
}
|
||||
|
||||
async function prepareWayfern(app) {
|
||||
const localBundle = defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT);
|
||||
if (existsSync(localBundle)) {
|
||||
const wayfern = inspectWayfern(localBundle);
|
||||
await seedWayfern(app.dataRoot, wayfern);
|
||||
return { version: wayfern.version, source: "local fixture" };
|
||||
}
|
||||
|
||||
await app.start();
|
||||
const current = await app.invoke("fetch_browser_versions_with_count", {
|
||||
browserStr: "wayfern",
|
||||
});
|
||||
assert.ok(
|
||||
current.versions.length > 0,
|
||||
"No Wayfern build is published for this platform",
|
||||
);
|
||||
const version = current.versions[0];
|
||||
await app.invoke("download_browser", {
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
});
|
||||
return { version, source: "published download" };
|
||||
}
|
||||
|
||||
function processExists(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(app, pid) {
|
||||
await app.waitFor(() => !processExists(pid), {
|
||||
timeoutMs: 20_000,
|
||||
description: `Wayfern process ${pid} to exit`,
|
||||
});
|
||||
}
|
||||
|
||||
function assertIdleResourceBounds(pid) {
|
||||
if (process.platform === "win32") return;
|
||||
const output = execFileSync("ps", ["-o", "rss=,%cpu=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const [rssText, cpuText] = output.split(/\s+/);
|
||||
const rssKiB = Number(rssText);
|
||||
const cpuPercent = Number(cpuText);
|
||||
assert.ok(
|
||||
rssKiB > 0 && rssKiB < 2_000_000,
|
||||
`Wayfern main process RSS is ${rssKiB} KiB`,
|
||||
);
|
||||
assert.ok(
|
||||
cpuPercent >= 0 && cpuPercent < 200,
|
||||
`Wayfern main process CPU is ${cpuPercent}%`,
|
||||
);
|
||||
}
|
||||
|
||||
function realWayfernTermsPath() {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(
|
||||
process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
return path.join(
|
||||
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"),
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
|
||||
async function snapshotFile(file) {
|
||||
try {
|
||||
const [contents, metadata] = await Promise.all([
|
||||
readFile(file),
|
||||
stat(file, { bigint: true }),
|
||||
]);
|
||||
return {
|
||||
exists: true,
|
||||
contents: contents.toString("base64"),
|
||||
size: metadata.size.toString(),
|
||||
mtime: metadata.mtimeNs.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { exists: false };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRealProfile(app, version, name, fingerprint = null) {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name,
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
wayfernConfig: {
|
||||
fingerprint,
|
||||
randomize_fingerprint_on_launch: false,
|
||||
geoip: false,
|
||||
},
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
}
|
||||
|
||||
test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and process cleanup", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const realTermsFile = realWayfernTermsPath();
|
||||
const realTermsBefore = await snapshotFile(realTermsFile);
|
||||
const hasLocalWayfern = existsSync(
|
||||
defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT),
|
||||
);
|
||||
const app = appFromEnvironment("browser-wayfern", {
|
||||
seedVersionCache: hasLocalWayfern,
|
||||
});
|
||||
let cdp;
|
||||
let browserPid;
|
||||
try {
|
||||
const prepared = await prepareWayfern(app);
|
||||
if (!app.session) await app.start();
|
||||
|
||||
assert.equal(await app.invoke("check_wayfern_downloaded"), true);
|
||||
assert.equal(await app.invoke("check_wayfern_terms_accepted"), false);
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
assert.equal(await app.invoke("check_wayfern_terms_accepted"), true);
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("get_downloaded_browser_versions", {
|
||||
browserStr: "wayfern",
|
||||
})
|
||||
).includes(prepared.version),
|
||||
);
|
||||
assert.equal(
|
||||
await app.invoke("check_browser_exists", {
|
||||
browserStr: "wayfern",
|
||||
version: prepared.version,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(await app.invoke("check_missing_binaries"), []);
|
||||
assert.deepEqual(await app.invoke("ensure_all_binaries_exist"), []);
|
||||
assert.deepEqual(await app.invoke("ensure_active_browsers_downloaded"), []);
|
||||
assert.deepEqual(await app.invoke("get_supported_browsers"), ["wayfern"]);
|
||||
assert.equal(
|
||||
await app.invoke("is_browser_supported_on_platform", {
|
||||
browserStr: "wayfern",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("fetch_browser_versions_cached_first", {
|
||||
browserStr: "wayfern",
|
||||
})
|
||||
).some((item) => item.version === prepared.version),
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("fetch_browser_versions_with_count_cached_first", {
|
||||
browserStr: "wayfern",
|
||||
})
|
||||
).versions.includes(prepared.version),
|
||||
);
|
||||
assert.equal(
|
||||
(await app.invoke("get_browser_release_types", { browserStr: "wayfern" }))
|
||||
.stable,
|
||||
prepared.version,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("cancel_download", {
|
||||
browserStr: "wayfern",
|
||||
version: prepared.version,
|
||||
}),
|
||||
/No active download/,
|
||||
);
|
||||
|
||||
const sample = await app.invoke("generate_sample_fingerprint", {
|
||||
browser: "wayfern",
|
||||
version: prepared.version,
|
||||
configJson: JSON.stringify({ geoip: false }),
|
||||
});
|
||||
const fingerprint = JSON.parse(sample);
|
||||
assert.ok(
|
||||
Object.keys(fingerprint).length >= 10,
|
||||
"Wayfern returned an incomplete fingerprint",
|
||||
);
|
||||
|
||||
const profile = await createRealProfile(
|
||||
app,
|
||||
prepared.version,
|
||||
`Real Wayfern (${prepared.source})`,
|
||||
);
|
||||
assert.ok(profile.wayfern_config.fingerprint);
|
||||
assert.ok(
|
||||
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10,
|
||||
);
|
||||
assert.equal(await app.invoke("check_missing_geoip_database"), true);
|
||||
assert.equal(await app.invoke("is_geoip_database_available"), false);
|
||||
await app.invoke("download_geoip_database");
|
||||
assert.equal(await app.invoke("is_geoip_database_available"), true);
|
||||
assert.equal(await app.invoke("check_missing_geoip_database"), false);
|
||||
await app.invoke("update_wayfern_config", {
|
||||
profileId: profile.id,
|
||||
config: profile.wayfern_config,
|
||||
});
|
||||
await app.invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId: profile.id,
|
||||
exitIp: "8.8.8.8",
|
||||
});
|
||||
const consistency = await app.invoke(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{
|
||||
profileId: profile.id,
|
||||
},
|
||||
);
|
||||
assert.equal(typeof consistency, "object");
|
||||
|
||||
const directProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
const directLaunch = await app.invoke("launch_browser_profile", {
|
||||
profile: directProfile,
|
||||
url: `${fixtureUrl}/direct-command`,
|
||||
});
|
||||
assert.ok(directLaunch.process_id);
|
||||
await app.invoke("open_url_with_profile", {
|
||||
profileId: profile.id,
|
||||
url: `${fixtureUrl}/direct-open`,
|
||||
});
|
||||
await app.invoke("kill_browser_profile", { profile: directLaunch });
|
||||
await waitForProcessExit(app, directLaunch.process_id);
|
||||
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
onboarding_completed: true,
|
||||
},
|
||||
});
|
||||
const port = await app.invoke("start_api_server", { port: 0 });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const launched = await request(`${base}/v1/profiles/${profile.id}/run`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/wayfern`, headless: true },
|
||||
});
|
||||
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
|
||||
assert.equal(launched.value.headless, true);
|
||||
|
||||
cdp = await CdpClient.connect(launched.value.remote_debugging_port);
|
||||
await cdp.waitFor(`document.title === "Donut E2E Browser Fixture"`, {
|
||||
description: "fixture page title",
|
||||
});
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.querySelector('#path').textContent"),
|
||||
"/wayfern",
|
||||
);
|
||||
assert.equal(
|
||||
await cdp.evaluate(
|
||||
"document.querySelector('#fixture-button').click(); document.querySelector('#fixture-button').dataset.clicked",
|
||||
),
|
||||
"yes",
|
||||
);
|
||||
const echo = await cdp.evaluate(
|
||||
`fetch(${JSON.stringify(`${fixtureUrl}/api/echo`)}, {
|
||||
method: "POST",
|
||||
body: "wayfern-cdp-body"
|
||||
}).then((response) => response.json())`,
|
||||
);
|
||||
assert.equal(echo.method, "POST");
|
||||
assert.equal(echo.body, "wayfern-cdp-body");
|
||||
assert.ok(echo.userAgent.length > 20);
|
||||
assert.match(await cdp.evaluate("document.cookie"), /donut_e2e=browser-ok/);
|
||||
|
||||
const runningProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
browserPid = runningProfile.process_id;
|
||||
assert.equal(
|
||||
await app.invoke("check_browser_status", { profile: runningProfile }),
|
||||
true,
|
||||
);
|
||||
assertIdleResourceBounds(browserPid);
|
||||
if (process.platform !== "win32") {
|
||||
const command = execFileSync(
|
||||
"ps",
|
||||
["-ww", "-o", "command=", "-p", String(browserPid)],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
assert.match(
|
||||
command,
|
||||
new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
);
|
||||
}
|
||||
|
||||
const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/opened-via-api` },
|
||||
});
|
||||
assert.equal(opened.response.status, 200);
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const targets = await fetch(
|
||||
`http://127.0.0.1:${launched.value.remote_debugging_port}/json`,
|
||||
).then((response) => response.json());
|
||||
return targets.some((target) => target.url.includes("/opened-via-api"));
|
||||
},
|
||||
{ timeoutMs: 20_000, description: "API-opened Wayfern target" },
|
||||
);
|
||||
|
||||
const killed = await request(`${base}/v1/profiles/${profile.id}/kill`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(killed.response.status, 204);
|
||||
cdp.close();
|
||||
cdp = null;
|
||||
await waitForProcessExit(app, browserPid);
|
||||
const stoppedProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
assert.equal(
|
||||
await app.invoke("check_browser_status", { profile: stoppedProfile }),
|
||||
false,
|
||||
);
|
||||
|
||||
const batchProfile = await createRealProfile(
|
||||
app,
|
||||
prepared.version,
|
||||
"Wayfern Batch Automation",
|
||||
sample,
|
||||
);
|
||||
const batchRun = await request(`${base}/v1/profiles/batch/run`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
profile_ids: [batchProfile.id],
|
||||
url: `${fixtureUrl}/batch`,
|
||||
headless: true,
|
||||
},
|
||||
});
|
||||
assert.equal(batchRun.response.status, 200);
|
||||
assert.equal(
|
||||
batchRun.value.results[0].ok,
|
||||
true,
|
||||
batchRun.value.results[0].error,
|
||||
);
|
||||
const batchCdp = await CdpClient.connect(
|
||||
batchRun.value.results[0].remote_debugging_port,
|
||||
);
|
||||
assert.equal(
|
||||
await batchCdp.waitFor("window.__fixtureReady === true"),
|
||||
true,
|
||||
);
|
||||
batchCdp.close();
|
||||
const batchStop = await request(`${base}/v1/profiles/batch/stop`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { profile_ids: [batchProfile.id] },
|
||||
});
|
||||
assert.equal(batchStop.response.status, 200);
|
||||
assert.equal(
|
||||
batchStop.value.results[0].ok,
|
||||
true,
|
||||
batchStop.value.results[0].error,
|
||||
);
|
||||
|
||||
await app.invoke("stop_api_server");
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
await app.invoke("delete_profile", { profileId: batchProfile.id });
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
cdp?.close();
|
||||
if (app.session && browserPid && processExists(browserPid)) {
|
||||
const profile = (
|
||||
await app.invoke("list_browser_profiles").catch(() => [])
|
||||
).find((item) => item.process_id === browserPid);
|
||||
if (profile)
|
||||
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
|
||||
}
|
||||
await app.close();
|
||||
assert.deepEqual(
|
||||
await snapshotFile(realTermsFile),
|
||||
realTermsBefore,
|
||||
"the browser suite modified the real Wayfern terms marker",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { allCoveredCommands, commandCoverage } from "../coverage-map.mjs";
|
||||
import { WebDriverClient } from "../lib/webdriver.mjs";
|
||||
|
||||
function registeredCommands(source) {
|
||||
const match = source.match(
|
||||
/invoke_handler\(tauri::generate_handler!\[(.*?)\]\)/s,
|
||||
);
|
||||
assert.ok(match, "Could not locate Tauri generate_handler! command registry");
|
||||
const withoutComments = match[1].replace(/\/\/[^\n]*/g, "");
|
||||
return [
|
||||
...withoutComments.matchAll(/([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*,/g),
|
||||
].map((item) => item[1]);
|
||||
}
|
||||
|
||||
function commandHasExecutableEvidence(source, command) {
|
||||
const name = command
|
||||
.split("::")
|
||||
.at(-1)
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(
|
||||
`(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
|
||||
).test(source);
|
||||
}
|
||||
|
||||
test("every Tauri command has exactly one E2E owner and evidence level", async () => {
|
||||
const root =
|
||||
process.env.DONUT_E2E_PROJECT_ROOT ??
|
||||
path.resolve(import.meta.dirname, "../..");
|
||||
const source = await readFile(
|
||||
path.join(root, "src-tauri", "src", "lib.rs"),
|
||||
"utf8",
|
||||
);
|
||||
const registered = registeredCommands(source);
|
||||
const covered = allCoveredCommands();
|
||||
assert.deepEqual(
|
||||
[...new Set(covered)].sort(),
|
||||
covered.slice().sort(),
|
||||
"The E2E coverage map contains duplicate command ownership",
|
||||
);
|
||||
assert.deepEqual(covered.slice().sort(), registered.slice().sort());
|
||||
|
||||
for (const [name, entry] of Object.entries(commandCoverage)) {
|
||||
assert.ok(
|
||||
["integration", "contract", "host-mutating"].includes(entry.level),
|
||||
name,
|
||||
);
|
||||
assert.ok(entry.commands.length > 0, `${name} has no commands`);
|
||||
if (entry.level === "host-mutating") {
|
||||
assert.ok(
|
||||
entry.reason?.length > 80,
|
||||
`${name} needs an explicit safety reason`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const suiteSource = await readFile(
|
||||
path.join(root, "e2e", "tests", `${entry.suite}.test.mjs`),
|
||||
"utf8",
|
||||
);
|
||||
for (const command of entry.commands) {
|
||||
assert.equal(
|
||||
commandHasExecutableEvidence(suiteSource, command),
|
||||
true,
|
||||
`${command} is assigned to ${entry.suite} but has no executable invoke evidence`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("WebDriver client preserves application values that contain an error field", async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({ value: { ok: false, error: "application error" } }),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
const client = new WebDriverClient(`http://127.0.0.1:${address.port}`);
|
||||
assert.deepEqual(await client.request("GET", "/value"), {
|
||||
ok: false,
|
||||
error: "application error",
|
||||
});
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,502 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
||||
|
||||
async function createProfile(app, name = "Entity Profile") {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name,
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
// CRUD-focused suites use a deterministic stored fingerprint. The browser
|
||||
// suite separately exercises real Wayfern fingerprint generation.
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
}
|
||||
|
||||
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
|
||||
await withApp("entities-core", async (app) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
const group = await app.invoke("create_profile_group", {
|
||||
name: "Research",
|
||||
});
|
||||
assert.equal(group.name, "Research");
|
||||
const renamedGroup = await app.invoke("update_profile_group", {
|
||||
groupId: group.id,
|
||||
name: "Research Team",
|
||||
});
|
||||
assert.equal(renamedGroup.name, "Research Team");
|
||||
|
||||
const duplicateError = await app.invokeError("create_profile_group", {
|
||||
name: "Research Team",
|
||||
});
|
||||
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
|
||||
|
||||
const proxy = await app.invoke("create_stored_proxy", {
|
||||
name: "Local Dead Proxy",
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 9,
|
||||
username: "e2e-user",
|
||||
password: "e2e-pass",
|
||||
},
|
||||
});
|
||||
assert.equal(proxy.proxy_settings.password, "e2e-pass");
|
||||
const updatedProxy = await app.invoke("update_stored_proxy", {
|
||||
proxyId: proxy.id,
|
||||
name: "Updated Proxy",
|
||||
proxySettings: {
|
||||
proxy_type: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 9,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
});
|
||||
assert.equal(updatedProxy.name, "Updated Proxy");
|
||||
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
|
||||
|
||||
const parsed = await app.invoke("parse_txt_proxies", {
|
||||
content: [
|
||||
"http://one.example:8080",
|
||||
"two.example:1080:user:pass",
|
||||
"not a proxy",
|
||||
].join("\n"),
|
||||
});
|
||||
assert.equal(parsed.length, 3);
|
||||
assert.ok(parsed.some((result) => result.status === "parsed"));
|
||||
assert.ok(parsed.some((result) => result.status === "invalid"));
|
||||
const parsedProxy = parsed.find((result) => result.status === "parsed");
|
||||
const { status: _status, ...parsedProxyFields } = parsedProxy;
|
||||
const parsedImport = await app.invoke("import_proxies_from_parsed", {
|
||||
parsedProxies: [parsedProxyFields],
|
||||
namePrefix: "Parsed",
|
||||
});
|
||||
assert.equal(parsedImport.imported_count, 1);
|
||||
|
||||
const validityError = await app.invokeError("check_proxy_validity", {
|
||||
proxyId: proxy.id,
|
||||
proxySettings: null,
|
||||
});
|
||||
assert.match(validityError, /Proxy check failed|Could not connect/i);
|
||||
const cachedValidity = await app.invoke("get_cached_proxy_check", {
|
||||
proxyId: proxy.id,
|
||||
});
|
||||
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
|
||||
|
||||
const exported = JSON.parse(
|
||||
await app.invoke("export_proxies", { format: "json" }),
|
||||
);
|
||||
assert.equal(exported.proxies.length, 2);
|
||||
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
|
||||
assert.ok(exported.proxies.some((item) => item.name === "Parsed Proxy 1"));
|
||||
const importResult = await app.invoke("import_proxies_json", {
|
||||
content: JSON.stringify({
|
||||
version: "1",
|
||||
source: "Donut Browser",
|
||||
exported_at: new Date().toISOString(),
|
||||
proxies: [
|
||||
{
|
||||
name: "Imported Proxy",
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8081,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(importResult.imported_count, 1);
|
||||
|
||||
const profile = await createProfile(app);
|
||||
assert.equal(profile.name, "Entity Profile");
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("update_profile_proxy", {
|
||||
profileId: profile.id,
|
||||
proxyId: proxy.id,
|
||||
})
|
||||
).proxy_id,
|
||||
proxy.id,
|
||||
);
|
||||
await app.invoke("assign_profiles_to_group", {
|
||||
profileIds: [profile.id],
|
||||
groupId: group.id,
|
||||
});
|
||||
await app.invoke("rename_profile", {
|
||||
profileId: profile.id,
|
||||
newName: "Renamed Profile",
|
||||
});
|
||||
await app.invoke("update_profile_tags", {
|
||||
profileId: profile.id,
|
||||
tags: ["alpha", "automation"],
|
||||
});
|
||||
await app.invoke("update_profile_note", {
|
||||
profileId: profile.id,
|
||||
note: "Extensive E2E metadata",
|
||||
});
|
||||
await app.invoke("update_profile_window_color", {
|
||||
profileId: profile.id,
|
||||
windowColor: "#123456",
|
||||
});
|
||||
await app.invoke("update_profile_launch_hook", {
|
||||
profileId: profile.id,
|
||||
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
|
||||
});
|
||||
const invalidHook = await app.invokeError("update_profile_launch_hook", {
|
||||
profileId: profile.id,
|
||||
launchHook: "file:///etc/passwd",
|
||||
});
|
||||
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
|
||||
await app.invoke("update_profile_proxy_bypass_rules", {
|
||||
profileId: profile.id,
|
||||
rules: ["localhost", "*.internal.example"],
|
||||
});
|
||||
await app.invoke("update_profile_dns_blocklist", {
|
||||
profileId: profile.id,
|
||||
dnsBlocklist: "light",
|
||||
});
|
||||
await app.invoke("update_profile_clear_on_close", {
|
||||
profileId: profile.id,
|
||||
clearOnClose: true,
|
||||
});
|
||||
|
||||
const profiles = await app.invoke("list_browser_profiles");
|
||||
const changed = profiles.find((item) => item.id === profile.id);
|
||||
assert.deepEqual(changed.tags, ["alpha", "automation"]);
|
||||
assert.equal(changed.note, "Extensive E2E metadata");
|
||||
assert.equal(changed.window_color, "#123456");
|
||||
assert.equal(changed.group_id, group.id);
|
||||
assert.deepEqual(changed.proxy_bypass_rules, [
|
||||
"localhost",
|
||||
"*.internal.example",
|
||||
]);
|
||||
assert.equal(changed.dns_blocklist, "light");
|
||||
assert.equal(changed.clear_on_close, true);
|
||||
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
|
||||
"alpha",
|
||||
"automation",
|
||||
]);
|
||||
|
||||
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
|
||||
const importRoot = path.join(app.root, "profile-import-fixture");
|
||||
const importProfile = path.join(importRoot, "Default");
|
||||
await mkdir(importProfile, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(importProfile, "Preferences"),
|
||||
JSON.stringify({ profile: { name: "Imported fixture" } }),
|
||||
);
|
||||
const scanned = await app.invoke("scan_folder_for_profiles", {
|
||||
folderPath: importRoot,
|
||||
});
|
||||
assert.equal(scanned.length, 1);
|
||||
assert.equal(scanned[0].mapped_browser, "wayfern");
|
||||
const importBatch = await app.invoke("import_browser_profiles", {
|
||||
items: [
|
||||
{
|
||||
source_path: scanned[0].path,
|
||||
browser_type: scanned[0].browser,
|
||||
new_profile_name: "Imported Profile",
|
||||
proxy_id: null,
|
||||
vpn_id: null,
|
||||
},
|
||||
],
|
||||
groupId: null,
|
||||
duplicateStrategy: "rename",
|
||||
wayfernConfig: null,
|
||||
});
|
||||
assert.equal(importBatch.imported_count + importBatch.failed_count, 1);
|
||||
const archivePath = path.join(app.root, "profile-import-fixture.zip");
|
||||
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
|
||||
const archiveScan = await app.invoke("scan_profile_archive", {
|
||||
archivePath,
|
||||
});
|
||||
assert.ok(Array.isArray(archiveScan.profiles));
|
||||
await app.invoke("cleanup_profile_import_scratch", {
|
||||
extractedDir: archiveScan.extracted_dir,
|
||||
});
|
||||
|
||||
const clone = await app.invoke("clone_profile", {
|
||||
profileId: profile.id,
|
||||
name: "Cloned Profile",
|
||||
});
|
||||
assert.notEqual(clone.id, profile.id);
|
||||
assert.equal(clone.name, "Cloned Profile");
|
||||
const counts = await app.invoke("get_groups_with_profile_counts");
|
||||
assert.equal(counts.find((item) => item.id === group.id).count, 2);
|
||||
assert.equal((await app.invoke("get_profile_groups")).length, 1);
|
||||
|
||||
await app.invoke("delete_selected_profiles", {
|
||||
profileIds: [profile.id, clone.id],
|
||||
});
|
||||
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
|
||||
await app.invoke("delete_profile_group", { groupId: group.id });
|
||||
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||
for (const importedProxy of (await app.invoke("get_stored_proxies")).filter(
|
||||
(item) =>
|
||||
item.name === "Imported Proxy" || item.name.startsWith("Parsed Proxy"),
|
||||
)) {
|
||||
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
|
||||
await withApp("entities-network-extension", async (app) => {
|
||||
const profile = await createProfile(app, "Assignment Profile");
|
||||
const extension = await app.invoke("add_extension", {
|
||||
name: "E2E Fixture Extension",
|
||||
fileName: "fixture.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
assert.equal(extension.name, "Donut E2E Fixture");
|
||||
assert.equal(extension.version, "1.0.0");
|
||||
const extensionGroup = await app.invoke("create_extension_group", {
|
||||
name: "Automation Extensions",
|
||||
});
|
||||
const populated = await app.invoke("add_extension_to_group", {
|
||||
groupId: extensionGroup.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
assert.deepEqual(populated.extension_ids, [extension.id]);
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: extensionGroup.id,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("get_extension_group_for_profile", {
|
||||
profileId: profile.id,
|
||||
})
|
||||
).id,
|
||||
extensionGroup.id,
|
||||
);
|
||||
const renamed = await app.invoke("update_extension", {
|
||||
extensionId: extension.id,
|
||||
name: "Renamed Fixture Extension",
|
||||
fileName: null,
|
||||
fileData: null,
|
||||
});
|
||||
assert.equal(renamed.name, "Renamed Fixture Extension");
|
||||
assert.equal(
|
||||
await app.invoke("get_extension_icon", { extensionId: extension.id }),
|
||||
null,
|
||||
);
|
||||
const changedGroup = await app.invoke("update_extension_group", {
|
||||
groupId: extensionGroup.id,
|
||||
name: "Renamed Extension Group",
|
||||
extensionIds: [extension.id],
|
||||
});
|
||||
assert.equal(changedGroup.name, "Renamed Extension Group");
|
||||
assert.equal((await app.invoke("list_extensions")).length, 1);
|
||||
assert.equal((await app.invoke("list_extension_groups")).length, 1);
|
||||
await app.invoke("remove_extension_from_group", {
|
||||
groupId: extensionGroup.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: null,
|
||||
});
|
||||
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
|
||||
await app.invoke("delete_extension", { extensionId: extension.id });
|
||||
|
||||
const vpn = await app.invoke("create_vpn_config_manual", {
|
||||
name: "E2E WireGuard",
|
||||
vpnType: "WireGuard",
|
||||
configData: wireGuardFixture(),
|
||||
});
|
||||
assert.equal(vpn.name, "E2E WireGuard");
|
||||
assert.equal(
|
||||
(await app.invoke("get_vpn_config", { vpnId: vpn.id })).id,
|
||||
vpn.id,
|
||||
);
|
||||
assert.equal((await app.invoke("list_vpn_configs")).length, 1);
|
||||
const updatedVpn = await app.invoke("update_vpn_config", {
|
||||
vpnId: vpn.id,
|
||||
name: "Updated WireGuard",
|
||||
});
|
||||
assert.equal(updatedVpn.name, "Updated WireGuard");
|
||||
assert.equal(
|
||||
(await app.invoke("get_vpn_status", { vpnId: vpn.id })).connected,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("update_profile_vpn", {
|
||||
profileId: profile.id,
|
||||
vpnId: vpn.id,
|
||||
})
|
||||
).vpn_id,
|
||||
vpn.id,
|
||||
);
|
||||
assert.deepEqual(await app.invoke("list_active_vpn_connections"), []);
|
||||
await app.invoke("disconnect_vpn", { vpnId: vpn.id });
|
||||
const unknownVpnError = await app.invokeError("check_vpn_validity", {
|
||||
vpnId: "missing-vpn",
|
||||
});
|
||||
assert.match(unknownVpnError, /not found|Failed to start VPN worker/i);
|
||||
const importedVpn = await app.invoke("import_vpn_config", {
|
||||
content: wireGuardFixture(),
|
||||
filename: "imported.conf",
|
||||
name: "Imported WireGuard",
|
||||
});
|
||||
assert.equal(importedVpn.success, true);
|
||||
await app.invoke("delete_vpn_config", { vpnId: importedVpn.vpn_id });
|
||||
await app.invoke("delete_vpn_config", { vpnId: vpn.id });
|
||||
|
||||
const dns = await app.invoke("set_custom_dns_config", {
|
||||
sources: [`${process.env.DONUT_E2E_FIXTURE_URL}/dns.txt`],
|
||||
blockDomains: [" Ads.Example.com ", "tracker.example"],
|
||||
allowDomains: ["safe.example"],
|
||||
allowlistMode: false,
|
||||
});
|
||||
assert.deepEqual(dns.block_domains, ["ads.example.com", "tracker.example"]);
|
||||
const textExport = await app.invoke("export_custom_dns_rules", {
|
||||
format: "txt",
|
||||
});
|
||||
assert.match(textExport, /ads\.example\.com/);
|
||||
await app.invoke("import_custom_dns_rules", {
|
||||
format: "txt",
|
||||
content: "||malware.example^\n@@||allowed.example^\n",
|
||||
});
|
||||
const importedDns = await app.invoke("get_custom_dns_config");
|
||||
assert.ok(importedDns.block_domains.includes("malware.example"));
|
||||
assert.ok(importedDns.allow_domains.includes("allowed.example"));
|
||||
await app.invoke("refresh_dns_blocklists");
|
||||
const blocklistStatus = await app.invoke("get_dns_blocklist_cache_status");
|
||||
assert.equal(blocklistStatus.length, 5);
|
||||
assert.ok(
|
||||
blocklistStatus.every(
|
||||
(entry) => entry.is_cached && entry.is_fresh && entry.entry_count === 2,
|
||||
),
|
||||
);
|
||||
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
});
|
||||
});
|
||||
|
||||
test("cookie import/copy/export, profile encryption, and traffic-stat read/clear paths", async () => {
|
||||
await withApp("entities-cookies-password", async (app) => {
|
||||
const source = await createProfile(app, "Cookie Source");
|
||||
const target = await createProfile(app, "Cookie Target");
|
||||
const cookieJson = JSON.stringify([
|
||||
{
|
||||
name: "session",
|
||||
value: "isolated-secret-cookie",
|
||||
domain: "fixture.local",
|
||||
path: "/",
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
expirationDate: 2_000_000_000,
|
||||
},
|
||||
]);
|
||||
const imported = await app.invoke("import_cookies_from_file", {
|
||||
profileId: source.id,
|
||||
content: cookieJson,
|
||||
});
|
||||
assert.equal(imported.cookies_imported, 1);
|
||||
const cookies = await app.invoke("read_profile_cookies", {
|
||||
profileId: source.id,
|
||||
});
|
||||
assert.equal(cookies.total_count, 1);
|
||||
assert.equal(cookies.domains[0].cookies[0].value, "isolated-secret-cookie");
|
||||
const stats = await app.invoke("get_profile_cookie_stats", {
|
||||
profileId: source.id,
|
||||
});
|
||||
assert.equal(stats.total_count, 1);
|
||||
const copied = await app.invoke("copy_profile_cookies", {
|
||||
request: {
|
||||
source_profile_id: source.id,
|
||||
target_profile_ids: [target.id],
|
||||
selected_cookies: [{ domain: "fixture.local", name: "session" }],
|
||||
},
|
||||
});
|
||||
assert.equal(copied[0].cookies_copied, 1);
|
||||
assert.match(
|
||||
await app.invoke("export_profile_cookies", {
|
||||
profileId: target.id,
|
||||
format: "json",
|
||||
}),
|
||||
/isolated-secret-cookie/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invoke("export_profile_cookies", {
|
||||
profileId: target.id,
|
||||
format: "netscape",
|
||||
}),
|
||||
/fixture\.local/,
|
||||
);
|
||||
|
||||
await app.invoke("set_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
assert.equal(
|
||||
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||
false,
|
||||
);
|
||||
const wrong = await app.invokeError("verify_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "wrong password",
|
||||
});
|
||||
assert.match(wrong, /INCORRECT_PASSWORD/);
|
||||
await app.invoke("verify_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
await app.invoke("change_profile_password", {
|
||||
profileId: source.id,
|
||||
oldPassword: "correct horse battery staple",
|
||||
newPassword: "new correct horse battery staple",
|
||||
});
|
||||
await app.invoke("lock_profile", { profileId: source.id });
|
||||
assert.equal(
|
||||
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||
true,
|
||||
);
|
||||
await app.invoke("unlock_profile", {
|
||||
profileId: source.id,
|
||||
password: "new correct horse battery staple",
|
||||
});
|
||||
await app.invoke("remove_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "new correct horse battery staple",
|
||||
});
|
||||
assert.equal(
|
||||
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(await app.invoke("get_all_traffic_snapshots"), []);
|
||||
assert.equal(
|
||||
await app.invoke("get_profile_traffic_snapshot", {
|
||||
profileId: source.id,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await app.invoke("get_traffic_stats_for_period", {
|
||||
profileId: source.id,
|
||||
seconds: 3600,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
await app.invoke("clear_profile_traffic_stats", { profileId: source.id });
|
||||
await app.invoke("clear_all_traffic_stats");
|
||||
|
||||
await app.invoke("delete_selected_profiles", {
|
||||
profileIds: [source.id, target.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
|
||||
async function jsonRequest(
|
||||
url,
|
||||
{ method = "GET", token, body, headers = {} } = {},
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let value = null;
|
||||
if (text) {
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
value = text;
|
||||
}
|
||||
}
|
||||
return { response, value };
|
||||
}
|
||||
|
||||
async function seedTerms(app) {
|
||||
const home = path.join(app.root, "home");
|
||||
const directory =
|
||||
process.platform === "darwin"
|
||||
? path.join(home, "Library", "Application Support", "Wayfern")
|
||||
: process.platform === "win32"
|
||||
? path.join(app.root, "windows", "roaming", "Wayfern")
|
||||
: path.join(app.root, "xdg", "config", "Wayfern");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, "license-accepted"),
|
||||
String(Math.floor(Date.now() / 1000)),
|
||||
);
|
||||
}
|
||||
|
||||
async function invokeContract(app, command, args = {}) {
|
||||
try {
|
||||
return { ok: true, value: await app.invoke(command, args) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
test("authenticated REST API serves its complete OpenAPI contract and CRUD lifecycle", async () => {
|
||||
await withApp("integrations-rest", async (app) => {
|
||||
await seedTerms(app);
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
onboarding_completed: true,
|
||||
},
|
||||
});
|
||||
assert.ok(saved.api_token?.length >= 32);
|
||||
const port = await app.invoke("start_api_server", { port: 0 });
|
||||
assert.equal(await app.invoke("get_api_server_status"), port);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
const openapi = await jsonRequest(`${base}/openapi.json`);
|
||||
assert.equal(openapi.response.status, 200);
|
||||
assert.equal(openapi.value.openapi.startsWith("3."), true);
|
||||
const paths = Object.keys(openapi.value.paths);
|
||||
for (const required of [
|
||||
"/v1/profiles",
|
||||
"/v1/profiles/{id}/run",
|
||||
"/v1/groups",
|
||||
"/v1/proxies",
|
||||
"/v1/vpns/{id}/export",
|
||||
"/v1/extensions",
|
||||
"/v1/browsers/{browser}/versions",
|
||||
]) {
|
||||
assert.ok(paths.includes(required), `OpenAPI is missing ${required}`);
|
||||
}
|
||||
|
||||
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
|
||||
assert.equal(unauthorized.response.status, 401);
|
||||
const wrongToken = await jsonRequest(`${base}/v1/profiles`, {
|
||||
token: "wrong",
|
||||
});
|
||||
assert.equal(wrongToken.response.status, 401);
|
||||
|
||||
const groupsInitially = await jsonRequest(`${base}/v1/groups`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(groupsInitially.response.status, 200);
|
||||
assert.deepEqual(groupsInitially.value, []);
|
||||
const createdGroup = await jsonRequest(`${base}/v1/groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Group" },
|
||||
});
|
||||
assert.equal(createdGroup.response.status, 200);
|
||||
assert.equal(createdGroup.value.name, "REST Group");
|
||||
const groupId = createdGroup.value.id;
|
||||
const updatedGroup = await jsonRequest(`${base}/v1/groups/${groupId}`, {
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Group Updated" },
|
||||
});
|
||||
assert.equal(updatedGroup.value.name, "REST Group Updated");
|
||||
|
||||
const createdProxy = await jsonRequest(`${base}/v1/proxies`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Proxy",
|
||||
proxy_settings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8080,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(createdProxy.response.status, 200);
|
||||
assert.equal(createdProxy.value.proxy_settings.port, 8080);
|
||||
const proxyId = createdProxy.value.id;
|
||||
const fetchedProxy = await jsonRequest(`${base}/v1/proxies/${proxyId}`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(fetchedProxy.value.name, "REST Proxy");
|
||||
const imported = await jsonRequest(`${base}/v1/proxies/import`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
format: "txt",
|
||||
content: "http://127.0.0.1:8081",
|
||||
name_prefix: "API",
|
||||
},
|
||||
});
|
||||
assert.equal(imported.response.status, 200);
|
||||
assert.equal(imported.value.imported_count, 1);
|
||||
|
||||
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(missing.response.status, 404);
|
||||
const invalidProfile = await jsonRequest(`${base}/v1/profiles`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "Bad", browser: "unsupported", version: "latest" },
|
||||
});
|
||||
assert.equal(invalidProfile.response.status, 400);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/proxies/${proxyId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
for (const importedProxy of imported.value.proxies) {
|
||||
await jsonRequest(`${base}/v1/proxies/${importedProxy.id}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
});
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/groups/${groupId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
await app.invoke("stop_api_server");
|
||||
assert.equal(await app.invoke("get_api_server_status"), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => {
|
||||
await withApp("integrations-mcp", async (app) => {
|
||||
await seedTerms(app);
|
||||
const port = await app.invoke("start_mcp_server");
|
||||
assert.equal(await app.invoke("get_mcp_server_status"), true);
|
||||
const config = await app.invoke("get_mcp_config");
|
||||
assert.equal(config.port, port);
|
||||
assert.ok(config.token.length >= 32);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
assert.equal((await fetch(`${base}/health`)).status, 200);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/mcp`, {
|
||||
method: "POST",
|
||||
body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
|
||||
})
|
||||
).response.status,
|
||||
401,
|
||||
);
|
||||
|
||||
const initialized = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "donut-e2e", version: "1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(initialized.response.status, 200);
|
||||
assert.equal(initialized.value.result.serverInfo.name, "donut-browser");
|
||||
const sessionId = initialized.response.headers.get("mcp-session-id");
|
||||
assert.ok(sessionId);
|
||||
const mcpHeaders = { "mcp-session-id": sessionId };
|
||||
const notification = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: { jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
});
|
||||
assert.equal(notification.response.status, 202);
|
||||
const tools = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
|
||||
});
|
||||
assert.equal(tools.response.status, 200);
|
||||
const names = tools.value.result.tools.map((tool) => tool.name);
|
||||
for (const name of [
|
||||
"list_profiles",
|
||||
"create_profile",
|
||||
"run_profile",
|
||||
"list_proxies",
|
||||
"get_page_content",
|
||||
"get_interactive_elements",
|
||||
]) {
|
||||
assert.ok(names.includes(name), `MCP is missing ${name}`);
|
||||
}
|
||||
const listed = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "list_profiles", arguments: {} },
|
||||
},
|
||||
});
|
||||
assert.equal(listed.response.status, 200);
|
||||
assert.equal(listed.value.error, undefined);
|
||||
assert.ok(listed.value.result);
|
||||
|
||||
const agents = await app.invoke("list_mcp_agents");
|
||||
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
||||
await app.invoke("add_mcp_to_agent", { agentId: "cursor" });
|
||||
assert.equal(
|
||||
(await app.invoke("list_mcp_agents")).find(
|
||||
(agent) => agent.id === "cursor",
|
||||
).connected,
|
||||
true,
|
||||
);
|
||||
await app.invoke("remove_mcp_from_agent", { agentId: "cursor" });
|
||||
assert.equal(
|
||||
(await app.invoke("list_mcp_agents")).find(
|
||||
(agent) => agent.id === "cursor",
|
||||
).connected,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "DELETE",
|
||||
headers: mcpHeaders,
|
||||
})
|
||||
).response.status,
|
||||
200,
|
||||
);
|
||||
await app.invoke("stop_mcp_server");
|
||||
assert.equal(await app.invoke("get_mcp_server_status"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => {
|
||||
await withApp("integrations-contracts", async (app) => {
|
||||
assert.equal(await app.invoke("cloud_get_user"), null);
|
||||
assert.equal(await app.invoke("cloud_get_proxy_usage"), null);
|
||||
assert.ok(await app.invoke("cloud_get_wayfern_token"));
|
||||
assert.deepEqual(await app.invoke("get_team_locks"), []);
|
||||
assert.equal(
|
||||
await app.invoke("get_team_lock_status", {
|
||||
profileId: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(await app.invoke("get_sync_sessions"), []);
|
||||
const startResult = await invokeContract(app, "start_sync_session", {
|
||||
leaderProfileId: "00000000-0000-0000-0000-000000000001",
|
||||
followerProfileIds: ["00000000-0000-0000-0000-000000000002"],
|
||||
});
|
||||
assert.equal(startResult.ok, false);
|
||||
const stopError = await app.invokeError("stop_sync_session", {
|
||||
sessionId: "missing",
|
||||
});
|
||||
assert.match(stopError, /not found|session/i);
|
||||
const removeError = await app.invokeError("remove_sync_follower", {
|
||||
sessionId: "missing",
|
||||
followerProfileId: "missing",
|
||||
});
|
||||
assert.match(removeError, /not found|session/i);
|
||||
|
||||
assert.equal(await app.invoke("check_for_app_updates"), null);
|
||||
assert.equal(await app.invoke("check_for_app_updates_manual"), null);
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_exchange_device_code", {
|
||||
code: "DONUT-E2E-INVALID-CODE",
|
||||
}),
|
||||
);
|
||||
assert.ok(await invokeContract(app, "cloud_refresh_profile"));
|
||||
assert.ok(await invokeContract(app, "cloud_get_countries"));
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_get_regions", {
|
||||
country: "ZZ",
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_get_cities", {
|
||||
country: "ZZ",
|
||||
region: null,
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_get_isps", {
|
||||
country: "ZZ",
|
||||
region: null,
|
||||
city: null,
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
await invokeContract(app, "create_cloud_location_proxy", {
|
||||
name: "E2E unavailable cloud proxy",
|
||||
country: "ZZ",
|
||||
region: null,
|
||||
city: null,
|
||||
isp: null,
|
||||
}),
|
||||
);
|
||||
assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token"));
|
||||
|
||||
assert.ok(await invokeContract(app, "trigger_manual_version_update"));
|
||||
assert.ok(await invokeContract(app, "clear_all_version_cache_and_refetch"));
|
||||
assert.ok(await invokeContract(app, "check_for_browser_updates"));
|
||||
await app.invoke("dismiss_update_notification", {
|
||||
notificationId: "missing-e2e-notification",
|
||||
});
|
||||
assert.deepEqual(
|
||||
await app.invoke("complete_browser_update_with_auto_update", {
|
||||
browser: "wayfern",
|
||||
newVersion: "150.0.7871.100",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const prepareError = await app.invokeError(
|
||||
"download_and_prepare_app_update",
|
||||
{
|
||||
updateInfo: {
|
||||
current_version: "0.0.0",
|
||||
new_version: "0.0.1-e2e",
|
||||
release_notes: "E2E invalid update contract",
|
||||
download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`,
|
||||
is_nightly: false,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
manual_update_required: false,
|
||||
release_page_url: null,
|
||||
repo_update: false,
|
||||
checksums_url: null,
|
||||
asset_digest: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.match(prepareError, /checksum|verif|Failed to download/i);
|
||||
const versionStatus = await app.invoke("get_version_update_status");
|
||||
assert.ok(versionStatus && typeof versionStatus === "object");
|
||||
assert.equal(typeof (await app.invoke("is_default_browser")), "boolean");
|
||||
|
||||
const trial = await app.invoke("get_commercial_trial_status");
|
||||
assert.ok(trial && typeof trial === "object");
|
||||
await app.invoke("acknowledge_trial_expiration");
|
||||
assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true);
|
||||
await app.invoke("cloud_logout");
|
||||
assert.equal(await app.invoke("cloud_get_user"), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment, withApp } from "../lib/app.mjs";
|
||||
|
||||
test("fresh app renders, completes onboarding, persists settings, and never touches real app roots", async () => {
|
||||
await withApp("smoke-fresh", async (app) => {
|
||||
assert.equal(typeof (await app.session.title()), "string");
|
||||
assert.match(await app.bodyText(), /New/);
|
||||
await app.waitForText("No profiles yet");
|
||||
|
||||
const initial = await app.invoke("get_app_settings");
|
||||
assert.equal(typeof initial.onboarding_completed, "boolean");
|
||||
await app.invoke("complete_onboarding");
|
||||
assert.equal(await app.invoke("get_onboarding_completed"), true);
|
||||
await app.invoke("dismiss_window_resize_warning");
|
||||
assert.equal(await app.invoke("get_window_resize_warning_dismissed"), true);
|
||||
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...initial,
|
||||
theme: "dark",
|
||||
language: "en",
|
||||
onboarding_completed: true,
|
||||
disable_auto_updates: true,
|
||||
},
|
||||
});
|
||||
assert.equal(saved.theme, "dark");
|
||||
assert.equal(saved.language, "en");
|
||||
|
||||
await app.invoke("save_table_sorting_settings", {
|
||||
sorting: { column: "browser", direction: "desc" },
|
||||
});
|
||||
assert.deepEqual(await app.invoke("get_table_sorting_settings"), {
|
||||
column: "browser",
|
||||
direction: "desc",
|
||||
});
|
||||
assert.ok((await app.invoke("get_system_language")).length >= 2);
|
||||
const system = await app.invoke("get_system_info");
|
||||
assert.ok(system && typeof system === "object");
|
||||
assert.equal(typeof (await app.invoke("read_log_files")), "string");
|
||||
|
||||
await app.restart();
|
||||
const afterRestart = await app.invoke("get_app_settings");
|
||||
assert.equal(afterRestart.theme, "dark");
|
||||
assert.equal(afterRestart.language, "en");
|
||||
assert.equal(afterRestart.onboarding_completed, true);
|
||||
|
||||
const settingsFile = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"settings",
|
||||
"app_settings.json",
|
||||
);
|
||||
await access(settingsFile);
|
||||
const persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
assert.equal(persisted.api_token, null);
|
||||
assert.equal(persisted.mcp_token, null);
|
||||
});
|
||||
});
|
||||
|
||||
test("two isolated sessions run concurrently and do not share frontend or backend state", async () => {
|
||||
const first = appFromEnvironment("smoke-isolation-a");
|
||||
const second = appFromEnvironment("smoke-isolation-b");
|
||||
try {
|
||||
await Promise.all([first.start(), second.start()]);
|
||||
const firstSettings = await first.invoke("get_app_settings");
|
||||
await first.invoke("save_app_settings", {
|
||||
settings: { ...firstSettings, theme: "dark", onboarding_completed: true },
|
||||
});
|
||||
const secondSettings = await second.invoke("get_app_settings");
|
||||
assert.equal(secondSettings.theme, "system");
|
||||
assert.notEqual(secondSettings.theme, "dark");
|
||||
|
||||
await first.execute("localStorage.setItem('donut-e2e-only-a', 'yes');");
|
||||
assert.equal(
|
||||
await second.execute("return localStorage.getItem('donut-e2e-only-a');"),
|
||||
null,
|
||||
"native WebView data leaked across sessions",
|
||||
);
|
||||
} catch (error) {
|
||||
await Promise.all([first.capture("failure"), second.capture("failure")]);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([first.close(), second.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("keyboard command palette and major navigation surfaces are operable through native WebDriver", async () => {
|
||||
await withApp("smoke-ui", async (app) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
const modifier =
|
||||
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
||||
await app.pressShortcut({ key: "k", ...modifier });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return Boolean(document.querySelector("[cmdk-input][placeholder='Type a command or search...']"));`,
|
||||
),
|
||||
{ description: "open command palette" },
|
||||
);
|
||||
|
||||
const input = await app.session.findCss("[cmdk-input]");
|
||||
await app.session.sendKeys(input, "settings");
|
||||
const body = await app.bodyText();
|
||||
assert.match(body, /Settings/i);
|
||||
|
||||
// Exercise native WebDriver element marshalling and click, not just script execution.
|
||||
const close = await app.execute(
|
||||
`return [...document.querySelectorAll("button")].find(
|
||||
(button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "")
|
||||
) ?? null;`,
|
||||
);
|
||||
if (close) {
|
||||
await app.session.click(close);
|
||||
} else {
|
||||
await app.pressShortcut({ key: "Escape" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle", async () => {
|
||||
const app = appFromEnvironment("smoke-lifecycle");
|
||||
try {
|
||||
await app.start();
|
||||
await app.invoke("update_tray_menu", {
|
||||
showLabel: "Show Donut E2E",
|
||||
quitLabel: "Quit Donut E2E",
|
||||
});
|
||||
await app.invoke("hide_to_tray");
|
||||
assert.equal(
|
||||
typeof (await app.invoke("get_onboarding_completed")),
|
||||
"boolean",
|
||||
);
|
||||
|
||||
await app.restart();
|
||||
const exitingSession = app.session;
|
||||
await app
|
||||
.execute(
|
||||
`window.__TAURI_INTERNALS__.invoke("confirm_quit").catch(() => {});
|
||||
return true;`,
|
||||
)
|
||||
.catch(() => {});
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
try {
|
||||
await exitingSession.title();
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 10_000, description: "confirmed app exit" },
|
||||
);
|
||||
app.session = null;
|
||||
await exitingSession.close().catch(() => {});
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
||||
|
||||
const syncUrl = process.env.DONUT_E2E_SYNC_URL;
|
||||
const syncToken = process.env.DONUT_E2E_SYNC_TOKEN;
|
||||
|
||||
async function syncRequest(endpoint, body) {
|
||||
const response = await fetch(`${syncUrl}/v1/objects/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${syncToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Sync ${endpoint} failed with HTTP ${response.status}: ${text}`,
|
||||
);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function listRemote(prefix = "") {
|
||||
const result = await syncRequest("list", {
|
||||
prefix,
|
||||
maxKeys: 1000,
|
||||
continuationToken: null,
|
||||
});
|
||||
return result.objects;
|
||||
}
|
||||
|
||||
async function downloadRemote(key) {
|
||||
const presigned = await syncRequest("presign-download", {
|
||||
key,
|
||||
expiresIn: 300,
|
||||
});
|
||||
const response = await fetch(presigned.url);
|
||||
assert.equal(response.status, 200, `Could not download remote object ${key}`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function configureSync(app) {
|
||||
const saved = await app.invoke("save_sync_settings", {
|
||||
syncServerUrl: syncUrl,
|
||||
syncToken,
|
||||
});
|
||||
assert.equal(saved.sync_server_url, syncUrl);
|
||||
assert.equal(saved.sync_token, syncToken);
|
||||
assert.deepEqual(await app.invoke("get_sync_settings"), saved);
|
||||
await app.invoke("restart_sync_service");
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
}
|
||||
|
||||
async function createProfile(app, name) {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name,
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
// Keep sync tests deterministic and network-free; browser.test.mjs covers
|
||||
// generation through the real Wayfern binary.
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(app, callback, description, timeoutMs = 45_000) {
|
||||
return app.waitFor(callback, { description, timeoutMs, intervalMs: 250 });
|
||||
}
|
||||
|
||||
test("two real app devices reconcile profile files and every config entity with last-write-wins", async () => {
|
||||
assert.ok(syncUrl && syncToken, "Sync infrastructure was not started");
|
||||
const deviceA = appFromEnvironment("sync-regular-a");
|
||||
const deviceB = appFromEnvironment("sync-regular-b");
|
||||
try {
|
||||
await Promise.all([deviceA.start(), deviceB.start()]);
|
||||
await Promise.all([configureSync(deviceA), configureSync(deviceB)]);
|
||||
|
||||
const group = await deviceA.invoke("create_profile_group", {
|
||||
name: "Synced Group A",
|
||||
});
|
||||
const proxy = await deviceA.invoke("create_stored_proxy", {
|
||||
name: "Synced Proxy A",
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8089,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
});
|
||||
const vpn = await deviceA.invoke("create_vpn_config_manual", {
|
||||
name: "Synced VPN A",
|
||||
vpnType: "WireGuard",
|
||||
configData: wireGuardFixture(),
|
||||
});
|
||||
const extension = await deviceA.invoke("add_extension", {
|
||||
name: "Synced Extension A",
|
||||
fileName: "synced-fixture.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
const extensionGroup = await deviceA.invoke("create_extension_group", {
|
||||
name: "Synced Extension Group A",
|
||||
});
|
||||
await deviceA.invoke("add_extension_to_group", {
|
||||
groupId: extensionGroup.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
deviceA.invoke("set_group_sync_enabled", {
|
||||
groupId: group.id,
|
||||
enabled: true,
|
||||
}),
|
||||
deviceA.invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: true,
|
||||
}),
|
||||
deviceA.invoke("set_vpn_sync_enabled", { vpnId: vpn.id, enabled: true }),
|
||||
deviceA.invoke("set_extension_sync_enabled", {
|
||||
extensionId: extension.id,
|
||||
enabled: true,
|
||||
}),
|
||||
deviceA.invoke("set_extension_group_sync_enabled", {
|
||||
extensionGroupId: extensionGroup.id,
|
||||
enabled: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const profile = await createProfile(deviceA, "Synced Profile A");
|
||||
const profileData = path.join(
|
||||
deviceA.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
"Default",
|
||||
);
|
||||
await mkdir(profileData, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(profileData, "Preferences"),
|
||||
JSON.stringify({ donutE2E: "regular-profile-payload" }),
|
||||
);
|
||||
await deviceA.invoke("update_profile_tags", {
|
||||
profileId: profile.id,
|
||||
tags: ["sync", "device-a"],
|
||||
});
|
||||
await deviceA.invoke("update_profile_note", {
|
||||
profileId: profile.id,
|
||||
note: "regular sync metadata",
|
||||
});
|
||||
await deviceA.invoke("set_profile_sync_mode", {
|
||||
profileId: profile.id,
|
||||
syncMode: "Regular",
|
||||
});
|
||||
await deviceA.invoke("request_profile_sync", { profileId: profile.id });
|
||||
assert.equal(
|
||||
await deviceA.invoke("cancel_profile_sync", {
|
||||
profileId: "not-running-sync",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () => {
|
||||
const keys = (await listRemote("")).map((object) => object.key);
|
||||
return [
|
||||
`groups/${group.id}.json`,
|
||||
`proxies/${proxy.id}.json`,
|
||||
`vpns/${vpn.id}.json`,
|
||||
`extensions/${extension.id}.json`,
|
||||
`extension_groups/${extensionGroup.id}.json`,
|
||||
`profiles/${profile.id}/manifest.json`,
|
||||
`profiles/${profile.id}/files/profile/Default/Preferences`,
|
||||
].every((key) => keys.includes(key));
|
||||
},
|
||||
"all regular entities uploaded",
|
||||
);
|
||||
|
||||
await deviceB.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
deviceB,
|
||||
async () => {
|
||||
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
||||
await Promise.all([
|
||||
deviceB.invoke("list_browser_profiles"),
|
||||
deviceB.invoke("get_profile_groups"),
|
||||
deviceB.invoke("get_stored_proxies"),
|
||||
deviceB.invoke("list_vpn_configs"),
|
||||
deviceB.invoke("list_extensions"),
|
||||
deviceB.invoke("list_extension_groups"),
|
||||
]);
|
||||
return (
|
||||
profiles.some((item) => item.id === profile.id) &&
|
||||
groups.some((item) => item.id === group.id) &&
|
||||
proxies.some((item) => item.id === proxy.id) &&
|
||||
vpns.some((item) => item.id === vpn.id) &&
|
||||
extensions.some((item) => item.id === extension.id) &&
|
||||
extensionGroups.some((item) => item.id === extensionGroup.id)
|
||||
);
|
||||
},
|
||||
"device B receives every entity",
|
||||
);
|
||||
const downloadedPreferences = path.join(
|
||||
deviceB.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
"Default",
|
||||
"Preferences",
|
||||
);
|
||||
await waitFor(
|
||||
deviceB,
|
||||
async () =>
|
||||
(
|
||||
await readFile(downloadedPreferences, "utf8").catch(() => "")
|
||||
).includes("regular-profile-payload"),
|
||||
"device B receives profile browser files",
|
||||
);
|
||||
|
||||
// updated_at has one-second resolution. Make the device-B edits
|
||||
// unambiguously newer, then verify last-write-wins in both directions.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
await deviceB.invoke("update_stored_proxy", {
|
||||
proxyId: proxy.id,
|
||||
name: "Synced Proxy B Wins",
|
||||
proxySettings: null,
|
||||
});
|
||||
await deviceB.invoke("rename_profile", {
|
||||
profileId: profile.id,
|
||||
newName: "Synced Profile B Wins",
|
||||
});
|
||||
await deviceB.invoke("request_profile_sync", { profileId: profile.id });
|
||||
await deviceA.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () => {
|
||||
const proxies = await deviceA.invoke("get_stored_proxies");
|
||||
const profiles = await deviceA.invoke("list_browser_profiles");
|
||||
return (
|
||||
proxies.find((item) => item.id === proxy.id)?.name ===
|
||||
"Synced Proxy B Wins" &&
|
||||
profiles.find((item) => item.id === profile.id)?.name ===
|
||||
"Synced Profile B Wins"
|
||||
);
|
||||
},
|
||||
"newer device-B edits win on device A",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await deviceA.invoke("is_proxy_in_use_by_synced_profile", {
|
||||
proxyId: proxy.id,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await deviceA.invoke("is_group_in_use_by_synced_profile", {
|
||||
groupId: group.id,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await deviceA.invoke("is_vpn_in_use_by_synced_profile", {
|
||||
vpnId: vpn.id,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
const counts = await deviceA.invoke("get_unsynced_entity_counts");
|
||||
assert.equal(typeof counts.proxies, "number");
|
||||
await deviceA.invoke("enable_sync_for_all_entities");
|
||||
|
||||
await Promise.all([
|
||||
deviceB.invoke("delete_extension_group", {
|
||||
groupId: extensionGroup.id,
|
||||
}),
|
||||
deviceB.invoke("delete_extension", { extensionId: extension.id }),
|
||||
deviceB.invoke("delete_vpn_config", { vpnId: vpn.id }),
|
||||
deviceB.invoke("delete_profile_group", { groupId: group.id }),
|
||||
deviceB.invoke("delete_stored_proxy", { proxyId: proxy.id }),
|
||||
deviceB.invoke("delete_profile", { profileId: profile.id }),
|
||||
]);
|
||||
await waitFor(
|
||||
deviceB,
|
||||
async () => {
|
||||
const keys = (await listRemote("")).map((object) => object.key);
|
||||
return [
|
||||
`tombstones/groups/${group.id}.json`,
|
||||
`tombstones/proxies/${proxy.id}.json`,
|
||||
`tombstones/vpns/${vpn.id}.json`,
|
||||
`tombstones/extensions/${extension.id}.json`,
|
||||
`tombstones/extension_groups/${extensionGroup.id}.json`,
|
||||
`tombstones/profiles/${profile.id}.json`,
|
||||
].every((key) => keys.includes(key));
|
||||
},
|
||||
"deletions create every remote tombstone",
|
||||
);
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () => {
|
||||
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
||||
await Promise.all([
|
||||
deviceA.invoke("list_browser_profiles"),
|
||||
deviceA.invoke("get_profile_groups"),
|
||||
deviceA.invoke("get_stored_proxies"),
|
||||
deviceA.invoke("list_vpn_configs"),
|
||||
deviceA.invoke("list_extensions"),
|
||||
deviceA.invoke("list_extension_groups"),
|
||||
]);
|
||||
return (
|
||||
!profiles.some((item) => item.id === profile.id) &&
|
||||
!groups.some((item) => item.id === group.id) &&
|
||||
!proxies.some((item) => item.id === proxy.id) &&
|
||||
!vpns.some((item) => item.id === vpn.id) &&
|
||||
!extensions.some((item) => item.id === extension.id) &&
|
||||
!extensionGroups.some((item) => item.id === extensionGroup.id)
|
||||
);
|
||||
},
|
||||
"remote tombstones delete every entity from device A",
|
||||
);
|
||||
} catch (error) {
|
||||
await Promise.all([deviceA.capture("failure"), deviceB.capture("failure")]);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([deviceA.close(), deviceB.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("global config sealing and encrypted profile sync reject a wrong password, round-trip with the right one, and roll over", async () => {
|
||||
const source = appFromEnvironment("sync-encrypted-source");
|
||||
const receiver = appFromEnvironment("sync-encrypted-receiver");
|
||||
const rolloverReceiver = appFromEnvironment(
|
||||
"sync-encrypted-rollover-receiver",
|
||||
);
|
||||
try {
|
||||
await Promise.all([source.start(), receiver.start()]);
|
||||
await Promise.all([configureSync(source), configureSync(receiver)]);
|
||||
await source.invoke("set_e2e_password", {
|
||||
password: "shared encryption password",
|
||||
});
|
||||
await receiver.invoke("set_e2e_password", {
|
||||
password: "intentionally wrong password",
|
||||
});
|
||||
assert.equal(await source.invoke("check_has_e2e_password"), true);
|
||||
assert.equal(
|
||||
await source.invoke("verify_e2e_password", {
|
||||
password: "shared encryption password",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await source.invoke("verify_e2e_password", { password: "wrong" }),
|
||||
false,
|
||||
);
|
||||
|
||||
const sealedProxy = await source.invoke("create_stored_proxy", {
|
||||
name: "SECRET-CONFIG-MARKER",
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "secret-proxy.invalid",
|
||||
port: 8443,
|
||||
username: "secret-user",
|
||||
password: "secret-password",
|
||||
},
|
||||
});
|
||||
await source.invoke("set_proxy_sync_enabled", {
|
||||
proxyId: sealedProxy.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const encryptedProfile = await createProfile(source, "Encrypted Profile");
|
||||
const encryptedData = path.join(
|
||||
source.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
encryptedProfile.id,
|
||||
"profile",
|
||||
);
|
||||
await mkdir(encryptedData, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(encryptedData, "Local State"),
|
||||
"SECRET-PROFILE-MARKER that must never appear remotely",
|
||||
);
|
||||
await source.invoke("set_profile_sync_mode", {
|
||||
profileId: encryptedProfile.id,
|
||||
syncMode: "Encrypted",
|
||||
});
|
||||
await source.invoke("request_profile_sync", {
|
||||
profileId: encryptedProfile.id,
|
||||
});
|
||||
|
||||
const proxyKey = `proxies/${sealedProxy.id}.json`;
|
||||
const profileMetadataKey = `profiles/${encryptedProfile.id}/metadata.json`;
|
||||
const profileFileKey = `profiles/${encryptedProfile.id}/files/profile/Local State`;
|
||||
await waitFor(
|
||||
source,
|
||||
async () => {
|
||||
const keys = (await listRemote("")).map((object) => object.key);
|
||||
return (
|
||||
keys.includes(proxyKey) &&
|
||||
keys.includes(profileMetadataKey) &&
|
||||
keys.includes(profileFileKey)
|
||||
);
|
||||
},
|
||||
"sealed config and encrypted profile uploaded",
|
||||
);
|
||||
const sealedBefore = await downloadRemote(proxyKey);
|
||||
const metadataBefore = await downloadRemote(profileMetadataKey);
|
||||
const encryptedFile = await downloadRemote(profileFileKey);
|
||||
assert.equal(
|
||||
sealedBefore.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
||||
false,
|
||||
);
|
||||
assert.equal(sealedBefore.includes(Buffer.from("secret-password")), false);
|
||||
assert.equal(
|
||||
encryptedFile.includes(Buffer.from("SECRET-PROFILE-MARKER")),
|
||||
false,
|
||||
);
|
||||
const envelope = JSON.parse(sealedBefore.toString("utf8"));
|
||||
assert.equal(envelope.v, 1);
|
||||
assert.ok(envelope.salt && envelope.ct);
|
||||
|
||||
await receiver.invoke("restart_sync_service");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
assert.equal(
|
||||
(await receiver.invoke("get_stored_proxies")).some(
|
||||
(item) => item.id === sealedProxy.id,
|
||||
),
|
||||
false,
|
||||
"wrong password must not materialize sealed config",
|
||||
);
|
||||
assert.equal(
|
||||
(await receiver.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === encryptedProfile.id,
|
||||
),
|
||||
false,
|
||||
"wrong password must not materialize encrypted profiles",
|
||||
);
|
||||
|
||||
await receiver.invoke("set_e2e_password", {
|
||||
password: "shared encryption password",
|
||||
});
|
||||
await receiver.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
receiver,
|
||||
async () =>
|
||||
(await receiver.invoke("get_stored_proxies")).some(
|
||||
(item) =>
|
||||
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||
) &&
|
||||
(await receiver.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === encryptedProfile.id,
|
||||
),
|
||||
"correct password decrypts config and profile metadata",
|
||||
);
|
||||
const receiverFile = path.join(
|
||||
receiver.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
encryptedProfile.id,
|
||||
"profile",
|
||||
"Local State",
|
||||
);
|
||||
await waitFor(
|
||||
receiver,
|
||||
async () =>
|
||||
(await readFile(receiverFile, "utf8").catch(() => "")).includes(
|
||||
"SECRET-PROFILE-MARKER",
|
||||
),
|
||||
"correct password decrypts profile browser file",
|
||||
);
|
||||
|
||||
await source.invoke("set_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
await source.invoke("rollover_encryption_for_all_entities");
|
||||
await waitFor(
|
||||
source,
|
||||
async () => {
|
||||
const [proxy, metadata] = await Promise.all([
|
||||
downloadRemote(proxyKey),
|
||||
downloadRemote(profileMetadataKey),
|
||||
]);
|
||||
return !proxy.equals(sealedBefore) && !metadata.equals(metadataBefore);
|
||||
},
|
||||
"password rollover rewrites sealed config and profile metadata",
|
||||
);
|
||||
const sealedAfter = await downloadRemote(proxyKey);
|
||||
assert.equal(
|
||||
sealedAfter.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
||||
false,
|
||||
);
|
||||
await receiver.invoke("set_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
await receiver.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
receiver,
|
||||
async () =>
|
||||
(await receiver.invoke("get_stored_proxies")).some(
|
||||
(item) =>
|
||||
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||
),
|
||||
"receiver accepts rolled password",
|
||||
);
|
||||
|
||||
await rolloverReceiver.start();
|
||||
await rolloverReceiver.invoke("set_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
await configureSync(rolloverReceiver);
|
||||
await waitFor(
|
||||
rolloverReceiver,
|
||||
async () =>
|
||||
(await rolloverReceiver.invoke("get_stored_proxies")).some(
|
||||
(item) =>
|
||||
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||
) &&
|
||||
(await rolloverReceiver.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === encryptedProfile.id,
|
||||
),
|
||||
"fresh receiver decrypts rolled config and profile metadata",
|
||||
);
|
||||
const rolloverFile = path.join(
|
||||
rolloverReceiver.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
encryptedProfile.id,
|
||||
"profile",
|
||||
"Local State",
|
||||
);
|
||||
await waitFor(
|
||||
rolloverReceiver,
|
||||
async () =>
|
||||
(await readFile(rolloverFile, "utf8").catch(() => "")).includes(
|
||||
"SECRET-PROFILE-MARKER",
|
||||
),
|
||||
"fresh receiver decrypts rolled profile browser file",
|
||||
);
|
||||
|
||||
await source.invoke("set_profile_sync_mode", {
|
||||
profileId: encryptedProfile.id,
|
||||
syncMode: "Disabled",
|
||||
});
|
||||
await source.invoke("delete_e2e_password");
|
||||
assert.equal(await source.invoke("check_has_e2e_password"), false);
|
||||
const missingPassword = await source.invokeError("verify_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
assert.match(missingPassword, /NO_E2E_PASSWORD_SET/);
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
source.capture("failure"),
|
||||
receiver.capture("failure"),
|
||||
rolloverReceiver.capture("failure"),
|
||||
]);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([
|
||||
source.close(),
|
||||
receiver.close(),
|
||||
rolloverReceiver.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
|
||||
async function dismissSurface(app) {
|
||||
await app.pressShortcut({ key: "Escape" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
test("all primary navigation buttons and sub-page tabs render and remain interactive", async () => {
|
||||
await withApp("ui-navigation", async (app) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
await app.restart();
|
||||
const surfaces = [
|
||||
["Settings", /General|Appearance|Sync/i],
|
||||
["Network", /Proxies|VPNs|DNS/i],
|
||||
["Extensions", /Extensions|Groups/i],
|
||||
["Integrations", /API|MCP/i],
|
||||
["Account", /Account|Sign in/i],
|
||||
];
|
||||
for (const [label, expected] of surfaces) {
|
||||
await app.clickSelector(`[aria-label="${label}"]`);
|
||||
await app.waitFor(async () => expected.test(await app.bodyText()), {
|
||||
description: `${label} surface`,
|
||||
});
|
||||
assert.match(await app.bodyText(), expected);
|
||||
await dismissSurface(app);
|
||||
}
|
||||
|
||||
await app.clickSelector('[aria-label="Groups"]');
|
||||
await app.waitForText("Create");
|
||||
await dismissSurface(app);
|
||||
|
||||
await app.clickSelector('[aria-label="More"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return Boolean(document.querySelector("[role='menu']"));`),
|
||||
{ description: "More menu" },
|
||||
);
|
||||
await dismissSurface(app);
|
||||
|
||||
await app.clickSelector('[aria-label="Profiles"]');
|
||||
await app.clickText("New");
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return Boolean(document.querySelector("[role='dialog']"));`,
|
||||
),
|
||||
{ description: "new profile dialog" },
|
||||
);
|
||||
assert.match(await app.bodyText(), /profile/i);
|
||||
await dismissSurface(app);
|
||||
});
|
||||
});
|
||||
|
||||
test("settings tabs, command palette filtering, and responsive layout survive resize", async () => {
|
||||
await withApp("ui-settings-responsive", async (app) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
await app.restart();
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
for (const tab of ["Appearance", "Sync", "Encryption"]) {
|
||||
const exists = await app.execute(
|
||||
`return [...document.querySelectorAll("[role='tab']")].some(
|
||||
(node) => (node.textContent || "").trim() === arguments[0]
|
||||
);`,
|
||||
[tab],
|
||||
);
|
||||
if (exists) {
|
||||
await app.clickText(tab, { roles: ["tab"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return [...document.querySelectorAll("[role='tab']")].some(
|
||||
(node) => (node.textContent || "").trim() === arguments[0] &&
|
||||
node.getAttribute("data-state") === "active"
|
||||
);`,
|
||||
[tab],
|
||||
),
|
||||
{ description: `${tab} settings tab` },
|
||||
);
|
||||
}
|
||||
}
|
||||
await dismissSurface(app);
|
||||
|
||||
const modifier =
|
||||
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
||||
await app.pressShortcut({ key: "k", ...modifier });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return Boolean(document.querySelector("[cmdk-input]"));`),
|
||||
{ description: "command palette" },
|
||||
);
|
||||
const input = await app.session.findCss("[cmdk-input]");
|
||||
await app.session.sendKeys(input, "proxy vpn");
|
||||
assert.match(await app.bodyText(), /Network|Proxy|VPN/i);
|
||||
await dismissSurface(app);
|
||||
|
||||
// The native driver owns the top-level window. Resize through the WebDriver
|
||||
// protocol and assert the app still has usable controls at the minimum size.
|
||||
await app.session.command("POST", "/window/rect", {
|
||||
width: 640,
|
||||
height: 400,
|
||||
});
|
||||
const viewport = await app.execute(
|
||||
"return { width: innerWidth, height: innerHeight };",
|
||||
);
|
||||
assert.ok(viewport.width >= 600);
|
||||
assert.ok(viewport.height >= 350);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector('[aria-label="Settings"]').getBoundingClientRect().width > 0;`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user