mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 05:40:50 +02:00
refactor: harden tests
This commit is contained in:
+40
-15
@@ -1,7 +1,7 @@
|
||||
# Donut Browser native E2E tests
|
||||
|
||||
These tests exercise the actual Tauri application through the private
|
||||
`../tauri-cross-platform-webdriver/` driver. They do not replace Rust or React unit tests; they
|
||||
These tests exercise the actual Tauri application through a sibling native test driver. They do
|
||||
not replace Rust or React unit tests; they
|
||||
cover the process boundaries those tests cannot: WKWebView/WebView2/WebKitGTK UI, Tauri invokes,
|
||||
REST and MCP servers, two-device sync, S3 payload encryption, Wayfern, CDP, and child-process
|
||||
cleanup.
|
||||
@@ -13,14 +13,22 @@ Place both repositories beside each other:
|
||||
```text
|
||||
Code/
|
||||
├── donutbrowser/
|
||||
└── tauri-cross-platform-webdriver/
|
||||
└── <test-driver-checkout>/
|
||||
```
|
||||
|
||||
Install Donut dependencies with `pnpm install`. The browser suite also needs
|
||||
`WAYFERN_TEST_TOKEN`. The runner reads it from the environment, Donut's `.env`, or
|
||||
`../wayfern-test/.env` without printing it. If `../wayfern-test/test_extracted_app` contains a
|
||||
Wayfern build, the runner links/copies it into the test data root; otherwise the browser suite
|
||||
downloads the current published build into that root.
|
||||
`WAYFERN_TEST_TOKEN`. The runner reads it from the environment or Donut's ignored `.env` without
|
||||
printing it. When a local browser fixture is configured, the runner copies it into the test data
|
||||
root (using an isolated APFS clone on macOS); otherwise the browser suite downloads the current
|
||||
published build into that root.
|
||||
|
||||
Set `DONUT_E2E_WAYFERN_PATH` to use a local browser fixture. Without it, the runner uses an ignored
|
||||
cache fixture when present and otherwise downloads the published test build.
|
||||
|
||||
The real-network suite additionally requires Docker plus
|
||||
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`. It creates its own
|
||||
WireGuard server and tunnel-only HTTP target in a disposable container. It never connects a test
|
||||
profile to a developer or production VPN.
|
||||
|
||||
Run one suite:
|
||||
|
||||
@@ -28,15 +36,31 @@ Run one suite:
|
||||
pnpm e2e:smoke
|
||||
pnpm e2e:ui
|
||||
pnpm e2e:entities
|
||||
pnpm e2e:network
|
||||
pnpm e2e:integrations
|
||||
pnpm e2e:sync
|
||||
pnpm e2e:browser
|
||||
```
|
||||
|
||||
Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, the
|
||||
`e2e`-feature app, and `tauri-wd`. Add `--no-build` to `node e2e/run.mjs --suite=<name>` only when
|
||||
all four outputs are current. `DONUT_E2E_KEEP_ARTIFACTS=1` retains successful runs; failed runs are
|
||||
always retained and their location is printed.
|
||||
private harness in `e2e/app`, and `tauri-wd`. The harness enables Donut's `e2e` feature and injects
|
||||
the sibling WebDriver plugin without making the production crate depend on a private filesystem
|
||||
path. Add `--no-build` to `node e2e/run.mjs --suite=<name>` only when all four outputs are current.
|
||||
`DONUT_E2E_KEEP_ARTIFACTS=1` retains successful local runs; failed runs are always retained and
|
||||
their location is printed. Raw screenshots, captured HTML, logs, and isolated app state stay local.
|
||||
The runner also creates a text-only `diagnostics/` directory whose logs are redacted and checked
|
||||
against active test secrets. CI uploads only that directory on failure. Disposable copied browser
|
||||
binaries are pruned so repeated failures do not consume gigabytes.
|
||||
|
||||
The suites deliberately distinguish visible behavior from command coverage. `e2e:entities`
|
||||
exercises isolated CRUD and persistence through Tauri commands. `e2e:network` visibly creates a
|
||||
profile group, HTTP proxy, WireGuard VPN, extension, extension group, and Wayfern profile; assigns
|
||||
the proxy and VPN in the profile table; validates both residential HTTP and SOCKS5 proxies; then
|
||||
launches Wayfern through the residential proxy and through the local WireGuard tunnel. Normal test
|
||||
sessions start with onboarding completed so the Welcome dialog cannot hide the feature under test.
|
||||
The onboarding and Wayfern-terms scenarios explicitly opt into fresh state and test those dialogs.
|
||||
`e2e:ui` selects predefined, preset, and manually customized themes through the native UI and
|
||||
asserts their persisted settings and rendered CSS variables across rail navigation and app restart.
|
||||
|
||||
## Isolation contract
|
||||
|
||||
@@ -60,9 +84,10 @@ the WebDriver plugin or this fallback.
|
||||
|
||||
`.github/workflows/app-e2e.yml` runs smoke tests on macOS, Linux/Xvfb, and Windows for pull
|
||||
requests. Pushes to `main`, weekly schedules, and manual runs execute the full macOS suite,
|
||||
including MinIO-backed sync and real Wayfern automation.
|
||||
including MinIO-backed sync and real Wayfern automation, plus a Linux/Docker job for residential
|
||||
proxy and local WireGuard browser traffic.
|
||||
|
||||
Because the WebDriver repository is private, CI needs a `TAURI_WEBDRIVER_TOKEN` secret with
|
||||
read-only access. `TAURI_WEBDRIVER_REPOSITORY` may override its default
|
||||
`zhom/tauri-cross-platform-webdriver` repository name. The full job also requires the
|
||||
`WAYFERN_TEST_TOKEN` secret.
|
||||
CI needs a `TAURI_WEBDRIVER_TOKEN` secret with read-only access and a
|
||||
`TAURI_WEBDRIVER_REPOSITORY` repository variable identifying the test-driver checkout. The full
|
||||
job also requires the `WAYFERN_TEST_TOKEN` secret. The network job requires that secret plus
|
||||
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`.
|
||||
|
||||
Generated
+9073
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "donutbrowser-e2e"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
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" }
|
||||
|
||||
[patch.crates-io]
|
||||
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
|
||||
@@ -0,0 +1,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
donutbrowser_lib::run_with_builder(|builder| {
|
||||
builder.plugin(tauri_plugin_cross_platform_webdriver::init())
|
||||
});
|
||||
}
|
||||
+107
@@ -47,6 +47,8 @@ export class AppSession {
|
||||
extraEnv = {},
|
||||
args = [],
|
||||
seedVersionCache = true,
|
||||
onboardingCompleted = true,
|
||||
wayfernTermsAccepted = true,
|
||||
}) {
|
||||
this.name = name;
|
||||
this.root = root;
|
||||
@@ -57,6 +59,8 @@ export class AppSession {
|
||||
this.extraEnv = extraEnv;
|
||||
this.args = args;
|
||||
this.seedVersionCache = seedVersionCache;
|
||||
this.onboardingCompleted = onboardingCompleted;
|
||||
this.wayfernTermsAccepted = wayfernTermsAccepted;
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
@@ -70,6 +74,69 @@ export class AppSession {
|
||||
mkdir(path.join(this.root, "tmp"), { recursive: true }),
|
||||
mkdir(path.join(this.root, "artifacts"), { recursive: true }),
|
||||
]);
|
||||
if (this.onboardingCompleted) {
|
||||
const settingsFile = path.join(
|
||||
this.dataRoot,
|
||||
"data",
|
||||
"settings",
|
||||
"app_settings.json",
|
||||
);
|
||||
await mkdir(path.dirname(settingsFile), { recursive: true });
|
||||
await writeFile(
|
||||
settingsFile,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
language: "en",
|
||||
onboarding_completed: true,
|
||||
commercial_trial_acknowledged: true,
|
||||
window_resize_warning_dismissed: true,
|
||||
disable_auto_updates: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ flag: "wx" },
|
||||
).catch((error) => {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.wayfernTermsAccepted) {
|
||||
const termsFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(
|
||||
this.root,
|
||||
"home",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: process.platform === "win32"
|
||||
? path.join(
|
||||
this.root,
|
||||
"windows",
|
||||
"roaming",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: path.join(
|
||||
this.root,
|
||||
"xdg",
|
||||
"config",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
await mkdir(path.dirname(termsFile), { recursive: true });
|
||||
await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, {
|
||||
flag: "wx",
|
||||
}).catch((error) => {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.seedVersionCache) {
|
||||
const versionCache = path.join(
|
||||
this.root,
|
||||
@@ -257,6 +324,44 @@ export class AppSession {
|
||||
await this.session.click(element);
|
||||
}
|
||||
|
||||
async clickTextIn(
|
||||
containerSelector,
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const containers = [...document.querySelectorAll(arguments[0])];
|
||||
const wanted = arguments[1];
|
||||
const exact = arguments[2];
|
||||
const roles = new Set(arguments[3]);
|
||||
const visible = (node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
for (const container of containers.reverse()) {
|
||||
if (!visible(container)) continue;
|
||||
const candidates = [...container.querySelectorAll("button, a, [role], [data-slot='button']")];
|
||||
const match = candidates.find((node) => {
|
||||
const role = node.getAttribute("role") || (node.tagName === "A" ? "link" : "button");
|
||||
const label = (node.getAttribute("aria-label") || node.innerText || node.textContent || "").trim();
|
||||
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
|
||||
});
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
`,
|
||||
[containerSelector, text, exact, roles],
|
||||
);
|
||||
assert.ok(
|
||||
element,
|
||||
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
await this.session.click(element);
|
||||
}
|
||||
|
||||
async clickSelector(selector) {
|
||||
const element = await this.waitFor(
|
||||
() =>
|
||||
@@ -366,6 +471,8 @@ export function appFromEnvironment(name, options = {}) {
|
||||
extraEnv: options.extraEnv,
|
||||
args: options.args,
|
||||
seedVersionCache: options.seedVersionCache,
|
||||
onboardingCompleted: options.onboardingCompleted,
|
||||
wayfernTermsAccepted: options.wayfernTermsAccepted,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
redactSensitiveText,
|
||||
sensitiveVariants,
|
||||
} from "../../scripts/redact-sensitive-text.mjs";
|
||||
|
||||
const MAX_LOG_BYTES = 512 * 1024;
|
||||
|
||||
async function logFiles(directory, fileNamePattern = /\.(?:log|txt)$/iu) {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(
|
||||
() => [],
|
||||
);
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && fileNamePattern.test(entry.name))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function diagnosticSources(runRoot) {
|
||||
const sources = await logFiles(path.join(runRoot, "logs"));
|
||||
const sessions = await readdir(path.join(runRoot, "sessions"), {
|
||||
withFileTypes: true,
|
||||
}).catch(() => []);
|
||||
for (const session of sessions.filter((entry) => entry.isDirectory())) {
|
||||
const root = path.join(runRoot, "sessions", session.name);
|
||||
sources.push(...(await logFiles(path.join(root, "donut", "logs"))));
|
||||
sources.push(
|
||||
...(await logFiles(path.join(root, "tmp"), /^donut-proxy-.*\.log$/iu)),
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export async function assertSafeDiagnostics(
|
||||
diagnosticsRoot,
|
||||
sensitiveValues = [],
|
||||
) {
|
||||
const entries = await readdir(diagnosticsRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/\.(?:json|log)$/iu.test(entry.name)) {
|
||||
throw new Error(`Unsafe diagnostics entry: ${entry.name}`);
|
||||
}
|
||||
const content = await readFile(
|
||||
path.join(diagnosticsRoot, entry.name),
|
||||
"utf8",
|
||||
);
|
||||
for (const value of sensitiveVariants(sensitiveValues)) {
|
||||
if (content.includes(value)) {
|
||||
throw new Error(
|
||||
`Sensitive value survived diagnostics redaction in ${entry.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSafeDiagnostics(
|
||||
runRoot,
|
||||
{ suite, failed, sensitiveValues = [] },
|
||||
) {
|
||||
const diagnosticsRoot = path.join(runRoot, "diagnostics");
|
||||
await mkdir(diagnosticsRoot, { recursive: true, mode: 0o700 });
|
||||
await chmod(diagnosticsRoot, 0o700);
|
||||
|
||||
const sources = await diagnosticSources(runRoot);
|
||||
for (const [index, source] of sources.entries()) {
|
||||
const content = await readFile(source, "utf8").catch(() => "");
|
||||
const tail = content.slice(-MAX_LOG_BYTES);
|
||||
const destination = path.join(
|
||||
diagnosticsRoot,
|
||||
`${String(index + 1).padStart(3, "0")}.log`,
|
||||
);
|
||||
await writeFile(
|
||||
destination,
|
||||
redactSensitiveText(tail, { sensitiveValues }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await chmod(destination, 0o600);
|
||||
}
|
||||
|
||||
const summaryPath = path.join(diagnosticsRoot, "summary.json");
|
||||
await writeFile(
|
||||
summaryPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
suite,
|
||||
status: failed ? "failed" : "passed",
|
||||
sanitized_log_files: sources.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await chmod(summaryPath, 0o600);
|
||||
await assertSafeDiagnostics(diagnosticsRoot, sensitiveValues);
|
||||
return diagnosticsRoot;
|
||||
}
|
||||
+44
-23
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, copyFile, cp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -11,17 +11,13 @@ export function defaultWayfernPath(projectRoot) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) {
|
||||
return path.resolve(process.env.DONUT_E2E_WAYFERN_PATH);
|
||||
}
|
||||
const sibling = path.resolve(
|
||||
projectRoot,
|
||||
"../wayfern-test/test_extracted_app",
|
||||
);
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(sibling, "Wayfern.app");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(sibling, "Wayfern.exe");
|
||||
}
|
||||
return path.join(sibling, "wayfern");
|
||||
const fixtureRoot = path.join(projectRoot, ".cache", "e2e-wayfern-fixture");
|
||||
return process.platform === "darwin"
|
||||
? path.join(fixtureRoot, "Wayfern.app")
|
||||
: path.join(
|
||||
fixtureRoot,
|
||||
process.platform === "win32" ? "Wayfern.exe" : "wayfern",
|
||||
);
|
||||
}
|
||||
|
||||
export function wayfernExecutable(bundlePath) {
|
||||
@@ -60,18 +56,16 @@ export function inspectWayfern(bundlePath) {
|
||||
return { bundlePath, executable, version: match[1], output };
|
||||
}
|
||||
|
||||
async function linkOrCopy(source, destination) {
|
||||
async function cloneAppBundle(source, destination) {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
await symlink(
|
||||
source,
|
||||
destination,
|
||||
process.platform === "win32" ? "junction" : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
execFileSync("/bin/cp", ["-cR", source, destination]);
|
||||
} catch (_error) {
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
errorOnExist: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +79,10 @@ export async function seedWayfern(dataRoot, wayfern) {
|
||||
);
|
||||
await mkdir(installDir, { recursive: true });
|
||||
if (process.platform === "darwin") {
|
||||
await linkOrCopy(wayfern.bundlePath, path.join(installDir, "Wayfern.app"));
|
||||
await cloneAppBundle(
|
||||
wayfern.bundlePath,
|
||||
path.join(installDir, "Wayfern.app"),
|
||||
);
|
||||
} else {
|
||||
const name = process.platform === "win32" ? "wayfern.exe" : "wayfern";
|
||||
const destination = path.join(installDir, name);
|
||||
@@ -116,6 +113,30 @@ export async function seedWayfern(dataRoot, wayfern) {
|
||||
return installDir;
|
||||
}
|
||||
|
||||
export async function prepareWayfern(app, projectRoot) {
|
||||
const localBundle = defaultWayfernPath(projectRoot);
|
||||
if (existsSync(localBundle)) {
|
||||
const wayfern = inspectWayfern(localBundle);
|
||||
await seedWayfern(app.dataRoot, wayfern);
|
||||
return { version: wayfern.version, source: "local fixture" };
|
||||
}
|
||||
|
||||
if (!app.session) 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" };
|
||||
}
|
||||
|
||||
export function wireGuardFixture() {
|
||||
return [
|
||||
"[Interface]",
|
||||
|
||||
+223
-29
@@ -7,7 +7,15 @@ import {
|
||||
existsSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rename, rm } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import net from "node:net";
|
||||
@@ -15,6 +23,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(dirname, "..");
|
||||
@@ -26,10 +35,11 @@ const isWindows = process.platform === "win32";
|
||||
const executableSuffix = isWindows ? ".exe" : "";
|
||||
const appBinary = path.join(
|
||||
projectRoot,
|
||||
"src-tauri",
|
||||
"e2e",
|
||||
"app",
|
||||
"target",
|
||||
"debug",
|
||||
`donutbrowser${executableSuffix}`,
|
||||
`donutbrowser-e2e${executableSuffix}`,
|
||||
);
|
||||
const driverBinary = path.join(
|
||||
webdriverRoot,
|
||||
@@ -39,17 +49,20 @@ const driverBinary = path.join(
|
||||
);
|
||||
|
||||
const suiteFiles = {
|
||||
smoke: ["smoke.test.mjs", "coverage.test.mjs"],
|
||||
smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"],
|
||||
ui: ["ui.test.mjs"],
|
||||
entities: ["entities.test.mjs"],
|
||||
network: ["network.test.mjs"],
|
||||
integrations: ["integrations.test.mjs"],
|
||||
sync: ["sync.test.mjs"],
|
||||
browser: ["browser.test.mjs"],
|
||||
full: [
|
||||
"diagnostics.test.mjs",
|
||||
"coverage.test.mjs",
|
||||
"smoke.test.mjs",
|
||||
"ui.test.mjs",
|
||||
"entities.test.mjs",
|
||||
"network.test.mjs",
|
||||
"integrations.test.mjs",
|
||||
"sync.test.mjs",
|
||||
"browser.test.mjs",
|
||||
@@ -189,28 +202,42 @@ async function stopProcess(record) {
|
||||
record.stream.end();
|
||||
}
|
||||
|
||||
async function loadLocalToken() {
|
||||
if (process.env.WAYFERN_TEST_TOKEN) {
|
||||
return process.env.WAYFERN_TEST_TOKEN;
|
||||
function unquoteEnvValue(value) {
|
||||
const trimmed = value.trim();
|
||||
const quote = trimmed[0];
|
||||
if ((quote === '"' || quote === "'") && trimmed.at(-1) === quote) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
for (const file of [
|
||||
path.join(projectRoot, ".env"),
|
||||
path.resolve(projectRoot, "../wayfern-test/.env"),
|
||||
]) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function loadLocalValues(names) {
|
||||
const values = Object.fromEntries(
|
||||
names
|
||||
.filter((name) => process.env[name])
|
||||
.map((name) => [name, process.env[name]]),
|
||||
);
|
||||
for (const file of [path.join(projectRoot, ".env")]) {
|
||||
try {
|
||||
const content = await readFile(file, "utf8");
|
||||
const match = content.match(
|
||||
/^\s*(?:export\s+)?WAYFERN_TEST_TOKEN\s*=\s*(.+?)\s*$/m,
|
||||
);
|
||||
if (match) {
|
||||
const raw = match[1].trim();
|
||||
return raw.replace(/^(['"])(.*)\1$/, "$2");
|
||||
for (const name of names) {
|
||||
if (values[name]) continue;
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = content.match(
|
||||
new RegExp(
|
||||
`^\\s*(?:export\\s+)?${escapedName}\\s*=\\s*(.+?)\\s*$`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
if (match) {
|
||||
values[name] = unquoteEnvValue(match[1]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A local token is only mandatory for the browser suite.
|
||||
// Individual suites validate the secrets they require.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildAll() {
|
||||
@@ -221,8 +248,8 @@ function buildAll() {
|
||||
run("pnpm", ["copy-proxy-binary"], projectRoot);
|
||||
run(
|
||||
"cargo",
|
||||
["build", "--features", "e2e", "--bin", "donutbrowser"],
|
||||
path.join(projectRoot, "src-tauri"),
|
||||
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
|
||||
projectRoot,
|
||||
);
|
||||
run(
|
||||
"cargo",
|
||||
@@ -474,21 +501,159 @@ async function startSyncInfrastructure(runRoot, options, records) {
|
||||
};
|
||||
}
|
||||
|
||||
function runDocker(args, { allowFailure = false } = {}) {
|
||||
const result = spawnSync("docker", args, {
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
if (!allowFailure && (result.error || result.status !== 0)) {
|
||||
throw new Error(
|
||||
`docker ${args[0]} failed: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status}`}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dockerAvailable() {
|
||||
const result = runDocker(["version"], { allowFailure: true });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
async function startWireGuardInfrastructure() {
|
||||
if (!dockerAvailable()) {
|
||||
throw new Error(
|
||||
"The network E2E suite requires a running Docker daemon for its local WireGuard peer",
|
||||
);
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
const name = `donut-wg-e2e-${process.pid}-${Date.now()}`;
|
||||
const image =
|
||||
process.env.DONUT_E2E_WIREGUARD_IMAGE ??
|
||||
"lscr.io/linuxserver/wireguard:latest";
|
||||
log("Starting isolated local WireGuard peer");
|
||||
runDocker([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--cap-add=NET_ADMIN",
|
||||
"-p",
|
||||
`${port}:51820/udp`,
|
||||
"-e",
|
||||
"PEERS=1",
|
||||
"-e",
|
||||
"SERVERURL=127.0.0.1",
|
||||
"-e",
|
||||
"SERVERPORT=51820",
|
||||
"-e",
|
||||
"PEERDNS=auto",
|
||||
"-e",
|
||||
"INTERNAL_SUBNET=10.64.0.0",
|
||||
image,
|
||||
]);
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + 45_000;
|
||||
let config = "";
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
const configResult = runDocker(
|
||||
["exec", name, "cat", "/config/peer1/peer1.conf"],
|
||||
{ allowFailure: true },
|
||||
);
|
||||
const statusResult = runDocker(["exec", name, "wg", "show"], {
|
||||
allowFailure: true,
|
||||
});
|
||||
if (
|
||||
configResult.status === 0 &&
|
||||
statusResult.status === 0 &&
|
||||
statusResult.stdout.includes("listening port")
|
||||
) {
|
||||
config = configResult.stdout;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!config) {
|
||||
throw new Error("Local WireGuard peer did not become ready within 45s");
|
||||
}
|
||||
|
||||
const server = runDocker([
|
||||
"exec",
|
||||
"-d",
|
||||
name,
|
||||
"sh",
|
||||
"-c",
|
||||
'while true; do printf "HTTP/1.1 200 OK\\r\\nContent-Length: 13\\r\\nConnection: close\\r\\n\\r\\nWG-TUNNEL-OK\\n" | nc -l -p 8080 >> /tmp/donut-e2e-target-requests 2>/dev/null; done',
|
||||
]);
|
||||
if (server.status !== 0) {
|
||||
throw new Error("Failed to start the WireGuard tunnel target server");
|
||||
}
|
||||
config = config.replace(
|
||||
/^Endpoint\s*=.*$/m,
|
||||
`Endpoint = 127.0.0.1:${port}`,
|
||||
);
|
||||
return {
|
||||
name,
|
||||
config,
|
||||
targetUrl: "http://10.64.0.1:8080/donut-e2e-wireguard",
|
||||
};
|
||||
} catch (error) {
|
||||
runDocker(["rm", "-f", name], { allowFailure: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRetainedArtifacts(
|
||||
runRoot,
|
||||
{ suite, failed, sensitiveValues },
|
||||
) {
|
||||
const sessionsRoot = path.join(runRoot, "sessions");
|
||||
const sessions = await readdir(sessionsRoot, {
|
||||
withFileTypes: true,
|
||||
}).catch(() => []);
|
||||
await Promise.all(
|
||||
sessions
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) =>
|
||||
rm(path.join(sessionsRoot, entry.name, "donut", "data", "binaries"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await createSafeDiagnostics(runRoot, { suite, failed, sensitiveValues });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const runRoot = await mkdtemp(path.join(os.tmpdir(), "donut-e2e-"));
|
||||
await mkdir(path.join(runRoot, "logs"), { recursive: true });
|
||||
const records = [];
|
||||
let fixture;
|
||||
let wireGuard;
|
||||
let failed = false;
|
||||
const sensitiveValues = [
|
||||
"donut-e2e-sync-token-0123456789abcdef",
|
||||
"minioadmin",
|
||||
];
|
||||
const cleanup = async () => {
|
||||
await Promise.all(records.reverse().map(stopProcess));
|
||||
if (fixture) {
|
||||
await new Promise((resolve) => fixture.server.close(resolve));
|
||||
}
|
||||
if (wireGuard) {
|
||||
runDocker(["rm", "-f", wireGuard.name], { allowFailure: true });
|
||||
}
|
||||
if (!options.keep && !failed) {
|
||||
await rm(runRoot, { recursive: true, force: true });
|
||||
} else {
|
||||
await prepareRetainedArtifacts(runRoot, {
|
||||
suite: options.suite,
|
||||
failed,
|
||||
sensitiveValues,
|
||||
});
|
||||
log(`Artifacts retained at ${runRoot}`);
|
||||
}
|
||||
};
|
||||
@@ -536,22 +701,42 @@ async function main() {
|
||||
await waitForUrl(`http://127.0.0.1:${driverPort}/status`, 15_000, driver);
|
||||
|
||||
const needsBrowser =
|
||||
options.suite === "browser" || options.suite === "full";
|
||||
options.suite === "browser" ||
|
||||
options.suite === "network" ||
|
||||
options.suite === "full";
|
||||
const networkEnabled =
|
||||
(options.suite === "network" || options.suite === "full") &&
|
||||
process.env.DONUT_E2E_SKIP_NETWORK_TEST !== "1";
|
||||
const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null;
|
||||
fixture = await startFixtureServer(geoIpFixture);
|
||||
let sync = {};
|
||||
if (options.suite === "sync" || options.suite === "full") {
|
||||
sync = await startSyncInfrastructure(runRoot, options, records);
|
||||
}
|
||||
|
||||
const token = await loadLocalToken();
|
||||
if ((options.suite === "browser" || options.suite === "full") && !token) {
|
||||
throw new Error("WAYFERN_TEST_TOKEN is required by the browser suite");
|
||||
if (networkEnabled && process.env.DONUT_E2E_SKIP_VPN_TUNNEL !== "1") {
|
||||
wireGuard = await startWireGuardInfrastructure();
|
||||
}
|
||||
|
||||
const files = suiteFiles[options.suite].map((file) =>
|
||||
path.join(dirname, "tests", file),
|
||||
);
|
||||
const localValues = await loadLocalValues([
|
||||
"WAYFERN_TEST_TOKEN",
|
||||
"RESIDENTIAL_PROXY_URL_ONE_SOCKS",
|
||||
"RESIDENTIAL_PROXY_URL_ONE_HTTP",
|
||||
]);
|
||||
sensitiveValues.push(...Object.values(localValues));
|
||||
const token = localValues.WAYFERN_TEST_TOKEN ?? "";
|
||||
if (needsBrowser && !token) {
|
||||
throw new Error("WAYFERN_TEST_TOKEN is required by the browser suite");
|
||||
}
|
||||
if (wireGuard) {
|
||||
sensitiveValues.push(
|
||||
wireGuard.config,
|
||||
Buffer.from(wireGuard.config).toString("base64"),
|
||||
);
|
||||
}
|
||||
|
||||
const files = suiteFiles[options.suite]
|
||||
.filter((file) => networkEnabled || file !== "network.test.mjs")
|
||||
.map((file) => path.join(dirname, "tests", file));
|
||||
const testArgs = [
|
||||
"--test",
|
||||
"--test-concurrency=1",
|
||||
@@ -570,9 +755,18 @@ async function main() {
|
||||
DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`,
|
||||
DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0",
|
||||
WAYFERN_TEST_TOKEN: token,
|
||||
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
|
||||
localValues.RESIDENTIAL_PROXY_URL_ONE_SOCKS ?? "",
|
||||
RESIDENTIAL_PROXY_URL_ONE_HTTP:
|
||||
localValues.RESIDENTIAL_PROXY_URL_ONE_HTTP ?? "",
|
||||
DONUT_E2E_SYNC_URL: sync.syncUrl ?? "",
|
||||
DONUT_E2E_SYNC_TOKEN: sync.syncToken ?? "",
|
||||
DONUT_E2E_MINIO_URL: sync.minioUrl ?? "",
|
||||
DONUT_E2E_WIREGUARD_CONFIG_BASE64: wireGuard
|
||||
? Buffer.from(wireGuard.config).toString("base64")
|
||||
: "",
|
||||
DONUT_E2E_WIREGUARD_TARGET_URL: wireGuard?.targetUrl ?? "",
|
||||
DONUT_E2E_WIREGUARD_CONTAINER: wireGuard?.name ?? "",
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
@@ -7,11 +7,7 @@ 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";
|
||||
import { defaultWayfernPath, prepareWayfern } from "../lib/fixtures.mjs";
|
||||
|
||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
|
||||
@@ -36,30 +32,6 @@ async function request(url, { method = "GET", token, body } = {}) {
|
||||
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 {
|
||||
@@ -166,11 +138,15 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
);
|
||||
const app = appFromEnvironment("browser-wayfern", {
|
||||
seedVersionCache: hasLocalWayfern,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let cdp;
|
||||
let browserPid;
|
||||
try {
|
||||
const prepared = await prepareWayfern(app);
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
|
||||
assert.equal(await app.invoke("check_wayfern_downloaded"), true);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { allCoveredCommands, commandCoverage } from "../coverage-map.mjs";
|
||||
import { seedWayfern } from "../lib/fixtures.mjs";
|
||||
import { WebDriverClient } from "../lib/webdriver.mjs";
|
||||
|
||||
function registeredCommands(source) {
|
||||
@@ -58,10 +67,15 @@ test("every Tauri command has exactly one E2E owner and evidence level", async (
|
||||
continue;
|
||||
}
|
||||
|
||||
const suiteSource = await readFile(
|
||||
const evidenceFiles = [
|
||||
path.join(root, "e2e", "tests", `${entry.suite}.test.mjs`),
|
||||
"utf8",
|
||||
);
|
||||
...(entry.suite === "browser"
|
||||
? [path.join(root, "e2e", "lib", "fixtures.mjs")]
|
||||
: []),
|
||||
];
|
||||
const suiteSource = (
|
||||
await Promise.all(evidenceFiles.map((file) => readFile(file, "utf8")))
|
||||
).join("\n");
|
||||
for (const command of entry.commands) {
|
||||
assert.equal(
|
||||
commandHasExecutableEvidence(suiteSource, command),
|
||||
@@ -94,3 +108,38 @@ test("WebDriver client preserves application values that contain an error field"
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("Wayfern fixtures are copied into the isolated data root, never linked", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "donut-wayfern-copy-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const source =
|
||||
process.platform === "darwin"
|
||||
? path.join(root, "source", "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||
: path.join(
|
||||
root,
|
||||
"source",
|
||||
process.platform === "win32" ? "Wayfern.exe" : "wayfern",
|
||||
);
|
||||
await mkdir(path.dirname(source), { recursive: true });
|
||||
await writeFile(source, "source-fixture");
|
||||
const bundlePath =
|
||||
process.platform === "darwin"
|
||||
? path.join(root, "source", "Wayfern.app")
|
||||
: source;
|
||||
const installDir = await seedWayfern(path.join(root, "isolated"), {
|
||||
bundlePath,
|
||||
executable: source,
|
||||
version: "1.2.3.4",
|
||||
});
|
||||
const destination =
|
||||
process.platform === "darwin"
|
||||
? path.join(installDir, "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||
: path.join(
|
||||
installDir,
|
||||
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
||||
);
|
||||
|
||||
assert.equal((await lstat(destination)).isSymbolicLink(), false);
|
||||
await writeFile(destination, "isolated-mutation");
|
||||
assert.equal(await readFile(source, "utf8"), "source-fixture");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { redactIssueBody } from "../../scripts/redact-sensitive-text.mjs";
|
||||
import { createSafeDiagnostics } from "../lib/diagnostics.mjs";
|
||||
|
||||
const roots = [];
|
||||
after(async () => {
|
||||
await Promise.all(
|
||||
roots.map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("shared E2E diagnostics contain only redacted text logs", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "donut-diagnostics-test-"));
|
||||
roots.push(root);
|
||||
const secretUrl = "http://real-user:real-password@proxy.example:8080";
|
||||
const token = ["github", "pat", "example", "token", "0123456789"].join("_");
|
||||
const logText = [
|
||||
`proxy=${secretUrl}`,
|
||||
`Authorization: Bearer ${token}`,
|
||||
"visited https://example.com/callback?code=private-code",
|
||||
"exit IP 203.0.113.42",
|
||||
"home /Users/private-person/Library/Application Support",
|
||||
"email private.person@example.com",
|
||||
"PrivateKey = wireguard-private-key",
|
||||
].join("\n");
|
||||
|
||||
await Promise.all([
|
||||
mkdir(path.join(root, "logs"), { recursive: true }),
|
||||
mkdir(path.join(root, "sessions", "network", "donut", "logs"), {
|
||||
recursive: true,
|
||||
}),
|
||||
mkdir(path.join(root, "sessions", "network", "donut", "data", "proxies"), {
|
||||
recursive: true,
|
||||
}),
|
||||
mkdir(path.join(root, "sessions", "network", "artifacts"), {
|
||||
recursive: true,
|
||||
}),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(path.join(root, "logs", "driver.log"), logText),
|
||||
writeFile(
|
||||
path.join(root, "sessions", "network", "donut", "logs", "app.log"),
|
||||
logText,
|
||||
),
|
||||
writeFile(
|
||||
path.join(
|
||||
root,
|
||||
"sessions",
|
||||
"network",
|
||||
"donut",
|
||||
"data",
|
||||
"proxies",
|
||||
"real.json",
|
||||
),
|
||||
JSON.stringify({ upstream_url: secretUrl, token }),
|
||||
),
|
||||
writeFile(
|
||||
path.join(root, "sessions", "network", "artifacts", "page.html"),
|
||||
`<html>${secretUrl}</html>`,
|
||||
),
|
||||
]);
|
||||
|
||||
const diagnostics = await createSafeDiagnostics(root, {
|
||||
suite: "network",
|
||||
failed: true,
|
||||
sensitiveValues: [secretUrl, token],
|
||||
});
|
||||
const files = await readdir(diagnostics);
|
||||
assert.deepEqual(files.sort(), ["001.log", "002.log", "summary.json"]);
|
||||
const combined = (
|
||||
await Promise.all(
|
||||
files.map((file) => readFile(path.join(diagnostics, file), "utf8")),
|
||||
)
|
||||
).join("\n");
|
||||
for (const value of [
|
||||
secretUrl,
|
||||
"real-user",
|
||||
"real-password",
|
||||
"proxy.example",
|
||||
token,
|
||||
"private-code",
|
||||
"203.0.113.42",
|
||||
"private-person",
|
||||
"private.person@example.com",
|
||||
"wireguard-private-key",
|
||||
]) {
|
||||
assert.ok(!combined.includes(value), `diagnostics leaked ${value}`);
|
||||
}
|
||||
assert.ok(
|
||||
!files.some(
|
||||
(file) => /\.(?:html|json)$/u.test(file) && file !== "summary.json",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("automated issue processing omits the complete log field", () => {
|
||||
const safe = redactIssueBody(
|
||||
`### What happened?\nA failure at user@example.com\n\n### Error logs or screenshots\nARBITRARY_PRIVATE_LOG_CONTENT\npassword=hunter2\n\n### Operating System\nLinux`,
|
||||
);
|
||||
assert.ok(!safe.includes("ARBITRARY_PRIVATE_LOG_CONTENT"));
|
||||
assert.ok(!safe.includes("hunter2"));
|
||||
assert.ok(!safe.includes("user@example.com"));
|
||||
assert.match(safe, /omitted from automated processing/u);
|
||||
assert.match(safe, /Operating System\nLinux/u);
|
||||
});
|
||||
@@ -25,7 +25,6 @@ async function createProfile(app, name = "Entity Profile") {
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
+60
-53
@@ -5,59 +5,66 @@ 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");
|
||||
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 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");
|
||||
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.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);
|
||||
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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
{ onboardingCompleted: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("two isolated sessions run concurrently and do not share frontend or backend state", async () => {
|
||||
@@ -89,15 +96,15 @@ test("two isolated sessions run concurrently and do not share frontend or backen
|
||||
|
||||
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(
|
||||
async () => {
|
||||
await app.pressShortcut({ key: "k", ...modifier });
|
||||
return app.execute(
|
||||
`return Boolean(document.querySelector("[cmdk-input][placeholder='Type a command or search...']"));`,
|
||||
),
|
||||
);
|
||||
},
|
||||
{ description: "open command palette" },
|
||||
);
|
||||
|
||||
|
||||
+336
-4
@@ -2,15 +2,236 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
|
||||
const THEME_VARIABLES = [
|
||||
"--background",
|
||||
"--foreground",
|
||||
"--card",
|
||||
"--card-foreground",
|
||||
"--popover",
|
||||
"--popover-foreground",
|
||||
"--primary",
|
||||
"--primary-foreground",
|
||||
"--secondary",
|
||||
"--secondary-foreground",
|
||||
"--muted",
|
||||
"--muted-foreground",
|
||||
"--accent",
|
||||
"--accent-foreground",
|
||||
"--destructive",
|
||||
"--destructive-foreground",
|
||||
"--success",
|
||||
"--success-foreground",
|
||||
"--warning",
|
||||
"--warning-foreground",
|
||||
"--border",
|
||||
"--chart-1",
|
||||
"--chart-2",
|
||||
"--chart-3",
|
||||
"--chart-4",
|
||||
"--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",
|
||||
};
|
||||
|
||||
async function dismissSurface(app) {
|
||||
await app.pressShortcut({ key: "Escape" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
async function themeSnapshot(app) {
|
||||
return app.execute(
|
||||
`
|
||||
const root = document.documentElement;
|
||||
const rootStyle = getComputedStyle(root);
|
||||
const bodyStyle = getComputedStyle(document.body);
|
||||
const variables = arguments[0];
|
||||
return {
|
||||
mode: root.classList.contains("light")
|
||||
? "light"
|
||||
: root.classList.contains("dark")
|
||||
? "dark"
|
||||
: "unset",
|
||||
inline: Object.fromEntries(
|
||||
variables.map((key) => [key, root.style.getPropertyValue(key).trim()])
|
||||
),
|
||||
resolved: Object.fromEntries(
|
||||
variables.map((key) => [key, rootStyle.getPropertyValue(key).trim()])
|
||||
),
|
||||
bodyBackground: bodyStyle.backgroundColor,
|
||||
bodyForeground: bodyStyle.color,
|
||||
};
|
||||
`,
|
||||
[THEME_VARIABLES],
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTheme(app, predicate, description) {
|
||||
return app.waitFor(
|
||||
async () => {
|
||||
const snapshot = await themeSnapshot(app);
|
||||
return predicate(snapshot) ? snapshot : false;
|
||||
},
|
||||
{ description },
|
||||
);
|
||||
}
|
||||
|
||||
function themeVariablesEqual(actual, expected) {
|
||||
return THEME_VARIABLES.every(
|
||||
(key) => actual[key]?.toLowerCase() === expected[key]?.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseSelectOption(app, triggerSelector, option) {
|
||||
await app.clickSelector(triggerSelector);
|
||||
await app.clickText(option, { roles: ["option"] });
|
||||
}
|
||||
|
||||
async function saveSettings(app) {
|
||||
await app.clickText("Save Settings", { roles: ["button"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return document.querySelector("#theme-select") === null;`),
|
||||
{ description: "Settings to close after saving" },
|
||||
);
|
||||
}
|
||||
|
||||
async function assertThemeAcrossNavigation(app, expected) {
|
||||
for (const surface of ["Network", "Extensions", "Profiles"]) {
|
||||
await app.clickSelector(`[aria-label="${surface}"]`);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(expected),
|
||||
{ description: `theme to remain unchanged on ${surface}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function dragBackgroundColorPicker(app) {
|
||||
await app.clickSelector('[aria-label="Background"]');
|
||||
const drag = await app.waitFor(
|
||||
() =>
|
||||
app.execute(`
|
||||
const popover = document.querySelector('[data-slot="popover-content"]');
|
||||
const selection = [...(popover?.querySelectorAll("div") ?? [])].find(
|
||||
(node) => node.style.background.includes("linear-gradient")
|
||||
);
|
||||
if (!selection) return null;
|
||||
const rect = selection.getBoundingClientRect();
|
||||
const points = [];
|
||||
for (const yf of [0.2, 0.4, 0.6, 0.8]) {
|
||||
for (const xf of [0.2, 0.4, 0.6, 0.8]) {
|
||||
const point = {
|
||||
x: Math.round(rect.left + rect.width * xf),
|
||||
y: Math.round(rect.top + rect.height * yf),
|
||||
};
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
if (hit === selection || selection.contains(hit)) points.push(point);
|
||||
}
|
||||
}
|
||||
return points.length >= 2
|
||||
? { start: points[0], end: points[points.length - 1] }
|
||||
: null;
|
||||
`),
|
||||
{ description: "two pointer-interactive background color picker points" },
|
||||
);
|
||||
await app.execute(`
|
||||
window.__donutE2eThemePointerEvents = [];
|
||||
for (const type of ["pointermove", "pointerdown", "pointerup"]) {
|
||||
window.addEventListener(type, (event) => {
|
||||
window.__donutE2eThemePointerEvents.push({
|
||||
type,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
buttons: event.buttons,
|
||||
target: event.target?.className ?? event.target?.tagName ?? "",
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
`);
|
||||
await app.session.command("POST", "/actions", {
|
||||
actions: [
|
||||
{
|
||||
type: "pointer",
|
||||
id: "theme-color-pointer",
|
||||
actions: [
|
||||
{
|
||||
type: "pointerMove",
|
||||
x: drag.start.x,
|
||||
y: drag.start.y,
|
||||
origin: "viewport",
|
||||
},
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pause", duration: 150 },
|
||||
{
|
||||
type: "pointerMove",
|
||||
x: drag.end.x,
|
||||
y: drag.end.y,
|
||||
duration: 100,
|
||||
origin: "viewport",
|
||||
},
|
||||
{ type: "pointerUp", button: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const pointerEvents = await app.execute(
|
||||
`return window.__donutE2eThemePointerEvents ?? [];`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
pointerEvents.map((event) => event.type),
|
||||
["pointermove", "pointerdown", "pointermove", "pointerup"],
|
||||
);
|
||||
assert.match(pointerEvents[1].target, /cursor-pointer/);
|
||||
assert.match(pointerEvents[2].target, /cursor-pointer/);
|
||||
assert.equal(pointerEvents[2].buttons, 1);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector("#theme-preset-select")?.textContent?.includes("Your Own") === true;`,
|
||||
),
|
||||
{
|
||||
description: `customized theme to be marked as Your Own after ${JSON.stringify(pointerEvents)}`,
|
||||
},
|
||||
);
|
||||
await app.clickSelector('[aria-label="Background"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector('[data-slot="popover-content"]') === null;`,
|
||||
),
|
||||
{ description: "color picker to close" },
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
@@ -55,8 +276,6 @@ test("all primary navigation buttons and sub-page tabs render and remain interac
|
||||
|
||||
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"]) {
|
||||
@@ -115,3 +334,116 @@ test("settings tabs, command palette filtering, and responsive layout survive re
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("predefined theme remains rendered across navigation and restart", async () => {
|
||||
await withApp("ui-theme-predefined", async (app) => {
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
await chooseSelectOption(app, "#theme-select", "Light");
|
||||
await saveSettings(app);
|
||||
|
||||
const persisted = await app.invoke("get_app_settings");
|
||||
assert.equal(persisted.theme, "light");
|
||||
const selected = await waitForTheme(
|
||||
app,
|
||||
(snapshot) =>
|
||||
snapshot.mode === "light" &&
|
||||
Object.values(snapshot.inline).every((value) => value === ""),
|
||||
"predefined light theme to render without custom variables",
|
||||
);
|
||||
assert.notEqual(selected.bodyBackground, "");
|
||||
assert.notEqual(selected.bodyForeground, "");
|
||||
await assertThemeAcrossNavigation(app, selected);
|
||||
|
||||
await app.restart();
|
||||
assert.equal((await app.invoke("get_app_settings")).theme, "light");
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(selected),
|
||||
{ description: "predefined light theme after restart" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("preset and manually customized themes survive navigation and restart", async () => {
|
||||
await withApp("ui-theme-custom", async (app) => {
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
await chooseSelectOption(app, "#theme-select", "Custom");
|
||||
await chooseSelectOption(app, "#theme-preset-select", "Dracula");
|
||||
await saveSettings(app);
|
||||
|
||||
const presetSettings = await app.invoke("get_app_settings");
|
||||
assert.equal(presetSettings.theme, "custom");
|
||||
assert.deepEqual(presetSettings.custom_theme, DRACULA_THEME);
|
||||
const preset = await waitForTheme(
|
||||
app,
|
||||
(snapshot) =>
|
||||
snapshot.mode === "dark" &&
|
||||
themeVariablesEqual(snapshot.inline, DRACULA_THEME) &&
|
||||
themeVariablesEqual(snapshot.resolved, DRACULA_THEME),
|
||||
"Dracula preset variables to render",
|
||||
);
|
||||
await assertThemeAcrossNavigation(app, preset);
|
||||
|
||||
await app.restart();
|
||||
assert.deepEqual(
|
||||
(await app.invoke("get_app_settings")).custom_theme,
|
||||
DRACULA_THEME,
|
||||
);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(preset),
|
||||
{ description: "Dracula preset after restart" },
|
||||
);
|
||||
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#theme-select")?.textContent?.trim();`,
|
||||
),
|
||||
"Custom",
|
||||
);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#theme-preset-select")?.textContent?.trim();`,
|
||||
),
|
||||
"Dracula",
|
||||
);
|
||||
await dragBackgroundColorPicker(app);
|
||||
await saveSettings(app);
|
||||
|
||||
const customizedSettings = await app.invoke("get_app_settings");
|
||||
assert.equal(customizedSettings.theme, "custom");
|
||||
assert.notEqual(
|
||||
customizedSettings.custom_theme["--background"].toLowerCase(),
|
||||
DRACULA_THEME["--background"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(customizedSettings.custom_theme).sort(),
|
||||
[...THEME_VARIABLES].sort(),
|
||||
);
|
||||
const customized = await waitForTheme(
|
||||
app,
|
||||
(snapshot) =>
|
||||
snapshot.mode === "dark" &&
|
||||
themeVariablesEqual(snapshot.inline, customizedSettings.custom_theme) &&
|
||||
themeVariablesEqual(snapshot.resolved, customizedSettings.custom_theme),
|
||||
"manually customized variables to render",
|
||||
);
|
||||
assert.notEqual(customized.bodyBackground, preset.bodyBackground);
|
||||
await assertThemeAcrossNavigation(app, customized);
|
||||
|
||||
await app.restart();
|
||||
assert.deepEqual(
|
||||
(await app.invoke("get_app_settings")).custom_theme,
|
||||
customizedSettings.custom_theme,
|
||||
);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(customized),
|
||||
{ description: "manually customized theme after restart" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user