mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-24 21:30:58 +02:00
chore: add cross-platform webdriver tests
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user