mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-02 01:08:41 +02:00
feat: xray support
This commit is contained in:
Generated
+38
-31
@@ -570,6 +570,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -1870,7 +1876,7 @@ name = "donutbrowser-e2e"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"donutbrowser",
|
||||
"tauri-plugin-cross-platform-webdriver",
|
||||
"tauri-wd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6637,36 +6643,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-cross-platform-webdriver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
"cairo-rs",
|
||||
"glib 0.21.5",
|
||||
"gtk",
|
||||
"javascriptcore-rs",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-web-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
@@ -6929,6 +6905,37 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-wd"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.23.0",
|
||||
"block2",
|
||||
"cairo-rs",
|
||||
"glib 0.21.5",
|
||||
"gtk",
|
||||
"image",
|
||||
"javascriptcore-rs",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-web-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winres"
|
||||
version = "0.3.6"
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
|
||||
tauri-plugin-cross-platform-webdriver = { path = "../../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver" }
|
||||
tauri-wd = { path = "../../../tauri-wd/crates/tauri-wd" }
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
|
||||
fn main() {
|
||||
donutbrowser_lib::run_with_builder(|builder| {
|
||||
builder.plugin(tauri_plugin_cross_platform_webdriver::init())
|
||||
builder.plugin(tauri_wd::init())
|
||||
});
|
||||
}
|
||||
|
||||
+56
-27
@@ -159,6 +159,10 @@ export class AppSession {
|
||||
});
|
||||
}
|
||||
if (this.seedVersionCache) {
|
||||
const seededVersion =
|
||||
typeof this.seedVersionCache === "string"
|
||||
? this.seedVersionCache
|
||||
: "150.0.7871.100";
|
||||
const versionCache = path.join(
|
||||
this.root,
|
||||
"donut",
|
||||
@@ -170,7 +174,7 @@ export class AppSession {
|
||||
await writeFile(
|
||||
versionCache,
|
||||
`${JSON.stringify({
|
||||
releases: [{ version: "150.0.7871.100", date: "2026-07-01" }],
|
||||
releases: [{ version: seededVersion, date: "2026-07-01" }],
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
})}\n`,
|
||||
{ flag: "wx" },
|
||||
@@ -228,7 +232,7 @@ export class AppSession {
|
||||
return this.session.execute(script, args);
|
||||
}
|
||||
|
||||
async invoke(command, args = {}) {
|
||||
async invoke(command, args = {}, timeoutMs = 330_000) {
|
||||
assert.ok(this.session, `${this.name} is not started`);
|
||||
const result = await this.session.executeAsync(
|
||||
`
|
||||
@@ -243,6 +247,7 @@ export class AppSession {
|
||||
}));
|
||||
`,
|
||||
[command, args],
|
||||
timeoutMs,
|
||||
);
|
||||
if (!result?.ok) {
|
||||
throw new Error(
|
||||
@@ -314,6 +319,27 @@ export class AppSession {
|
||||
});
|
||||
}
|
||||
|
||||
async clickElement(element, description = "element") {
|
||||
await this.waitFor(
|
||||
() =>
|
||||
this.execute(
|
||||
`
|
||||
const node = arguments[0];
|
||||
if (!(node instanceof Element) || !node.isConnected) return false;
|
||||
node.scrollIntoView({ block: "center", inline: "center" });
|
||||
const rect = node.getBoundingClientRect();
|
||||
const x = Math.floor(rect.left + rect.width / 2);
|
||||
const y = Math.floor(rect.top + rect.height / 2);
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
return Boolean(hit && (hit === node || node.contains(hit)));
|
||||
`,
|
||||
[element],
|
||||
),
|
||||
{ description: `pointer-interactable ${description}` },
|
||||
);
|
||||
await this.session.click(element);
|
||||
}
|
||||
|
||||
async clickText(
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
@@ -342,7 +368,7 @@ export class AppSession {
|
||||
element,
|
||||
`No visible interactive element matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
await this.session.click(element);
|
||||
await this.clickElement(element, JSON.stringify(text));
|
||||
}
|
||||
|
||||
async clickTextIn(
|
||||
@@ -380,7 +406,10 @@ export class AppSession {
|
||||
element,
|
||||
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
await this.session.click(element);
|
||||
await this.clickElement(
|
||||
element,
|
||||
`${JSON.stringify(text)} inside ${containerSelector}`,
|
||||
);
|
||||
}
|
||||
|
||||
async clickSelector(selector) {
|
||||
@@ -399,7 +428,7 @@ export class AppSession {
|
||||
),
|
||||
{ description: `visible selector ${selector}` },
|
||||
);
|
||||
await this.session.click(element);
|
||||
await this.clickElement(element, selector);
|
||||
}
|
||||
|
||||
async fillSelector(selector, value) {
|
||||
@@ -421,28 +450,28 @@ export class AppSession {
|
||||
alt = false,
|
||||
shift = false,
|
||||
}) {
|
||||
await this.execute(
|
||||
`
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: arguments[0],
|
||||
code: arguments[1],
|
||||
metaKey: arguments[2],
|
||||
ctrlKey: arguments[3],
|
||||
altKey: arguments[4],
|
||||
shiftKey: arguments[5],
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
`,
|
||||
[
|
||||
key,
|
||||
key.length === 1 ? `Key${key.toUpperCase()}` : key,
|
||||
meta,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
],
|
||||
);
|
||||
const modifiers = [
|
||||
...(meta ? ["\uE03D"] : []),
|
||||
...(ctrl ? ["\uE009"] : []),
|
||||
...(alt ? ["\uE00A"] : []),
|
||||
...(shift ? ["\uE008"] : []),
|
||||
];
|
||||
const value = key === "Escape" ? "\uE00C" : key;
|
||||
const actions = [
|
||||
...modifiers.map((modifier) => ({ type: "keyDown", value: modifier })),
|
||||
{ type: "keyDown", value },
|
||||
{ type: "keyUp", value },
|
||||
...modifiers
|
||||
.toReversed()
|
||||
.map((modifier) => ({ type: "keyUp", value: modifier })),
|
||||
];
|
||||
try {
|
||||
await this.session.command("POST", "/actions", {
|
||||
actions: [{ type: "key", id: "keyboard", actions }],
|
||||
});
|
||||
} finally {
|
||||
await this.session.command("DELETE", "/actions");
|
||||
}
|
||||
}
|
||||
|
||||
async capture(label) {
|
||||
|
||||
+59
-5
@@ -1,7 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, copyFile, cp, mkdir, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
cp,
|
||||
mkdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -69,6 +77,42 @@ async function cloneAppBundle(source, destination) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheDownloadedWayfern(app, projectRoot, version) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) return;
|
||||
const destination = defaultWayfernPath(projectRoot);
|
||||
if (existsSync(destination)) return;
|
||||
|
||||
const installDir = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"binaries",
|
||||
"wayfern",
|
||||
version,
|
||||
);
|
||||
const source =
|
||||
process.platform === "darwin"
|
||||
? path.join(installDir, "Wayfern.app")
|
||||
: path.join(
|
||||
installDir,
|
||||
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
||||
);
|
||||
const staging = `${destination}.tmp-${process.pid}`;
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
try {
|
||||
if (process.platform === "darwin") {
|
||||
await cloneAppBundle(source, staging);
|
||||
} else {
|
||||
await mkdir(path.dirname(staging), { recursive: true });
|
||||
await copyFile(source, staging);
|
||||
if (process.platform !== "win32") await chmod(staging, 0o755);
|
||||
}
|
||||
await rename(staging, destination);
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
if (!existsSync(destination)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedWayfern(dataRoot, wayfern) {
|
||||
const installDir = path.join(
|
||||
dataRoot,
|
||||
@@ -130,10 +174,20 @@ export async function prepareWayfern(app, projectRoot) {
|
||||
"No Wayfern build is published for this platform",
|
||||
);
|
||||
const version = current.versions[0];
|
||||
await app.invoke("download_browser", {
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
});
|
||||
await app.session.setTimeouts({ script: 600_000 });
|
||||
try {
|
||||
await app.invoke(
|
||||
"download_browser",
|
||||
{
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
},
|
||||
620_000,
|
||||
);
|
||||
} finally {
|
||||
await app.session.setTimeouts();
|
||||
}
|
||||
await cacheDownloadedWayfern(app, projectRoot, version);
|
||||
return { version, source: "published download" };
|
||||
}
|
||||
|
||||
|
||||
+186
-5
@@ -15,6 +15,7 @@ import {
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
@@ -27,10 +28,7 @@ import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(dirname, "..");
|
||||
const webdriverRoot = path.resolve(
|
||||
projectRoot,
|
||||
"../tauri-cross-platform-webdriver",
|
||||
);
|
||||
const webdriverRoot = path.resolve(projectRoot, "../tauri-wd");
|
||||
const isWindows = process.platform === "win32";
|
||||
const executableSuffix = isWindows ? ".exe" : "";
|
||||
const appBinary = path.join(
|
||||
@@ -246,6 +244,7 @@ function buildAll() {
|
||||
}
|
||||
run("pnpm", ["build"], projectRoot);
|
||||
run("pnpm", ["copy-proxy-binary"], projectRoot);
|
||||
run(process.execPath, ["src-tauri/download-xray.mjs"], projectRoot);
|
||||
run(
|
||||
"cargo",
|
||||
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
|
||||
@@ -520,6 +519,178 @@ function dockerAvailable() {
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function hostRustTarget() {
|
||||
const result = spawnSync("rustc", ["-vV"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`Could not determine the host Rust target: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status}`}`,
|
||||
);
|
||||
}
|
||||
const target = result.stdout.match(/^host:\s*(.+)$/m)?.[1]?.trim();
|
||||
if (!target) {
|
||||
throw new Error("rustc -vV did not report a host target");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function xrayBinaryPath() {
|
||||
const target = hostRustTarget();
|
||||
return path.join(
|
||||
projectRoot,
|
||||
"src-tauri",
|
||||
"binaries",
|
||||
`xray-${target}${target.includes("windows") ? ".exe" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPort(port, timeoutMs, processRecord) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (processRecord?.process.exitCode !== null) {
|
||||
throw new Error(
|
||||
`${processRecord.name} exited early with ${processRecord.process.exitCode}; see ${processRecord.logPath}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
socket.setTimeout(1_000);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("connection timed out"));
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for 127.0.0.1:${port}: ${lastError?.message ?? lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function startXrayInfrastructure(runRoot, options, records) {
|
||||
const executable = xrayBinaryPath();
|
||||
if (!existsSync(executable)) {
|
||||
throw new Error(
|
||||
`The Xray-core E2E binary is missing: ${executable}. Run node src-tauri/download-xray.mjs first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const keyResult = spawnSync(executable, ["x25519"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (keyResult.error || keyResult.status !== 0) {
|
||||
throw new Error(
|
||||
`Xray-core key generation failed: ${keyResult.error?.message ?? keyResult.stderr?.trim() ?? `exit ${keyResult.status}`}`,
|
||||
);
|
||||
}
|
||||
const privateKey = keyResult.stdout.match(/^PrivateKey:\s*(\S+)\s*$/m)?.[1];
|
||||
const publicKey = keyResult.stdout.match(
|
||||
/^(?:Password \(PublicKey\)|PublicKey):\s*(\S+)\s*$/m,
|
||||
)?.[1];
|
||||
if (!privateKey || !publicKey) {
|
||||
throw new Error("Xray-core key generation returned an unknown format");
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
const id = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
|
||||
const shortId = "0123456789abcdef";
|
||||
const serverName = "www.cloudflare.com";
|
||||
const accessLog = path.join(runRoot, "logs", "xray-access.log");
|
||||
const configPath = path.join(runRoot, "xray-server.json");
|
||||
const config = {
|
||||
log: {
|
||||
access: accessLog,
|
||||
loglevel: "warning",
|
||||
},
|
||||
inbounds: [
|
||||
{
|
||||
tag: "vless-reality",
|
||||
listen: "127.0.0.1",
|
||||
port,
|
||||
protocol: "vless",
|
||||
settings: {
|
||||
clients: [{ id, flow: "xtls-rprx-vision" }],
|
||||
decryption: "none",
|
||||
},
|
||||
streamSettings: {
|
||||
network: "raw",
|
||||
security: "reality",
|
||||
realitySettings: {
|
||||
show: false,
|
||||
target: `${serverName}:443`,
|
||||
xver: 0,
|
||||
serverNames: [serverName],
|
||||
privateKey,
|
||||
shortIds: [shortId],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
outbounds: [{ tag: "direct", protocol: "freedom" }],
|
||||
};
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const validation = spawnSync(executable, ["run", "-test", "-c", configPath], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
if (validation.error || validation.status !== 0) {
|
||||
throw new Error(
|
||||
`Xray-core server configuration is invalid: ${validation.error?.message ?? validation.stderr?.trim() ?? `exit ${validation.status}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
const server = startProcess(
|
||||
"xray-server",
|
||||
executable,
|
||||
["run", "-c", configPath],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
runRoot,
|
||||
verbose: options.verbose,
|
||||
},
|
||||
);
|
||||
records.push(server);
|
||||
await waitForPort(port, 15_000, server);
|
||||
|
||||
const uri = new URL(`vless://${id}@127.0.0.1:${port}`);
|
||||
uri.searchParams.set("encryption", "none");
|
||||
uri.searchParams.set("flow", "xtls-rprx-vision");
|
||||
uri.searchParams.set("security", "reality");
|
||||
uri.searchParams.set("sni", serverName);
|
||||
uri.searchParams.set("fp", "chrome");
|
||||
uri.searchParams.set("pbk", publicKey);
|
||||
uri.searchParams.set("sid", shortId);
|
||||
uri.searchParams.set("spx", "/");
|
||||
uri.searchParams.set("type", "tcp");
|
||||
uri.searchParams.set("headerType", "none");
|
||||
uri.hash = "Local Xray E2E";
|
||||
|
||||
return {
|
||||
accessLog,
|
||||
configPath,
|
||||
privateKey,
|
||||
uri: uri.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function startWireGuardInfrastructure() {
|
||||
if (!dockerAvailable()) {
|
||||
throw new Error(
|
||||
@@ -633,6 +804,7 @@ async function main() {
|
||||
const records = [];
|
||||
let fixture;
|
||||
let wireGuard;
|
||||
let xray;
|
||||
let failed = false;
|
||||
const sensitiveValues = [
|
||||
"donut-e2e-sync-token-0123456789abcdef",
|
||||
@@ -646,6 +818,9 @@ async function main() {
|
||||
if (wireGuard) {
|
||||
runDocker(["rm", "-f", wireGuard.name], { allowFailure: true });
|
||||
}
|
||||
if (xray) {
|
||||
await rm(xray.configPath, { force: true });
|
||||
}
|
||||
if (!options.keep && !failed) {
|
||||
await rm(runRoot, { recursive: true, force: true });
|
||||
} else {
|
||||
@@ -686,7 +861,7 @@ async function main() {
|
||||
"--startup-timeout",
|
||||
"120",
|
||||
"--command-timeout",
|
||||
"330",
|
||||
"630",
|
||||
"--log",
|
||||
options.verbose ? "debug" : "info",
|
||||
],
|
||||
@@ -716,6 +891,10 @@ async function main() {
|
||||
if (networkEnabled && process.env.DONUT_E2E_SKIP_VPN_TUNNEL !== "1") {
|
||||
wireGuard = await startWireGuardInfrastructure();
|
||||
}
|
||||
if (networkEnabled) {
|
||||
xray = await startXrayInfrastructure(runRoot, options, records);
|
||||
sensitiveValues.push(xray.privateKey);
|
||||
}
|
||||
|
||||
const localValues = await loadLocalValues([
|
||||
"WAYFERN_TEST_TOKEN",
|
||||
@@ -767,6 +946,8 @@ async function main() {
|
||||
: "",
|
||||
DONUT_E2E_WIREGUARD_TARGET_URL: wireGuard?.targetUrl ?? "",
|
||||
DONUT_E2E_WIREGUARD_CONTAINER: wireGuard?.name ?? "",
|
||||
DONUT_E2E_VLESS_URI: xray?.uri ?? "",
|
||||
DONUT_E2E_XRAY_ACCESS_LOG: xray?.accessLog ?? "",
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { CdpClient } from "../lib/cdp.mjs";
|
||||
import { defaultWayfernPath, prepareWayfern } from "../lib/fixtures.mjs";
|
||||
import {
|
||||
defaultWayfernPath,
|
||||
inspectWayfern,
|
||||
prepareWayfern,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
|
||||
@@ -133,11 +137,14 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
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 localWayfernPath = defaultWayfernPath(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const localWayfernVersion = existsSync(localWayfernPath)
|
||||
? inspectWayfern(localWayfernPath).version
|
||||
: null;
|
||||
const app = appFromEnvironment("browser-wayfern", {
|
||||
seedVersionCache: hasLocalWayfern,
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let cdp;
|
||||
|
||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
|
||||
const VLESS_URI =
|
||||
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP";
|
||||
|
||||
async function jsonRequest(
|
||||
url,
|
||||
{ method = "GET", token, body, headers = {} } = {},
|
||||
@@ -139,6 +142,49 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(fetchedProxy.value.name, "REST Proxy");
|
||||
const createdVless = await jsonRequest(`${base}/v1/proxies`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST VLESS Reality",
|
||||
proxy_settings: {
|
||||
proxy_type: "vless",
|
||||
host: "127.0.0.1",
|
||||
port: 443,
|
||||
username: null,
|
||||
password: null,
|
||||
vless_uri: VLESS_URI,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(createdVless.response.status, 200);
|
||||
assert.equal(createdVless.value.proxy_settings.vless_uri, VLESS_URI);
|
||||
assert.equal(createdVless.value.proxy_settings.host, "127.0.0.1");
|
||||
assert.equal(createdVless.value.proxy_settings.port, 443);
|
||||
const vlessProxyId = createdVless.value.id;
|
||||
const invalidVless = await jsonRequest(
|
||||
`${base}/v1/proxies/${vlessProxyId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
proxy_settings: {
|
||||
...createdVless.value.proxy_settings,
|
||||
vless_uri: VLESS_URI.replace("security=reality", "security=tls"),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(invalidVless.response.status, 400);
|
||||
assert.match(JSON.stringify(invalidVless.value), /VLESS_CONFIG_INVALID/);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/proxies/${vlessProxyId}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.proxy_settings.vless_uri,
|
||||
VLESS_URI,
|
||||
);
|
||||
const imported = await jsonRequest(`${base}/v1/proxies/import`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
@@ -171,6 +217,15 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/proxies/${vlessProxyId}`, {
|
||||
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",
|
||||
@@ -257,6 +312,8 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
"create_profile",
|
||||
"run_profile",
|
||||
"list_proxies",
|
||||
"create_proxy",
|
||||
"update_proxy",
|
||||
"get_page_content",
|
||||
"get_interactive_elements",
|
||||
]) {
|
||||
@@ -276,6 +333,80 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
assert.equal(listed.value.error, undefined);
|
||||
assert.ok(listed.value.result);
|
||||
|
||||
const createdVless = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "create_proxy",
|
||||
arguments: {
|
||||
name: "MCP VLESS Reality",
|
||||
proxy_type: "vless",
|
||||
vless_uri: VLESS_URI,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(createdVless.response.status, 200);
|
||||
assert.equal(createdVless.value.error, undefined);
|
||||
let vlessProxy = (await app.invoke("get_stored_proxies")).find(
|
||||
(proxy) => proxy.name === "MCP VLESS Reality",
|
||||
);
|
||||
assert.ok(vlessProxy);
|
||||
assert.equal(vlessProxy.proxy_settings.proxy_type, "vless");
|
||||
assert.equal(vlessProxy.proxy_settings.vless_uri, VLESS_URI);
|
||||
|
||||
const updatedVless = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "update_proxy",
|
||||
arguments: {
|
||||
proxy_id: vlessProxy.id,
|
||||
name: "MCP VLESS Updated",
|
||||
vless_uri: VLESS_URI,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(updatedVless.value.error, undefined);
|
||||
vlessProxy = (await app.invoke("get_stored_proxies")).find(
|
||||
(proxy) => proxy.id === vlessProxy.id,
|
||||
);
|
||||
assert.equal(vlessProxy.name, "MCP VLESS Updated");
|
||||
|
||||
const invalidVless = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "update_proxy",
|
||||
arguments: {
|
||||
proxy_id: vlessProxy.id,
|
||||
vless_uri: VLESS_URI.replace("security=reality", "security=tls"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.match(invalidVless.value.error.message, /VLESS_CONFIG_INVALID/);
|
||||
assert.equal(
|
||||
(await app.invoke("get_stored_proxies")).find(
|
||||
(proxy) => proxy.id === vlessProxy.id,
|
||||
).proxy_settings.vless_uri,
|
||||
VLESS_URI,
|
||||
);
|
||||
await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id });
|
||||
|
||||
const agents = await app.invoke("list_mcp_agents");
|
||||
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
||||
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
|
||||
|
||||
+325
-1
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -406,6 +406,330 @@ function wireGuardTargetWasReached() {
|
||||
);
|
||||
}
|
||||
|
||||
function processIsRunning(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function createXrayProfile(app, version) {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name: "Xray Reality Profile",
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
wayfernConfig: {
|
||||
fingerprint: null,
|
||||
randomize_fingerprint_on_launch: false,
|
||||
geoip: false,
|
||||
},
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
}
|
||||
|
||||
test("VLESS Reality persists, imports, routes through Xray-core, records traffic, and cleans up", async () => {
|
||||
const vlessUri = process.env.DONUT_E2E_VLESS_URI;
|
||||
const accessLog = process.env.DONUT_E2E_XRAY_ACCESS_LOG;
|
||||
assert.ok(vlessUri, "The network harness must provide a VLESS Reality URI");
|
||||
assert.ok(accessLog, "The network harness must provide an Xray access log");
|
||||
|
||||
const app = appFromEnvironment("network-xray-reality", {
|
||||
seedVersionCache: false,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let apiPort;
|
||||
let activeCdp;
|
||||
let profile;
|
||||
let worker;
|
||||
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();
|
||||
}
|
||||
|
||||
const invalidUri = vlessUri.replace("security=reality", "security=tls");
|
||||
const invalidCreate = await app.invokeError("create_stored_proxy", {
|
||||
name: "Invalid VLESS",
|
||||
proxySettings: {
|
||||
proxy_type: "vless",
|
||||
host: "ignored.invalid",
|
||||
port: 1,
|
||||
username: "must-be-cleared",
|
||||
password: "must-be-cleared",
|
||||
vless_uri: invalidUri,
|
||||
},
|
||||
});
|
||||
assert.match(invalidCreate, /VLESS_CONFIG_INVALID/);
|
||||
|
||||
const created = await app.invoke("create_stored_proxy", {
|
||||
name: "Local VLESS Reality",
|
||||
proxySettings: {
|
||||
proxy_type: "VLESS",
|
||||
host: "ignored.invalid",
|
||||
port: 1,
|
||||
username: "must-be-cleared",
|
||||
password: "must-be-cleared",
|
||||
vless_uri: vlessUri,
|
||||
},
|
||||
});
|
||||
assert.equal(created.proxy_settings.proxy_type, "vless");
|
||||
assert.equal(created.proxy_settings.host, "127.0.0.1");
|
||||
assert.equal(created.proxy_settings.port, Number(new URL(vlessUri).port));
|
||||
assert.equal(created.proxy_settings.username, null);
|
||||
assert.equal(created.proxy_settings.password, null);
|
||||
assert.equal(created.proxy_settings.vless_uri, vlessUri);
|
||||
|
||||
const updated = await app.invoke("update_stored_proxy", {
|
||||
proxyId: created.id,
|
||||
name: "Updated VLESS Reality",
|
||||
proxySettings: {
|
||||
proxy_type: "vless",
|
||||
host: "still-ignored.invalid",
|
||||
port: 2,
|
||||
username: "still-cleared",
|
||||
password: "still-cleared",
|
||||
vless_uri: vlessUri,
|
||||
},
|
||||
});
|
||||
assert.equal(updated.name, "Updated VLESS Reality");
|
||||
assert.equal(updated.proxy_settings.host, "127.0.0.1");
|
||||
assert.equal(updated.proxy_settings.username, null);
|
||||
assert.equal(updated.proxy_settings.password, null);
|
||||
|
||||
const exportedJson = await app.invoke("export_proxies", {
|
||||
format: "json",
|
||||
});
|
||||
const exported = JSON.parse(exportedJson);
|
||||
assert.equal(exported.proxies.length, 1);
|
||||
assert.deepEqual(exported.proxies[0], {
|
||||
name: "Updated VLESS Reality",
|
||||
type: "vless",
|
||||
host: "127.0.0.1",
|
||||
port: Number(new URL(vlessUri).port),
|
||||
vless_uri: vlessUri,
|
||||
});
|
||||
assert.equal(
|
||||
await app.invoke("export_proxies", { format: "txt" }),
|
||||
vlessUri,
|
||||
);
|
||||
|
||||
const parsed = await app.invoke("parse_txt_proxies", {
|
||||
content: `${vlessUri}\n${invalidUri}\n`,
|
||||
});
|
||||
assert.equal(parsed.length, 2);
|
||||
assert.equal(parsed[0].status, "parsed");
|
||||
assert.equal(parsed[0].proxy_type, "vless");
|
||||
assert.equal(parsed[0].vless_uri, vlessUri);
|
||||
assert.equal(parsed[1].status, "invalid");
|
||||
|
||||
await app.restart();
|
||||
const persisted = (await app.invoke("get_stored_proxies")).find(
|
||||
(candidate) => candidate.id === created.id,
|
||||
);
|
||||
assert.ok(persisted, "VLESS proxy did not survive an app restart");
|
||||
assert.equal(persisted.proxy_settings.vless_uri, vlessUri);
|
||||
|
||||
await app.invoke("delete_stored_proxy", { proxyId: created.id });
|
||||
const imported = await app.invoke("import_proxies_json", {
|
||||
content: exportedJson,
|
||||
});
|
||||
assert.equal(imported.imported_count, 1);
|
||||
assert.equal(imported.skipped_count, 0);
|
||||
assert.deepEqual(imported.errors, []);
|
||||
assert.equal(imported.proxies[0].proxy_settings.vless_uri, vlessUri);
|
||||
const proxy = imported.proxies[0];
|
||||
|
||||
const invalidImport = await app.invoke("import_proxies_json", {
|
||||
content: JSON.stringify({
|
||||
version: "1.0",
|
||||
source: "DonutBrowser",
|
||||
exported_at: new Date().toISOString(),
|
||||
proxies: [
|
||||
{
|
||||
name: "Rejected VLESS",
|
||||
type: "vless",
|
||||
host: "127.0.0.1",
|
||||
port: 443,
|
||||
vless_uri: invalidUri,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(invalidImport.imported_count, 0);
|
||||
assert.equal(invalidImport.errors.length, 1);
|
||||
assert.match(invalidImport.errors[0], /VLESS_CONFIG_INVALID/);
|
||||
|
||||
profile = await createXrayProfile(app, prepared.version);
|
||||
await app.invoke("update_profile_proxy", {
|
||||
profileId: profile.id,
|
||||
proxyId: proxy.id,
|
||||
});
|
||||
const assigned = (await app.invoke("list_browser_profiles")).find(
|
||||
(candidate) => candidate.id === profile.id,
|
||||
);
|
||||
assert.equal(assigned.proxy_id, proxy.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,
|
||||
},
|
||||
});
|
||||
apiPort = await app.invoke("start_api_server", { port: 0 });
|
||||
const base = `http://127.0.0.1:${apiPort}`;
|
||||
const launched = await runProfile(
|
||||
app,
|
||||
base,
|
||||
saved.api_token,
|
||||
profile.id,
|
||||
"https://api.ipify.org/",
|
||||
);
|
||||
activeCdp = launched.cdp;
|
||||
const exitIp = await activeCdp.waitFor(
|
||||
`(() => {
|
||||
const value = document.body?.innerText?.trim() ?? "";
|
||||
return /^[0-9a-f:.]+$/i.test(value) ? value : false;
|
||||
})()`,
|
||||
{ timeoutMs: 30_000, description: "VLESS Reality exit IP" },
|
||||
);
|
||||
assert.ok(isIP(exitIp), `Unexpected exit IP response: ${exitIp}`);
|
||||
|
||||
const workerDirectory = path.join(app.dataRoot, "cache", "proxy_workers");
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const workerFiles = await readdir(workerDirectory).catch(() => []);
|
||||
const workerFile = workerFiles.find(
|
||||
(file) => file.startsWith("xray_worker_") && file.endsWith(".json"),
|
||||
);
|
||||
if (!workerFile) return false;
|
||||
worker = JSON.parse(
|
||||
await readFile(path.join(workerDirectory, workerFile), "utf8"),
|
||||
);
|
||||
return worker.profile_id === profile.id;
|
||||
},
|
||||
{ description: "profile-scoped Xray worker configuration" },
|
||||
);
|
||||
assert.ok(processIsRunning(worker.pid), "Xray supervisor is not running");
|
||||
assert.ok(processIsRunning(worker.xray_pid), "Xray-core is not running");
|
||||
assert.equal(worker.vless_uri, vlessUri);
|
||||
|
||||
const workerPath = path.join(
|
||||
workerDirectory,
|
||||
`xray_worker_${worker.id}.json`,
|
||||
);
|
||||
const runtimePath = path.join(
|
||||
workerDirectory,
|
||||
`xray_runtime_${worker.id}.json`,
|
||||
);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal((await stat(workerPath)).mode & 0o777, 0o600);
|
||||
assert.equal((await stat(runtimePath)).mode & 0o777, 0o600);
|
||||
}
|
||||
const runtime = JSON.parse(await readFile(runtimePath, "utf8"));
|
||||
assert.equal(runtime.inbounds[0].protocol, "socks");
|
||||
assert.equal(runtime.inbounds[0].listen, "127.0.0.1");
|
||||
assert.equal(runtime.outbounds[0].protocol, "vless");
|
||||
assert.equal(runtime.outbounds[0].streamSettings.security, "reality");
|
||||
assert.equal(
|
||||
runtime.outbounds[0].settings.vnext[0].users[0].flow,
|
||||
"xtls-rprx-vision",
|
||||
);
|
||||
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await readFile(accessLog, "utf8").catch(() => "")).includes(
|
||||
"api.ipify.org:443",
|
||||
),
|
||||
{
|
||||
timeoutMs: 15_000,
|
||||
description: "request in Xray-core server access log",
|
||||
},
|
||||
);
|
||||
const liveTraffic = await app.waitFor(
|
||||
async () => {
|
||||
const snapshot = await app.invoke("get_profile_traffic_snapshot", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
return snapshot?.total_bytes_sent > 0 &&
|
||||
snapshot?.total_bytes_received > 0
|
||||
? snapshot
|
||||
: false;
|
||||
},
|
||||
{
|
||||
timeoutMs: 15_000,
|
||||
description: "local traffic snapshot for VLESS profile",
|
||||
},
|
||||
);
|
||||
assert.ok(liveTraffic.total_requests > 0);
|
||||
|
||||
const supervisorPid = worker.pid;
|
||||
const xrayPid = worker.xray_pid;
|
||||
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
|
||||
activeCdp = null;
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const files = await readdir(workerDirectory).catch(() => []);
|
||||
return (
|
||||
!files.includes(`xray_worker_${worker.id}.json`) &&
|
||||
!processIsRunning(supervisorPid) &&
|
||||
!processIsRunning(xrayPid)
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 15_000,
|
||||
description: "Xray worker process and configuration cleanup",
|
||||
},
|
||||
);
|
||||
await assert.rejects(readFile(runtimePath), { code: "ENOENT" });
|
||||
|
||||
const persistedTraffic = await app.invoke("get_profile_traffic_snapshot", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.ok(
|
||||
persistedTraffic.total_bytes_sent >= liveTraffic.total_bytes_sent,
|
||||
);
|
||||
assert.ok(
|
||||
persistedTraffic.total_bytes_received >= liveTraffic.total_bytes_received,
|
||||
);
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
activeCdp?.close();
|
||||
if (app.session) {
|
||||
if (apiPort) await app.invoke("stop_api_server").catch(() => {});
|
||||
if (profile) {
|
||||
const latest = (
|
||||
await app.invoke("list_browser_profiles").catch(() => [])
|
||||
).find((candidate) => candidate.id === profile.id);
|
||||
if (latest?.process_id) {
|
||||
await app
|
||||
.invoke("kill_browser_profile", { profile: latest })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
+446
-34
@@ -1,5 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import Color from "color";
|
||||
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
|
||||
const THEME_VARIABLES = [
|
||||
@@ -31,34 +33,8 @@ const THEME_VARIABLES = [
|
||||
"--chart-5",
|
||||
];
|
||||
|
||||
const DRACULA_THEME = {
|
||||
"--background": "#282a36",
|
||||
"--foreground": "#f8f8f2",
|
||||
"--card": "#44475a",
|
||||
"--card-foreground": "#f8f8f2",
|
||||
"--popover": "#44475a",
|
||||
"--popover-foreground": "#f8f8f2",
|
||||
"--primary": "#bd93f9",
|
||||
"--primary-foreground": "#282a36",
|
||||
"--secondary": "#8be9fd",
|
||||
"--secondary-foreground": "#282a36",
|
||||
"--muted": "#6272a4",
|
||||
"--muted-foreground": "#f8f8f2",
|
||||
"--accent": "#ff79c6",
|
||||
"--accent-foreground": "#282a36",
|
||||
"--destructive": "#ff5555",
|
||||
"--destructive-foreground": "#f8f8f2",
|
||||
"--success": "#50fa7b",
|
||||
"--success-foreground": "#282a36",
|
||||
"--warning": "#ffb86c",
|
||||
"--warning-foreground": "#282a36",
|
||||
"--border": "#6272a4",
|
||||
"--chart-1": "#bd93f9",
|
||||
"--chart-2": "#50fa7b",
|
||||
"--chart-3": "#ff79c6",
|
||||
"--chart-4": "#8be9fd",
|
||||
"--chart-5": "#ffb86c",
|
||||
};
|
||||
const DRACULA_THEME = THEMES.find((theme) => theme.id === "dracula").colors;
|
||||
const AYU_LIGHT_THEME = THEMES.find((theme) => theme.id === "ayu-light").colors;
|
||||
|
||||
async function dismissSurface(app) {
|
||||
await app.pressShortcut({ key: "Escape" });
|
||||
@@ -108,6 +84,90 @@ function themeVariablesEqual(actual, expected) {
|
||||
);
|
||||
}
|
||||
|
||||
async function applyThemeForContrastAudit(app, theme) {
|
||||
await app.execute(
|
||||
`
|
||||
const [colors, derived, mode] = arguments;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(mode);
|
||||
for (const [key, value] of Object.entries({ ...colors, ...derived })) {
|
||||
root.style.setProperty(key, value, "important");
|
||||
}
|
||||
`,
|
||||
[theme.colors, getDerivedThemeColors(theme.colors), theme.mode],
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
async function animatedTabContrastSnapshot(app) {
|
||||
return app.execute(`
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const triggers = [
|
||||
...document.querySelectorAll('[data-slot="animated-tabs-trigger"]'),
|
||||
];
|
||||
const active = triggers.find(
|
||||
(trigger) => trigger.getAttribute("data-state") === "active",
|
||||
);
|
||||
const inactive = triggers.find(
|
||||
(trigger) => trigger.getAttribute("data-state") === "inactive",
|
||||
);
|
||||
const content = (trigger) =>
|
||||
[...(trigger?.children ?? [])].filter(
|
||||
(child) =>
|
||||
child.getAttribute("data-slot") !== "animated-tabs-indicator",
|
||||
);
|
||||
const activeContent = content(active);
|
||||
const inactiveContent = content(inactive);
|
||||
const indicator = active?.querySelector(
|
||||
'[data-slot="animated-tabs-indicator"]',
|
||||
);
|
||||
return {
|
||||
mode: document.documentElement.classList.contains("light")
|
||||
? "light"
|
||||
: "dark",
|
||||
background: rootStyle.getPropertyValue("--background").trim(),
|
||||
accent: rootStyle.getPropertyValue("--accent").trim(),
|
||||
accentForeground: rootStyle
|
||||
.getPropertyValue("--accent-foreground")
|
||||
.trim(),
|
||||
mutedForeground: rootStyle
|
||||
.getPropertyValue("--muted-foreground")
|
||||
.trim(),
|
||||
activeTitle: activeContent[0]
|
||||
? getComputedStyle(activeContent[0]).color
|
||||
: null,
|
||||
activeCount: activeContent[1]
|
||||
? getComputedStyle(activeContent[1]).color
|
||||
: null,
|
||||
activeBackground: indicator
|
||||
? getComputedStyle(indicator).backgroundColor
|
||||
: null,
|
||||
inactiveTitle: inactiveContent[0]
|
||||
? getComputedStyle(inactiveContent[0]).color
|
||||
: null,
|
||||
inactiveCount: inactiveContent[1]
|
||||
? getComputedStyle(inactiveContent[1]).color
|
||||
: null,
|
||||
};
|
||||
`);
|
||||
}
|
||||
|
||||
function assertColorEquals(actual, expected, description) {
|
||||
assert.ok(actual, `${description} is missing`);
|
||||
assert.equal(Color(actual).hex(), Color(expected).hex(), description);
|
||||
}
|
||||
|
||||
function assertContrast(foreground, background, minimum, description) {
|
||||
assert.ok(foreground, `${description} foreground is missing`);
|
||||
assert.ok(background, `${description} background is missing`);
|
||||
const ratio = Color(foreground).contrast(Color(background));
|
||||
assert.ok(
|
||||
ratio >= minimum,
|
||||
`${description} is ${ratio.toFixed(2)}:1; expected at least ${minimum}:1`,
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseSelectOption(app, triggerSelector, option) {
|
||||
await app.clickSelector(triggerSelector);
|
||||
await app.clickText(option, { roles: ["option"] });
|
||||
@@ -204,13 +264,15 @@ async function dragBackgroundColorPicker(app) {
|
||||
const pointerEvents = await app.execute(
|
||||
`return window.__donutE2eThemePointerEvents ?? [];`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
pointerEvents.map((event) => event.type),
|
||||
["pointermove", "pointerdown", "pointermove", "pointerup"],
|
||||
);
|
||||
assert.equal(pointerEvents[0]?.type, "pointermove");
|
||||
assert.equal(pointerEvents[1]?.type, "pointerdown");
|
||||
assert.equal(pointerEvents.at(-1)?.type, "pointerup");
|
||||
const dragMoves = pointerEvents.slice(2, -1);
|
||||
assert.ok(dragMoves.length >= 1);
|
||||
assert.ok(dragMoves.every((event) => event.type === "pointermove"));
|
||||
assert.match(pointerEvents[1].target, /cursor-pointer/);
|
||||
assert.match(pointerEvents[2].target, /cursor-pointer/);
|
||||
assert.equal(pointerEvents[2].buttons, 1);
|
||||
assert.match(dragMoves.at(-1).target, /cursor-pointer/);
|
||||
assert.equal(dragMoves.at(-1).buttons, 1);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
@@ -274,6 +336,325 @@ test("all primary navigation buttons and sub-page tabs render and remain interac
|
||||
});
|
||||
});
|
||||
|
||||
test("every custom theme keeps tabs, counts, group pills, and rail states readable", async () => {
|
||||
await withApp("ui-theme-contrast", async (app) => {
|
||||
await app.clickSelector('[aria-label="Extensions"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelectorAll('[data-slot="animated-tabs-trigger"]').length === 2;`,
|
||||
),
|
||||
{ description: "Extension tabs" },
|
||||
);
|
||||
|
||||
for (const theme of THEMES) {
|
||||
await applyThemeForContrastAudit(app, theme);
|
||||
const snapshot = await animatedTabContrastSnapshot(app);
|
||||
assert.equal(snapshot.mode, theme.mode, `${theme.id} appearance mode`);
|
||||
assertColorEquals(
|
||||
snapshot.activeBackground,
|
||||
snapshot.accent,
|
||||
`${theme.id} active tab background`,
|
||||
);
|
||||
for (const [label, color] of [
|
||||
["active tab title", snapshot.activeTitle],
|
||||
["active tab count", snapshot.activeCount],
|
||||
]) {
|
||||
assertColorEquals(
|
||||
color,
|
||||
snapshot.accentForeground,
|
||||
`${theme.id} ${label} token`,
|
||||
);
|
||||
assertContrast(
|
||||
color,
|
||||
snapshot.activeBackground,
|
||||
4.5,
|
||||
`${theme.id} ${label}`,
|
||||
);
|
||||
}
|
||||
for (const [label, color] of [
|
||||
["inactive tab title", snapshot.inactiveTitle],
|
||||
["inactive tab count", snapshot.inactiveCount],
|
||||
]) {
|
||||
assertColorEquals(
|
||||
color,
|
||||
snapshot.mutedForeground,
|
||||
`${theme.id} ${label} token`,
|
||||
);
|
||||
assertContrast(color, snapshot.background, 4.5, `${theme.id} ${label}`);
|
||||
}
|
||||
|
||||
await app.clickSelector(
|
||||
'[data-slot="animated-tabs-trigger"][data-state="inactive"]',
|
||||
);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return Boolean(document.querySelector(
|
||||
'[data-slot="animated-tabs-trigger"][data-state="active"] [data-slot="animated-tabs-indicator"]'
|
||||
));`,
|
||||
),
|
||||
{ description: `${theme.id} tab indicator after switching` },
|
||||
);
|
||||
}
|
||||
|
||||
await app.clickSelector('[aria-label="Groups"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return Boolean(document.querySelector('[data-slot="group-summary-pill"]'));`,
|
||||
),
|
||||
{ description: "Profile groups summary pill" },
|
||||
);
|
||||
|
||||
for (const theme of THEMES) {
|
||||
await applyThemeForContrastAudit(app, theme);
|
||||
const snapshot = await app.execute(`
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const pill = document.querySelector(
|
||||
'[data-slot="group-summary-pill"]',
|
||||
);
|
||||
const count = pill?.querySelector(
|
||||
'[data-slot="group-summary-count"]',
|
||||
);
|
||||
const title = pill?.firstElementChild;
|
||||
const rail = document.querySelector('nav [aria-current="page"]');
|
||||
return {
|
||||
pillBackground: pill ? getComputedStyle(pill).backgroundColor : null,
|
||||
pillTitle: title ? getComputedStyle(title).color : null,
|
||||
pillCount: count ? getComputedStyle(count).color : null,
|
||||
railBackground: rail
|
||||
? getComputedStyle(rail).backgroundColor
|
||||
: null,
|
||||
railForeground: rail ? getComputedStyle(rail).color : null,
|
||||
accent: rootStyle.getPropertyValue("--accent").trim(),
|
||||
accentForeground: rootStyle
|
||||
.getPropertyValue("--accent-foreground")
|
||||
.trim(),
|
||||
};
|
||||
`);
|
||||
assertColorEquals(
|
||||
snapshot.pillBackground,
|
||||
snapshot.accent,
|
||||
`${theme.id} group pill background`,
|
||||
);
|
||||
for (const [label, color] of [
|
||||
["group pill title", snapshot.pillTitle],
|
||||
["group pill count", snapshot.pillCount],
|
||||
]) {
|
||||
assertColorEquals(
|
||||
color,
|
||||
snapshot.accentForeground,
|
||||
`${theme.id} ${label} token`,
|
||||
);
|
||||
assertContrast(
|
||||
color,
|
||||
snapshot.pillBackground,
|
||||
4.5,
|
||||
`${theme.id} ${label}`,
|
||||
);
|
||||
}
|
||||
assertContrast(
|
||||
snapshot.railForeground,
|
||||
snapshot.railBackground,
|
||||
3,
|
||||
`${theme.id} selected rail icon`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("VLESS proxy form keeps the share URI as one clear, validated input", async () => {
|
||||
await withApp("ui-vless-proxy-form", async (app) => {
|
||||
const uri =
|
||||
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&type=tcp#E2E";
|
||||
|
||||
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", "E2E VLESS");
|
||||
await chooseSelectOption(app, "#proxy-type", "VLESS · Vision · REALITY");
|
||||
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#proxy-vless-uri") instanceof HTMLTextAreaElement;`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#proxy-host") === null &&
|
||||
document.querySelector("#proxy-port") === null &&
|
||||
document.querySelector("#proxy-username") === null &&
|
||||
document.querySelector("#proxy-password") === null;`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
await app.fillSelector(
|
||||
"#proxy-vless-uri",
|
||||
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com",
|
||||
);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "true";`,
|
||||
),
|
||||
{ description: "invalid VLESS endpoint feedback" },
|
||||
);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return [...document.querySelectorAll("[role='dialog'] button")]
|
||||
.find((button) => button.textContent?.trim() === "Add Proxy")
|
||||
?.disabled === true;`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
await app.fillSelector("#proxy-vless-uri", uri);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "false";`,
|
||||
),
|
||||
{ description: "valid VLESS endpoint feedback" },
|
||||
);
|
||||
await app.clickTextIn('[role="dialog"]', "Add Proxy", {
|
||||
roles: ["button"],
|
||||
});
|
||||
await app.waitForText("E2E VLESS");
|
||||
|
||||
const proxy = (await app.invoke("get_stored_proxies")).find(
|
||||
(item) => item.name === "E2E VLESS",
|
||||
);
|
||||
assert.ok(proxy);
|
||||
assert.equal(proxy.proxy_settings.proxy_type, "vless");
|
||||
assert.equal(proxy.proxy_settings.host, "vpn.example.com");
|
||||
assert.equal(proxy.proxy_settings.port, 443);
|
||||
assert.equal(proxy.proxy_settings.username, null);
|
||||
assert.equal(proxy.proxy_settings.password, null);
|
||||
assert.match(proxy.proxy_settings.vless_uri, /^vless:\/\//);
|
||||
|
||||
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||
});
|
||||
});
|
||||
|
||||
test("About exposes a searchable, responsive third-party license inventory", async () => {
|
||||
await withApp("ui-about-licenses", async (app) => {
|
||||
await app.clickSelector('[aria-label="More"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return Boolean(document.querySelector("[role='menu']"));`),
|
||||
{ description: "More menu" },
|
||||
);
|
||||
await app.clickText("About Donut Browser", {
|
||||
exact: false,
|
||||
roles: ["menuitem"],
|
||||
});
|
||||
await app.waitForText("Open-source anti-detect browser.");
|
||||
|
||||
const aboutText = await app.execute(
|
||||
`return document.querySelector("[role='dialog']")?.textContent ?? "";`,
|
||||
);
|
||||
assert.doesNotMatch(aboutText, /AGPL-3\.0|licensed under/i);
|
||||
|
||||
await app.clickText("Licenses", { roles: ["button"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "Licenses";`,
|
||||
),
|
||||
{ description: "Licenses view" },
|
||||
);
|
||||
|
||||
await app.session.command("POST", "/window/rect", {
|
||||
width: 640,
|
||||
height: 400,
|
||||
});
|
||||
const inventory = await app.execute(`
|
||||
const dialog = document.querySelector("[role='dialog']");
|
||||
const list = dialog?.querySelector(".scroll-fade");
|
||||
const search = dialog?.querySelector('input[type="search"]');
|
||||
const rows = [...(dialog?.querySelectorAll("li") ?? [])].map((row) =>
|
||||
[...row.children].map((child) => (child.textContent || "").trim())
|
||||
);
|
||||
const rect = dialog?.getBoundingClientRect();
|
||||
return {
|
||||
activeSearch: document.activeElement === search,
|
||||
rows,
|
||||
scrollable: Boolean(list && list.scrollHeight > list.clientHeight),
|
||||
bounds: rect
|
||||
? {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
viewportWidth: innerWidth,
|
||||
viewportHeight: innerHeight,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
`);
|
||||
assert.equal(inventory.activeSearch, true);
|
||||
assert.equal(inventory.scrollable, true);
|
||||
assert.ok(inventory.bounds);
|
||||
assert.ok(inventory.bounds.left >= 0);
|
||||
assert.ok(inventory.bounds.top >= 0);
|
||||
assert.ok(inventory.bounds.right <= inventory.bounds.viewportWidth);
|
||||
assert.ok(inventory.bounds.bottom <= inventory.bounds.viewportHeight);
|
||||
assert.ok(
|
||||
inventory.rows.some(
|
||||
([name, license]) => name === "Xray-core" && license === "MPL-2.0",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
inventory.rows.some(
|
||||
([name, license]) =>
|
||||
name === "Donut Browser" && license === "AGPL-3.0-only",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
inventory.rows.some(
|
||||
([name, license]) =>
|
||||
name === "tauri-plugin-opener" && license === "Apache-2.0 OR MIT",
|
||||
),
|
||||
);
|
||||
assert.ok(inventory.rows.every((row) => row.length === 2));
|
||||
|
||||
const search = await app.session.findCss('input[type="search"]');
|
||||
await app.session.sendKeys(search, "xray");
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelectorAll("[role='dialog'] li").length === 1;`,
|
||||
),
|
||||
{ description: "license search result" },
|
||||
);
|
||||
assert.match(
|
||||
await app.execute(
|
||||
`return document.querySelector("[role='dialog'] li")?.textContent ?? "";`,
|
||||
),
|
||||
/Xray-core.*MPL-2\.0/s,
|
||||
);
|
||||
|
||||
await app.clickSelector('button[aria-label="Back"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "About";`,
|
||||
),
|
||||
{ description: "About view after returning from licenses" },
|
||||
);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.activeElement?.textContent?.trim() === "Licenses";`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("settings tabs, command palette filtering, and responsive layout survive resize", async () => {
|
||||
await withApp("ui-settings-responsive", async (app) => {
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
@@ -503,3 +884,34 @@ test("preset and manually customized themes survive navigation and restart", asy
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a light custom preset keeps light component behavior after restart", async () => {
|
||||
await withApp("ui-theme-custom-light", async (app) => {
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
await chooseSelectOption(app, "#theme-select", "Custom");
|
||||
await chooseSelectOption(app, "#theme-preset-select", "Ayu Light");
|
||||
await saveSettings(app);
|
||||
|
||||
const selected = await waitForTheme(
|
||||
app,
|
||||
(snapshot) =>
|
||||
snapshot.mode === "light" &&
|
||||
themeVariablesEqual(snapshot.inline, AYU_LIGHT_THEME) &&
|
||||
themeVariablesEqual(snapshot.resolved, AYU_LIGHT_THEME),
|
||||
"Ayu Light variables and light mode to render",
|
||||
);
|
||||
await assertThemeAcrossNavigation(app, selected);
|
||||
|
||||
await app.restart();
|
||||
assert.deepEqual(
|
||||
(await app.invoke("get_app_settings")).custom_theme,
|
||||
AYU_LIGHT_THEME,
|
||||
);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(selected),
|
||||
{ description: "Ayu Light preset after restart" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user