Files
2026-07-21 23:13:17 +04:00

606 lines
20 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readdir, readFile, writeFile } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
import test from "node:test";
import { appFromEnvironment } from "../lib/app.mjs";
import { CdpClient } from "../lib/cdp.mjs";
import {
extensionZipBase64,
prepareWayfern,
wireGuardFixture,
} from "../lib/fixtures.mjs";
function proxySettings(raw, expectedKind) {
assert.ok(raw, `${expectedKind} residential proxy URL is required`);
const url = new URL(raw);
const rawType = url.protocol.slice(0, -1).toLowerCase();
const proxyType =
rawType === "socks" || rawType === "socks5h" ? "socks5" : rawType;
if (expectedKind === "HTTP") {
assert.ok(
proxyType === "http" || proxyType === "https",
`Expected an HTTP proxy URL, got ${rawType}`,
);
} else {
assert.equal(proxyType, "socks5");
}
const port = Number(url.port);
assert.ok(url.hostname && port > 0 && port <= 65535);
return {
proxy_type: proxyType,
host: url.hostname,
port,
username: url.username ? decodeURIComponent(url.username) : null,
password: url.password ? decodeURIComponent(url.password) : null,
};
}
function wireGuardFields(config) {
let section = "";
const fields = new Map();
for (const rawLine of config.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
if (line === "[Interface]") {
section = "interface";
continue;
}
if (line === "[Peer]") {
section = "peer";
continue;
}
const separator = line.indexOf("=");
if (separator === -1) continue;
fields.set(
`${section}.${line.slice(0, separator).trim()}`,
line.slice(separator + 1).trim(),
);
}
return {
privateKey: fields.get("interface.PrivateKey"),
address: fields.get("interface.Address"),
dns: fields.get("interface.DNS") ?? "",
peerPublicKey: fields.get("peer.PublicKey"),
peerEndpoint: fields.get("peer.Endpoint"),
allowedIps: fields.get("peer.AllowedIPs") ?? "0.0.0.0/0",
persistentKeepalive: fields.get("peer.PersistentKeepalive") ?? "",
presharedKey: fields.get("peer.PresharedKey") ?? "",
};
}
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 = text;
if (text) {
try {
value = JSON.parse(text);
} catch {
// Plain-text responses are intentional for some endpoints.
}
}
return { response, value };
}
async function createGroupThroughUi(app) {
await app.clickSelector('[aria-label="Groups"]');
await app.waitForText("Profile groups");
await app.clickSelector('[aria-label="Create"]');
await app.waitForText("Create New Group");
await app.fillSelector("#group-name", "Visible UI Group");
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
await app.waitForText("Visible UI Group");
const groups = await app.invoke("get_profile_groups");
return groups.find((group) => group.name === "Visible UI Group");
}
async function createProxyThroughUi(app, settings) {
await app.clickSelector('[aria-label="Network"]');
await app.waitForText("New proxy");
await app.clickSelector('[aria-label="New proxy"]');
await app.waitForText("Add Proxy");
await app.fillSelector("#proxy-name", "Visible Residential HTTP");
await app.fillSelector("#proxy-host", settings.host);
await app.fillSelector("#proxy-port", String(settings.port));
if (settings.username)
await app.fillSelector("#proxy-username", settings.username);
if (settings.password)
await app.fillSelector("#proxy-password", settings.password);
await app.clickTextIn('[role="dialog"]', "Add Proxy", {
roles: ["button"],
});
await app.waitForText("Visible Residential HTTP");
const proxies = await app.invoke("get_stored_proxies");
return proxies.find((proxy) => proxy.name === "Visible Residential HTTP");
}
async function createVpnThroughUi(app, config) {
const fields = wireGuardFields(config);
assert.ok(
fields.privateKey &&
fields.address &&
fields.peerPublicKey &&
fields.peerEndpoint,
"WireGuard fixture is missing required fields",
);
await app.clickText("VPNs", { exact: false, roles: ["tab"] });
await app.clickSelector('[aria-label="New VPN"]');
await app.waitForText("Create WireGuard VPN");
await app.fillSelector("#wg-name", "Visible Local WireGuard");
await app.fillSelector("#wg-private-key", fields.privateKey);
await app.fillSelector("#wg-address", fields.address);
if (fields.dns) await app.fillSelector("#wg-dns", fields.dns);
await app.fillSelector("#wg-peer-public-key", fields.peerPublicKey);
await app.fillSelector("#wg-peer-endpoint", fields.peerEndpoint);
await app.fillSelector("#wg-allowed-ips", fields.allowedIps);
if (fields.persistentKeepalive) {
await app.fillSelector("#wg-keepalive", fields.persistentKeepalive);
}
if (fields.presharedKey) {
await app.fillSelector("#wg-preshared-key", fields.presharedKey);
}
await app.clickTextIn('[role="dialog"]', "Create VPN", {
roles: ["button"],
});
await app.waitForText("Visible Local WireGuard");
const vpns = await app.invoke("list_vpn_configs");
return vpns.find((vpn) => vpn.name === "Visible Local WireGuard");
}
async function createExtensionsThroughUi(app) {
const extensionFile = path.join(app.root, "visible-extension.zip");
await writeFile(extensionFile, Buffer.from(extensionZipBase64(), "base64"));
await app.clickSelector('[aria-label="Extensions"]');
await app.waitForText("Upload");
await app.execute(`
const input = document.querySelector("#ext-file-input");
input.classList.remove("hidden");
input.style.position = "fixed";
input.style.left = "12px";
input.style.bottom = "12px";
`);
const input = await app.session.findCss("#ext-file-input");
await app.session.sendKeys(input, extensionFile);
await app.waitForText("visible-extension.zip");
await app.fillSelector(
'input[placeholder="Extension name"]',
"Visible UI Extension",
);
await app.clickText("Add", { roles: ["button"] });
await app.waitForText("Donut E2E Fixture");
await app.clickText("Groups", { exact: false, roles: ["tab"] });
await app.clickSelector('[aria-label="New group"]');
await app.fillSelector(
'input[placeholder="Group name"]',
"Visible Extension Group",
);
await app.clickText("Create", { roles: ["button"] });
await app.waitForText("Visible Extension Group");
let [extensions, groups] = await Promise.all([
app.invoke("list_extensions"),
app.invoke("list_extension_groups"),
]);
const extension = extensions.find(
(item) => item.name === "Donut E2E Fixture",
);
let group = groups.find((item) => item.name === "Visible Extension Group");
assert.ok(extension && group);
const editButton = await app.execute(
`
const row = [...document.querySelectorAll("tr")].find((candidate) =>
(candidate.innerText || "").includes(arguments[0])
);
return row?.querySelector("td:last-child button") ?? null;
`,
[group.name],
);
assert.ok(editButton, "Extension group edit control was not visible");
await app.session.click(editButton);
await app.waitForText("Edit Group");
const extensionPicker = await app.execute(`
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
return dialogs.reverse().find(
(dialog) => (dialog.innerText || "").includes("Edit Group")
)?.querySelector('[role="combobox"]') ?? null;
`);
assert.ok(extensionPicker, "Extension picker was not visible");
await app.session.click(extensionPicker);
await app.clickText(extension.name, { roles: ["option"] });
await app.clickTextIn('[role="dialog"]', "Save", { roles: ["button"] });
await app.waitFor(
async () => {
groups = await app.invoke("list_extension_groups");
group = groups.find((item) => item.name === "Visible Extension Group");
return group?.extension_ids.includes(extension.id);
},
{ description: "uploaded extension added to visible extension group" },
);
return {
extension,
group,
};
}
async function createProfileThroughUi(app, groupName) {
await app.clickSelector('[aria-label="Profiles"]');
await app.clickText(groupName, { exact: false, roles: ["button"] });
await app.clickText("New", { roles: ["button"] });
await app.waitFor(
async () => {
const text = await app.bodyText();
return (
text.includes("Create New Profile") ||
text.includes("Create New Chromium Profile")
);
},
{ description: "profile creation dialog" },
);
if (!(await app.visibleTextIncludes("Create New Chromium Profile"))) {
await app.clickText("Chromium", { exact: false, roles: ["button"] });
await app.waitForText("Create New Chromium Profile");
}
await app.fillSelector("#profile-name", "Visible Network Profile");
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
await app.waitForText("Visible Network Profile", 60_000);
await app.waitFor(
async () =>
!(await app.execute(`
return [...document.querySelectorAll('[role="dialog"]')].some(
(dialog) =>
(dialog.innerText || "").includes("Create New Chromium Profile")
);
`)),
{ description: "profile creation dialog to unmount" },
);
const profiles = await app.invoke("list_browser_profiles");
return profiles.find((profile) => profile.name === "Visible Network Profile");
}
async function assignNetworkThroughUi(app, profileName, currentName, newName) {
const trigger = await app.execute(
`
const row = [...document.querySelectorAll("tr")].find((candidate) =>
(candidate.innerText || "").includes(arguments[0])
);
const expected = arguments[1].toLocaleLowerCase();
return [...(row?.querySelectorAll('[aria-haspopup="dialog"]') ?? [])].find(
(trigger) => (trigger.innerText || trigger.textContent || "")
.toLocaleLowerCase()
.includes(expected)
) ?? null;
`,
[profileName, currentName],
);
assert.ok(trigger, `Network selector for ${profileName} was not visible`);
await app.session.click(trigger);
await app.clickText(newName, { exact: false, roles: ["option"] });
await app.waitFor(
() =>
app.execute(
`
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
.some((content) => (content.innerText || "").includes(arguments[0]));
`,
[newName],
),
{ description: `${newName} network picker to unmount` },
);
}
async function assignExtensionGroupThroughUi(
app,
profileName,
currentName,
newName,
) {
const trigger = await app.execute(
`
const row = [...document.querySelectorAll("tr")].find((candidate) =>
(candidate.innerText || "").includes(arguments[0])
);
return [...(row?.querySelectorAll("button") ?? [])].find(
(button) => (button.innerText || button.textContent || "")
.trim()
.includes(arguments[1])
) ?? null;
`,
[profileName, currentName],
);
assert.ok(trigger, `Extension selector for ${profileName} was not visible`);
await app.session.click(trigger);
await app.clickText(newName, { exact: false, roles: ["option"] });
await app.waitFor(
() =>
app.execute(
`
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
.some((content) => (content.innerText || "").includes(arguments[0]));
`,
[newName],
),
{ description: `${newName} extension picker to unmount` },
);
}
async function runProfile(_app, base, token, profileId, url) {
const launched = await request(`${base}/v1/profiles/${profileId}/run`, {
method: "POST",
token,
body: { url, headless: true },
});
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
const cdp = await CdpClient.connect(launched.value.remote_debugging_port);
return { launched: launched.value, cdp };
}
async function stopProfile(app, base, token, profileId, cdp) {
cdp.close();
const stopped = await request(`${base}/v1/profiles/${profileId}/kill`, {
method: "POST",
token,
});
assert.equal(stopped.response.status, 204);
await app.waitFor(
async () => {
const profile = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === profileId,
);
return !profile?.process_id;
},
{ timeoutMs: 20_000, description: "network profile process cleanup" },
);
}
async function assertProxyWorkerLogsRedacted(app, settings) {
const files = (await readdir(path.join(app.root, "tmp"))).filter(
(file) => file.startsWith("donut-proxy-") && file.endsWith(".log"),
);
assert.ok(files.length > 0, "No proxy worker diagnostic logs were created");
const contents = (
await Promise.all(
files.map((file) => readFile(path.join(app.root, "tmp", file), "utf8")),
)
).join("\n");
for (const item of settings) {
if (!item.username) continue;
const rawAuth = `${item.username}:${item.password ?? ""}@`;
const encodedAuth = `${encodeURIComponent(item.username)}:${encodeURIComponent(item.password ?? "")}@`;
assert.equal(
contents.includes(rawAuth) || contents.includes(encodedAuth),
false,
"Proxy worker logs exposed upstream credentials",
);
}
}
function wireGuardTargetWasReached() {
const container = process.env.DONUT_E2E_WIREGUARD_CONTAINER;
assert.ok(container, "WireGuard fixture container name is required");
return (
spawnSync(
"docker",
[
"exec",
container,
"grep",
"-q",
"GET /donut-e2e-wireguard ",
"/tmp/donut-e2e-target-requests",
],
{ stdio: "ignore", timeout: 2_000 },
).status === 0
);
}
test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions, and extension groups", async () => {
const httpSettings = proxySettings(
process.env.RESIDENTIAL_PROXY_URL_ONE_HTTP,
"HTTP",
);
const socksSettings = proxySettings(
process.env.RESIDENTIAL_PROXY_URL_ONE_SOCKS,
"SOCKS",
);
const realWireGuardConfig = process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64
? Buffer.from(
process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64,
"base64",
).toString("utf8")
: null;
const app = appFromEnvironment("network-visible-ui", {
wayfernTermsAccepted: false,
});
let apiPort;
let activeCdp;
let activeVpnId;
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
await app.invoke("accept_wayfern_terms");
await app.restart();
}
assert.equal(
await app.visibleTextIncludes("Welcome to Donut Browser"),
false,
"completed test sessions must not leave the Welcome dialog over the UI",
);
const group = await createGroupThroughUi(app);
assert.ok(group);
await app.capture("01-profile-group-created");
const httpProxy = await createProxyThroughUi(app, httpSettings);
assert.ok(httpProxy);
assert.equal(
httpProxy.proxy_settings.proxy_type === httpSettings.proxy_type &&
httpProxy.proxy_settings.host === httpSettings.host &&
httpProxy.proxy_settings.port === httpSettings.port &&
httpProxy.proxy_settings.username === httpSettings.username &&
httpProxy.proxy_settings.password === httpSettings.password,
true,
"The HTTP proxy created through the UI did not preserve its settings",
);
const vpn = await createVpnThroughUi(
app,
realWireGuardConfig ?? wireGuardFixture(),
);
assert.ok(vpn);
activeVpnId = vpn.id;
await app.capture("02-proxy-and-vpn-created");
const extensionEntities = await createExtensionsThroughUi(app);
assert.ok(extensionEntities.extension);
assert.ok(extensionEntities.group);
await app.capture("03-extension-and-group-created");
const profile = await createProfileThroughUi(app, group.name);
assert.ok(profile);
assert.equal(profile.version, prepared.version);
assert.equal(profile.group_id, group.id);
await assignExtensionGroupThroughUi(
app,
profile.name,
"Default",
extensionEntities.group.name,
);
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
)?.extension_group_id === extensionEntities.group.id,
{ description: "extension group assignment persisted" },
);
await app.capture("04-profile-created");
const socksProxy = await app.invoke("create_stored_proxy", {
name: "Residential SOCKS5",
proxySettings: socksSettings,
});
const [httpCheck, socksCheck] = await Promise.all([
app.invoke("check_proxy_validity", {
proxyId: httpProxy.id,
proxySettings: null,
}),
app.invoke("check_proxy_validity", {
proxyId: socksProxy.id,
proxySettings: null,
}),
]);
assert.equal(httpCheck.is_valid, true);
assert.equal(socksCheck.is_valid, true);
assert.ok(isIP(httpCheck.ip));
assert.ok(isIP(socksCheck.ip));
await assignNetworkThroughUi(
app,
profile.name,
"Not selected",
httpProxy.name,
);
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
)?.proxy_id === httpProxy.id,
{ description: "HTTP proxy assignment persisted" },
);
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,
},
});
apiPort = await app.invoke("start_api_server", { port: 0 });
const base = `http://127.0.0.1:${apiPort}`;
const proxied = await runProfile(
app,
base,
saved.api_token,
profile.id,
"https://api.ipify.org/",
);
activeCdp = proxied.cdp;
const browserExitIp = await activeCdp.waitFor(
`(() => {
const value = document.body?.innerText?.trim() ?? "";
return /^[0-9a-f:.]+$/i.test(value) ? value : false;
})()`,
{ timeoutMs: 30_000, description: "Wayfern residential proxy exit IP" },
);
assert.ok(isIP(browserExitIp));
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
activeCdp = null;
await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]);
await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name);
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
)?.vpn_id === vpn.id,
{ description: "WireGuard assignment persisted" },
);
await app.capture("05-proxy-and-vpn-assigned");
if (realWireGuardConfig) {
const tunneled = await runProfile(
app,
base,
saved.api_token,
profile.id,
process.env.DONUT_E2E_WIREGUARD_TARGET_URL,
);
activeCdp = tunneled.cdp;
await app.waitFor(wireGuardTargetWasReached, {
timeoutMs: 30_000,
description: "Wayfern GET through local WireGuard peer",
});
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
activeCdp = null;
}
} catch (error) {
await app.capture("failure");
throw error;
} finally {
activeCdp?.close();
if (app.session) {
if (apiPort) await app.invoke("stop_api_server").catch(() => {});
for (const profile of await app
.invoke("list_browser_profiles")
.catch(() => [])) {
if (profile.process_id) {
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
}
}
if (activeVpnId) {
await app
.invoke("disconnect_vpn", { vpnId: activeVpnId })
.catch(() => {});
}
}
await app.close();
}
});