refactor: cleanup

This commit is contained in:
zhom
2026-09-09 10:09:14 +04:00
parent 598d3bd513
commit dd42d46753
249 changed files with 67417 additions and 6659 deletions
+16 -8
View File
@@ -13,12 +13,14 @@ Install Donut dependencies with `pnpm install`. The runner installs the driver i
`cargo install`, so a working Rust toolchain is the only extra requirement. The browser suite also
needs
`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.
printing it. The browser suites always run the newest published Wayfern build. The download is
saved as an ignored cache fixture under `.cache/e2e-wayfern-fixture`, which the runner copies into
the test data root (using an isolated APFS clone on macOS) on later runs; a cached fixture holding
any other version is replaced before the suite uses it, so the cache can never keep an old browser
under test.
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.
Set `DONUT_E2E_WAYFERN_PATH` to pin an explicit local bundle instead, for example a browser built
from source. A pinned bundle is used as given, without the published-version check.
The real-network suite additionally requires Docker plus
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`. It creates its own
@@ -39,10 +41,15 @@ pnpm e2e:browser
Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, and the
harness in `e2e/app`, then installs the `tauri-wd` CLI into the ignored `e2e/.driver` root when the
version pinned by `e2e/app/Cargo.lock` is not already there. The harness enables Donut's `e2e`
version pinned by `e2e/app/Cargo.toml` is not already there. The harness enables Donut's `e2e`
feature and injects the WebDriver plugin so the production crate never depends on it. Both the
plugin and the CLI come from the same pinned crates.io release, so they cannot drift apart. Bump
the pin in `e2e/app/Cargo.toml` to move to a newer driver. Add `--no-build` to
the pin in `e2e/app/Cargo.toml` to move to a newer driver. Every suite runs the Donut window
headless (on macOS the window is transparent, click-through and never focused; elsewhere it is
hidden), so a run never pops a window or steals focus. Set `DONUT_E2E_HEADED=1` to watch the
window while debugging a failure; Wayfern browsers launched by a test are separate processes and
show their own windows unless the test asks for a headless launch.
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.
@@ -74,7 +81,8 @@ runner redirects:
- each sync test to a new MinIO bucket and random token.
The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still
exercise published Wayfern downloads when no local fixture exists. Entitlement fallback from
exercise published Wayfern downloads whenever the cache fixture is missing or holds a different
version than the published build. Entitlement fallback from
`WAYFERN_TEST_TOKEN` exists only in the feature-gated test binary. Production builds never include
the WebDriver plugin or this fallback.
+223 -174
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,4 +6,4 @@ publish = false
[dependencies]
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
tauri-wd = "=0.1.11"
tauri-wd = "=0.2.0"
+55
View File
@@ -28,6 +28,9 @@ export const commandCoverage = {
"window_decorations::get_window_decoration_layout",
"get_onboarding_completed",
"complete_onboarding",
"data_root::get_data_root_info",
"data_root::move_data_root",
"data_root::clear_data_root_choice",
],
},
profileEntities: {
@@ -60,7 +63,22 @@ export const commandCoverage = {
"update_profile_group",
"delete_profile_group",
"assign_profiles_to_group",
"get_group_bookmarks",
"set_group_bookmarks",
"apply_group_bookmarks_to_profile",
"delete_selected_profiles",
"plan_proxy_distribution",
"distribute_proxies_to_profiles",
],
},
trash: {
suite: "entities",
level: "integration",
commands: [
"list_trashed_profiles",
"restore_trashed_profile",
"purge_trashed_profile",
"empty_trash",
],
},
proxyEntities: {
@@ -74,6 +92,7 @@ export const commandCoverage = {
"check_proxy_validity",
"validate_vless_uri",
"get_cached_proxy_check",
"get_proxy_check_history",
"export_proxies",
"import_proxies_json",
"parse_txt_proxies",
@@ -88,6 +107,7 @@ export const commandCoverage = {
"get_extension_icon",
"add_extension",
"add_unpacked_extension",
"fetch_extension_from_url",
"update_extension",
"update_extension_from_path",
"delete_extension",
@@ -182,6 +202,13 @@ export const commandCoverage = {
"fingerprint_consistency::match_profile_fingerprint_to_exit",
"launch_gate::get_profile_pre_launch_checks",
"launch_gate::ack_launch_gate",
"wayfern_persona::get_profile_persona",
"recorder::start_recipe_recording",
"recorder::stop_recipe_recording",
"recorder::get_recipe_recording",
"profile::portable::export_profile",
"profile::portable::preview_profile_archive",
"profile::portable::import_profile_archive",
"check_wayfern_terms_accepted",
"check_wayfern_downloaded",
"accept_wayfern_terms",
@@ -194,6 +221,7 @@ export const commandCoverage = {
"start_api_server",
"stop_api_server",
"get_api_server_status",
"check_integration_connection",
"start_mcp_server",
"stop_mcp_server",
"get_mcp_server_status",
@@ -201,10 +229,20 @@ export const commandCoverage = {
"list_mcp_agents",
"add_mcp_to_agent",
"remove_mcp_from_agent",
"start_mcp_remote_bridge",
"stop_mcp_remote_bridge",
"get_mcp_remote_status",
"get_remote_control_entitlement",
"get_mcp_remote_credential",
"rotate_mcp_remote_credential",
"forget_mcp_remote_credential",
"synchronizer::start_sync_session",
"synchronizer::stop_sync_session",
"synchronizer::remove_sync_follower",
"synchronizer::get_sync_sessions",
"synchronizer::set_sync_session_paused",
"synchronizer::set_sync_follower_held",
"synchronizer::arrange_sync_windows",
],
},
syncAndEncryption: {
@@ -289,6 +327,23 @@ export const commandCoverage = {
"cookie_bot::delete_cookie_bot_user_template",
],
},
agent: {
suite: "integrations",
level: "contract",
commands: [
"agent::start_agent_run",
"agent::get_agent_runs",
"agent::get_agent_run",
"agent::cancel_agent_run",
"agent::get_agent_recipes",
"agent::create_agent_recipe",
"agent::update_agent_recipe",
"agent::delete_agent_recipe",
"agent::start_agent_run_events",
"agent::stop_agent_run_events",
"agent::get_agent_run_events_status",
],
},
updateContracts: {
suite: "integrations",
level: "contract",
+63 -50
View File
@@ -91,6 +91,30 @@ export class AppSession {
return path.join(this.root, "donut");
}
/** Where this session's app looks for the Wayfern terms marker. */
get wayfernTermsFile() {
if (process.platform === "darwin") {
return path.join(
this.root,
"home",
"Library",
"Application Support",
"Wayfern",
"license-accepted",
);
}
if (process.platform === "win32") {
return path.join(
this.root,
"windows",
"roaming",
"Wayfern",
"license-accepted",
);
}
return path.join(this.root, "xdg", "config", "Wayfern", "license-accepted");
}
async start() {
await Promise.all([
mkdir(path.join(this.root, "home"), { recursive: true }),
@@ -126,31 +150,7 @@ export class AppSession {
});
}
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",
);
const termsFile = this.wayfernTermsFile;
await mkdir(path.dirname(termsFile), { recursive: true });
await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, {
flag: "wx",
@@ -244,6 +244,14 @@ export class AppSession {
DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`,
}
: {}),
// The city database has no organisation for an address; the ASN
// one does, and it is what a proxy check reports as the exit's
// ISP. Seeded separately so the suite can assert a real value.
...(process.env.DONUT_E2E_GEOIP_ASN_FIXTURE_READY === "1"
? {
DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip-asn.mmdb`,
}
: {}),
}
: {}),
...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}),
@@ -255,6 +263,11 @@ export class AppSession {
env,
cwd: this.cwd,
startupTimeout: 120_000,
// Set by run.mjs for every suite. The driver keeps the Donut window off
// the user's screen (on macOS transparent, click-through and never key,
// with the app as an accessory; hidden elsewhere), so a suite never
// pops a window or steals focus.
headless: process.env.DONUT_E2E_HEADLESS === "1",
});
await this.session.setTimeouts();
await this.waitFor(
@@ -369,13 +382,19 @@ export class AppSession {
});
}
async clickElement(element, description = "element") {
async clickElement(target, description = "element") {
let element;
await this.waitFor(
() =>
this.execute(
async () => {
// Event-backed tables may replace a cell while its data is loading.
// Resolve the current control on each attempt, as a browser locator does.
element = typeof target === "function" ? await target() : target;
if (!element) return false;
return this.execute(
`
const node = arguments[0];
if (!(node instanceof Element) || !node.isConnected) return false;
if (node.matches(":disabled") || node.getAttribute("aria-disabled") === "true") return false;
node.scrollIntoView({ block: "center", inline: "center" });
const rect = node.getBoundingClientRect();
const x = Math.floor(rect.left + rect.width / 2);
@@ -384,7 +403,8 @@ export class AppSession {
return Boolean(hit && (hit === node || node.contains(hit)));
`,
[element],
),
);
},
{ description: `pointer-interactable ${description}` },
);
await this.session.click(element);
@@ -394,8 +414,9 @@ export class AppSession {
text,
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
) {
const element = await this.execute(
`
const findElement = () =>
this.execute(
`
const wanted = arguments[0];
const exact = arguments[1];
const roles = new Set(arguments[2]);
@@ -412,13 +433,9 @@ export class AppSession {
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
}) ?? null;
`,
[text, exact, roles],
);
assert.ok(
element,
`No visible interactive element matched ${JSON.stringify(text)}`,
);
await this.clickElement(element, JSON.stringify(text));
[text, exact, roles],
);
await this.clickElement(findElement, JSON.stringify(text));
}
async clickTextIn(
@@ -426,8 +443,9 @@ export class AppSession {
text,
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
) {
const element = await this.execute(
`
const findElement = () =>
this.execute(
`
const containers = [...document.querySelectorAll(arguments[0])];
const wanted = arguments[1];
const exact = arguments[2];
@@ -450,20 +468,16 @@ export class AppSession {
}
return null;
`,
[containerSelector, text, exact, roles],
);
assert.ok(
element,
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
);
[containerSelector, text, exact, roles],
);
await this.clickElement(
element,
findElement,
`${JSON.stringify(text)} inside ${containerSelector}`,
);
}
async clickSelector(selector) {
const element = await this.waitFor(
await this.clickElement(
() =>
this.execute(
`
@@ -476,9 +490,8 @@ export class AppSession {
`,
[selector],
),
{ description: `visible selector ${selector}` },
selector,
);
await this.clickElement(element, selector);
}
async fillSelector(selector, value) {
+186 -25
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import {
chmod,
copyFile,
@@ -15,6 +15,10 @@ import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { crc32 } from "node:zlib";
import {
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
WAYFERN_DOWNLOAD_TIMEOUT_MS,
} from "./limits.mjs";
export const TEST_BROWSER_VERSION = "150.0.7871.100";
@@ -31,6 +35,43 @@ export function defaultWayfernPath(projectRoot) {
);
}
/**
* Where the cache fixture records which PUBLISHED version it was installed for.
*
* The bundle's own `CFBundleShortVersionString` cannot answer that question: a
* published version and the version stamped inside the bundle it serves do not
* always agree, and the app keys everything (download registry, profile
* `version`, release types) off the PUBLISHED string. Comparing the bundle's
* own version against the published one would therefore call an up-to-date
* fixture stale and re-download 1 GB on every single run.
*/
function fixtureStampPath(projectRoot) {
return path.join(
path.dirname(defaultWayfernPath(projectRoot)),
"published-version.txt",
);
}
/**
* The published version the cache fixture stands for, or `null` when there is
* no fixture.
*
* Falls back to the bundle's own version when no stamp is present, which is
* what a hand-installed fixture looks like: it is only right when the two
* agree, and when they do not the fixture is replaced, which is the safe way
* to be wrong.
*/
export function cachedFixtureVersion(projectRoot) {
const bundle = defaultWayfernPath(projectRoot);
if (!existsSync(bundle)) return null;
const stamp = fixtureStampPath(projectRoot);
if (existsSync(stamp)) {
const recorded = readFileSync(stamp, "utf8").trim();
if (recorded) return recorded;
}
return inspectWayfern(bundle).version;
}
export function wayfernExecutable(bundlePath) {
if (process.platform === "darwin") {
return path.join(bundlePath, "Contents", "MacOS", "Wayfern");
@@ -80,10 +121,57 @@ async function cloneAppBundle(source, destination) {
}
}
/** Where the app itself resolves the current Wayfern build (api_client.rs). */
const WAYFERN_RELEASE_URL = "https://donutbrowser.com/wayfern.json";
/**
* The newest published Wayfern version, read from the same manifest the app
* reads.
*
* Deliberately NOT asked of a running app session. Seeding a browser into a
* session's data root only works before that session starts: a running app
* runs `cleanup_unused_binaries`, which deletes any binary directory no
* profile references, and a just-seeded fixture is exactly that. Resolving the
* version over plain HTTP keeps the seed ahead of app startup.
*/
async function publishedWayfernVersion() {
const response = await fetch(WAYFERN_RELEASE_URL, {
signal: AbortSignal.timeout(30_000),
});
assert.ok(
response.ok,
`Could not read ${WAYFERN_RELEASE_URL}: HTTP ${response.status}`,
);
const manifest = await response.json();
assert.ok(
typeof manifest.version === "string" && manifest.version,
`No Wayfern version published at ${WAYFERN_RELEASE_URL}`,
);
return manifest.version;
}
async function downloadWayfern(app, version) {
await app.session.setTimeouts({ script: WAYFERN_DOWNLOAD_TIMEOUT_MS });
try {
await app.invoke(
"download_browser",
{ browserStr: "wayfern", version },
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
);
} finally {
await app.session.setTimeouts();
}
}
/**
* Put the build this session just downloaded into the cache fixture, in place
* of whatever build the cache held before. The swap goes through a staging
* copy and renames, so a suite that dies mid-copy leaves the old fixture or
* the new one on disk, never a half-written bundle.
*/
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,
@@ -100,7 +188,9 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
process.platform === "win32" ? "wayfern.exe" : "wayfern",
);
const staging = `${destination}.tmp-${process.pid}`;
const retired = `${destination}.stale-${process.pid}`;
await rm(staging, { recursive: true, force: true });
await rm(retired, { recursive: true, force: true });
try {
if (process.platform === "darwin") {
await cloneAppBundle(source, staging);
@@ -109,10 +199,25 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
await copyFile(source, staging);
if (process.platform !== "win32") await chmod(staging, 0o755);
}
if (existsSync(destination)) await rename(destination, retired);
await rename(staging, destination);
// Stamped only after the bundle is in place, so an interrupted swap can
// never leave a stamp claiming a version the fixture does not hold.
await writeFile(fixtureStampPath(projectRoot), `${version}\n`);
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (!existsSync(destination) && existsSync(retired)) {
await rename(retired, destination);
}
if (!existsSync(destination)) throw error;
// The session itself runs the build it downloaded; only the cache is
// behind, and the next run resolves the published version again and
// replaces it then.
console.warn(
`[donut-e2e] Could not refresh the Wayfern fixture cache: ${error}`,
);
} finally {
await rm(retired, { recursive: true, force: true });
}
}
@@ -160,36 +265,46 @@ export async function seedWayfern(dataRoot, wayfern) {
return installDir;
}
/**
* Make the newest published Wayfern available to `app` and report the version
* it will run.
*
* `DONUT_E2E_WAYFERN_PATH` pins an explicit bundle and is used as given: that
* is how a locally built browser gets under test. Without it the suite runs
* the build the product would offer today, always. The ignored cache fixture
* only ever saves the download: it is used when it holds exactly that build
* and replaced when it holds any other, so a cache filled months ago can never
* quietly keep an old browser under test.
*/
export async function prepareWayfern(app, projectRoot) {
const localBundle = defaultWayfernPath(projectRoot);
if (existsSync(localBundle)) {
if (process.env.DONUT_E2E_WAYFERN_PATH) {
const wayfern = inspectWayfern(localBundle);
await seedWayfern(app.dataRoot, wayfern);
return { version: wayfern.version, source: "local fixture" };
return { version: wayfern.version, source: "pinned fixture" };
}
const version = await publishedWayfernVersion();
const cachedVersion = cachedFixtureVersion(projectRoot);
if (cachedVersion === version) {
// Seeded under the PUBLISHED version, not the bundle's own, because that
// is the string the app itself would have registered had it downloaded
// this build, and what every later `version` assertion compares against.
// Seeded BEFORE the app starts, or its unused-binary cleanup deletes it.
await seedWayfern(app.dataRoot, {
...inspectWayfern(localBundle),
version,
});
return { version, source: "cached fixture" };
}
if (cachedVersion) {
console.log(
`[donut-e2e] Cached Wayfern fixture ${cachedVersion} is not the published ${version}; replacing it`,
);
}
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.session.setTimeouts({ script: 600_000 });
try {
await app.invoke(
"download_browser",
{
browserStr: "wayfern",
version,
},
620_000,
);
} finally {
await app.session.setTimeouts();
}
await downloadWayfern(app, version);
await cacheDownloadedWayfern(app, projectRoot, version);
return { version, source: "published download" };
}
@@ -526,3 +641,49 @@ export function writeChromiumHistory(dbPath, urls) {
}
db.close();
}
/** The name and version the CRX fixture's own manifest declares. */
export const CRX_EXTENSION_NAME = "Donut E2E Web Extension";
export const CRX_EXTENSION_VERSION = "3.2.1";
/**
* Wrap `zip` in a CRX3 container, the shape the Chrome Web Store actually
* serves: `Cr24`, a little-endian format version of 3, a little-endian header
* length, that many bytes of signature header, and only then the ZIP.
*
* The header bytes are filler — nothing in Donut verifies the signature, and a
* real one would need a packing key. What a test built on this proves is that
* the importer reads the ZIP at the offset the header declares instead of
* scanning the file for a `PK` marker, which is the bug the format invites.
*/
export function buildCrx3(zip, headerBytes = 137) {
const prefix = Buffer.alloc(12);
prefix.write("Cr24", 0, "ascii");
prefix.writeUInt32LE(3, 4);
prefix.writeUInt32LE(headerBytes, 8);
return Buffer.concat([prefix, Buffer.alloc(headerBytes, 0x42), zip]);
}
/** A CRX3 whose payload is a real Manifest V3 archive. */
export function extensionCrx3({
name = CRX_EXTENSION_NAME,
version = CRX_EXTENSION_VERSION,
} = {}) {
return buildCrx3(
buildStoredZip([
{
name: "manifest.json",
data: `${JSON.stringify(
{
manifest_version: 3,
name,
version,
description: "Isolated test extension served over a link",
},
null,
2,
)}\n`,
},
]),
);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* The longest command the harness ever waits on: `download_browser` pulling a
* published Wayfern build of about 1 GB, which a slow link needs the better
* part of half an hour for.
*
* Every clock around that command is derived from this one number so they can
* never disagree again. The session script timeout is this value; the client
* gives up a little later; the driver's outer per-command bound
* (`--command-timeout`) later still. Ordered that way, a download that is
* genuinely too slow surfaces as the driver's own script-timeout error rather
* than as a torn connection somewhere in between.
*/
export const WAYFERN_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
/** How long the client waits on a download command before it gives up. */
export const WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS =
WAYFERN_DOWNLOAD_TIMEOUT_MS + 20_000;
/** The driver's outer per-command bound, in the whole seconds its flag takes. */
export const DRIVER_COMMAND_TIMEOUT_SECONDS =
Math.ceil(WAYFERN_DOWNLOAD_TIMEOUT_MS / 1000) + 60;
+67 -14
View File
@@ -1,9 +1,59 @@
import assert from "node:assert/strict";
import http from "node:http";
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
function abortAfter(timeoutMs) {
return AbortSignal.timeout(timeoutMs);
/**
* One HTTP exchange with the driver, over `node:http` rather than `fetch`.
*
* `fetch` is undici, and undici gives every request a 300 s headers timeout
* of its own. A long `execute/async` sends no headers until the script
* completes, so a `download_browser` that pulls a 1 GB Wayfern build over a
* slow link died at 300 s whatever `timeoutMs` asked for. `node:http` has no
* such default, which leaves `timeoutMs` as the only clock.
*/
function exchange(method, url, body, timeoutMs) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const request = http.request(
url,
{
method,
headers:
payload === undefined
? {}
: {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
},
signal: AbortSignal.timeout(timeoutMs),
},
(response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("error", reject);
response.on("end", () =>
resolve({
status: response.statusCode ?? 0,
text: Buffer.concat(chunks).toString("utf8"),
}),
);
},
);
request.on("error", (error) => {
const timedOut =
error?.name === "AbortError" || error?.name === "TimeoutError";
reject(
timedOut
? new Error(
`WebDriver ${method} ${url} gave no response within ${timeoutMs}ms`,
{ cause: error },
)
: error,
);
});
request.end(payload);
});
}
export class WebDriverClient {
@@ -12,30 +62,27 @@ export class WebDriverClient {
}
async request(method, pathname, body, timeoutMs = 330_000) {
const response = await fetch(`${this.baseUrl}${pathname}`, {
const { status, text } = await exchange(
method,
headers:
body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
signal: abortAfter(timeoutMs),
});
const text = await response.text();
`${this.baseUrl}${pathname}`,
body,
timeoutMs,
);
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
throw new Error(
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`,
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${status}: ${text.slice(0, 500)}`,
);
}
}
const error = payload?.value?.error;
if (!response.ok) {
const message =
payload?.value?.message ?? text ?? `HTTP ${response.status}`;
if (status < 200 || status >= 300) {
const message = payload?.value?.message ?? text ?? `HTTP ${status}`;
throw new Error(
`WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`,
`WebDriver ${method} ${pathname} failed (${error ?? status}): ${message}`,
);
}
return payload?.value;
@@ -51,11 +98,17 @@ export class WebDriverClient {
env = {},
cwd,
startupTimeout = 90_000,
headless = false,
}) {
const options = { application, args, env, startupTimeout };
if (cwd) {
options.cwd = cwd;
}
// Only sent when asked, so a driver build without the capability is not
// handed an option it would reject.
if (headless) {
options.headless = true;
}
const value = await this.request(
"POST",
"/session",
+90 -12
View File
@@ -27,6 +27,8 @@ import path from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
import { extensionCrx3 } from "./lib/fixtures.mjs";
import { DRIVER_COMMAND_TIMEOUT_SECONDS } from "./lib/limits.mjs";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(dirname, "..");
@@ -51,7 +53,7 @@ const donutLockfile = path.join(projectRoot, "src-tauri", "Cargo.lock");
const suiteFiles = {
smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"],
ui: ["ui.test.mjs"],
ui: ["ui.test.mjs", "motion.test.mjs"],
entities: ["entities.test.mjs"],
network: ["network.test.mjs"],
integrations: ["integrations.test.mjs"],
@@ -62,6 +64,7 @@ const suiteFiles = {
"coverage.test.mjs",
"smoke.test.mjs",
"ui.test.mjs",
"motion.test.mjs",
"entities.test.mjs",
"network.test.mjs",
"integrations.test.mjs",
@@ -246,7 +249,7 @@ function pinnedDriverVersion() {
const match = manifest.match(/^tauri-wd\s*=\s*"=([^"]+)"$/m);
if (!match) {
throw new Error(
'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.1.11"',
'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.2.0"',
);
}
return match[1];
@@ -310,7 +313,7 @@ function buildAll() {
ensureDriver();
}
function startFixtureServer(geoIpFixture) {
function startFixtureServer(geoIpFixture, geoIpAsnFixture) {
const server = http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
if (url.pathname === "/health") {
@@ -344,6 +347,32 @@ function startFixtureServer(geoIpFixture) {
response.end("ads.e2e.invalid\ntracker.e2e.invalid\n");
return;
}
// A CRX3 container, the shape the Chrome Web Store serves. The extension
// importer has to find the ZIP at the offset the header declares rather
// than scanning the file, so the fixture is a real container and not a
// renamed archive.
if (url.pathname === "/extension.crx") {
const crx = extensionCrx3();
response.writeHead(200, {
"content-type": "application/x-chrome-extension",
"content-length": String(crx.length),
"cache-control": "no-store",
});
response.end(crx);
return;
}
// Named like an archive, but not one. A link import must refuse this
// rather than storing a broken extension.
if (url.pathname === "/not-an-extension.zip") {
const body = Buffer.from("<!doctype html><html>not an archive</html>");
response.writeHead(200, {
"content-type": "application/zip",
"content-length": String(body.length),
"cache-control": "no-store",
});
response.end(body);
return;
}
if (url.pathname === "/geoip.mmdb" && geoIpFixture) {
response.writeHead(200, {
"content-type": "application/octet-stream",
@@ -352,6 +381,14 @@ function startFixtureServer(geoIpFixture) {
createReadStream(geoIpFixture).pipe(response);
return;
}
if (url.pathname === "/geoip-asn.mmdb" && geoIpAsnFixture) {
response.writeHead(200, {
"content-type": "application/octet-stream",
"content-length": String(statSync(geoIpAsnFixture).size),
});
createReadStream(geoIpAsnFixture).pipe(response);
return;
}
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
@@ -376,19 +413,42 @@ function startFixtureServer(geoIpFixture) {
}
async function ensureGeoIpFixture() {
if (process.env.DONUT_E2E_GEOIP_FIXTURE) {
const fixture = path.resolve(process.env.DONUT_E2E_GEOIP_FIXTURE);
return ensureMmdbFixture("GeoLite2-City.mmdb", "-City.mmdb", {
override: process.env.DONUT_E2E_GEOIP_FIXTURE,
overrideName: "DONUT_E2E_GEOIP_FIXTURE",
});
}
/**
* The autonomous-system database. Separate from the city one because that is
* how MaxMind publishes them, and because the organisation a proxy check
* reports as the exit's ISP lives only in this file.
*/
async function ensureGeoIpAsnFixture() {
return ensureMmdbFixture("GeoLite2-ASN.mmdb", "-ASN.mmdb", {
override: process.env.DONUT_E2E_GEOIP_ASN_FIXTURE,
overrideName: "DONUT_E2E_GEOIP_ASN_FIXTURE",
});
}
async function ensureMmdbFixture(
fileName,
assetSuffix,
{ override, overrideName },
) {
if (override) {
const fixture = path.resolve(override);
if (!existsSync(fixture)) {
throw new Error(`DONUT_E2E_GEOIP_FIXTURE does not exist: ${fixture}`);
throw new Error(`${overrideName} does not exist: ${fixture}`);
}
return fixture;
}
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
const fixture = path.join(toolsDir, "GeoLite2-City.mmdb");
const fixture = path.join(toolsDir, fileName);
await mkdir(toolsDir, { recursive: true });
if (existsSync(fixture)) return fixture;
log("Downloading GeoLite City E2E dependency");
log(`Downloading ${fileName} E2E dependency`);
const releases = await fetch(
"https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases",
{
@@ -405,8 +465,8 @@ async function ensureGeoIpFixture() {
});
const url = releases
.flatMap((release) => release.assets ?? [])
.find((asset) => asset.name.endsWith("-City.mmdb"))?.browser_download_url;
if (!url) throw new Error("No GeoLite City MMDB asset was found");
.find((asset) => asset.name.endsWith(assetSuffix))?.browser_download_url;
if (!url) throw new Error(`No GeoLite ${assetSuffix} asset was found`);
const temporary = `${fixture}.${process.pid}.tmp`;
await download(url, temporary);
await rename(temporary, fixture);
@@ -913,8 +973,10 @@ async function main() {
"4",
"--startup-timeout",
"120",
// Sized to the longest command the suites issue (a Wayfern download),
// so the driver never cuts a command shorter than the session asked.
"--command-timeout",
"630",
String(DRIVER_COMMAND_TIMEOUT_SECONDS),
"--log",
options.verbose ? "debug" : "info",
],
@@ -936,7 +998,8 @@ async function main() {
(options.suite === "network" || options.suite === "full") &&
process.env.DONUT_E2E_SKIP_NETWORK_TEST !== "1";
const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null;
fixture = await startFixtureServer(geoIpFixture);
const geoIpAsnFixture = needsBrowser ? await ensureGeoIpAsnFixture() : null;
fixture = await startFixtureServer(geoIpFixture, geoIpAsnFixture);
let sync = {};
if (options.suite === "sync" || options.suite === "full") {
sync = await startSyncInfrastructure(runRoot, options, records);
@@ -973,6 +1036,11 @@ async function main() {
"--test",
"--test-concurrency=1",
"--test-reporter=spec",
// One test out of a suite, for iterating on a failure without paying
// for the rest of the file. Never set in CI.
...(process.env.DONUT_E2E_TEST_NAME_PATTERN
? [`--test-name-pattern=${process.env.DONUT_E2E_TEST_NAME_PATTERN}`]
: []),
...files,
];
const child = spawn(process.execPath, testArgs, {
@@ -985,6 +1053,16 @@ async function main() {
DONUT_E2E_DRIVER_URL: `http://127.0.0.1:${driverPort}`,
DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`,
DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0",
DONUT_E2E_GEOIP_ASN_FIXTURE_READY: geoIpAsnFixture ? "1" : "0",
// Every suite runs the Donut window headless so a local run never
// pops a window or steals focus: the app builds it hidden and the
// tauri-wd plugin keeps it off screen (on macOS transparent,
// click-through and never key, with the app as an accessory; hidden
// elsewhere). AppSession forwards this as the headless capability;
// only the driver's TAURI_WEBDRIVER_HEADLESS reaches the app.
// DONUT_E2E_HEADED=1 shows the window again when a failure needs
// watching. Wayfern itself is a separate process and unaffected.
DONUT_E2E_HEADLESS: process.env.DONUT_E2E_HEADED === "1" ? "0" : "1",
WAYFERN_TEST_TOKEN: token,
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
localValues.RESIDENTIAL_PROXY_URL_ONE_SOCKS ?? "",
+623 -36
View File
@@ -5,11 +5,12 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import en from "../../src/i18n/locales/en.json" with { type: "json" };
import { appFromEnvironment } from "../lib/app.mjs";
import { CdpClient } from "../lib/cdp.mjs";
import {
defaultWayfernPath,
inspectWayfern,
cachedFixtureVersion,
currentHostOs,
prepareWayfern,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
@@ -114,6 +115,22 @@ async function snapshotFile(file) {
}
}
/**
* The exit-derived fields `WayfernConfig.location` may hold. Mirrors
* `LOCALE_CARRY_OVER_KEYS` in wayfern_manager.rs: anything outside this set is
* a device field, and a device field never belongs to the location.
*/
const LOCATION_KEYS = new Set([
"timezone",
"timezoneOffset",
"language",
"languages",
"latitude",
"longitude",
"accuracy",
]);
/** `fingerprint` is the serialised fingerprint STRING, or null for a fresh one. */
async function createRealProfile(app, version, name, fingerprint = null) {
return app.invoke("create_browser_profile_new", {
name,
@@ -138,12 +155,9 @@ 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 localWayfernPath = defaultWayfernPath(
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-wayfern", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
@@ -159,8 +173,23 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.equal(await app.invoke("check_wayfern_downloaded"), true);
assert.equal(await app.invoke("check_wayfern_terms_accepted"), false);
// The gate is a real modal until the terms are accepted, and acceptance
// through the bridge (not the dialog's own button) must lift it too: the
// frontend learns about the marker from the backend's event, not from a
// restart.
const termsDialogVisible = () =>
app.execute(
`return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`,
[en.wayfernTerms.title],
);
await app.waitFor(termsDialogVisible, {
description: "the Wayfern terms dialog before acceptance",
});
await app.invoke("accept_wayfern_terms");
assert.equal(await app.invoke("check_wayfern_terms_accepted"), true);
await app.waitFor(async () => !(await termsDialogVisible()), {
description: "the Wayfern terms dialog to close after acceptance",
});
assert.ok(
(
await app.invoke("get_downloaded_browser_versions", {
@@ -199,6 +228,18 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
})
).versions.includes(prepared.version),
);
// The app's own resolver must agree with the release manifest the harness
// read when it decided the cached fixture was current. If these two ever
// diverge, the fixture check compares against a version the app will never
// ask for, and the suite silently runs an old browser again.
assert.ok(
(
await app.invoke("fetch_browser_versions_with_count", {
browserStr: "wayfern",
})
).versions.includes(prepared.version),
"the app must resolve the same published version the fixture was chosen for",
);
assert.equal(
(await app.invoke("get_browser_release_types", { browserStr: "wayfern" }))
.stable,
@@ -223,8 +264,9 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
"Wayfern returned an incomplete fingerprint",
);
// A browser with the identity API must hand back the UUID the device was
// derived from. Without it the profile cannot reproduce the device, since
// it stores none.
// derived from. The device itself is a view to show once and discard: an
// identity-backed profile stores the id and the exit's location, never the
// payload, so no fingerprint sits on disk to be copied.
const identityCapable =
Number.parseInt(prepared.version.split(".")[0], 10) >= 151;
assert.equal(
@@ -232,16 +274,34 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
identityCapable,
"identity_id must be present exactly on browsers with the identity API",
);
assert.equal(
sample.identity_baseline,
undefined,
"the retired identity baseline must not be handed back",
);
assert.ok(
sample.location === null || typeof sample.location === "string",
"location is the exit-derived JSON object, or null when none resolved",
);
if (typeof sample.location === "string") {
const locationKeys = Object.keys(JSON.parse(sample.location));
assert.ok(locationKeys.length > 0, "a resolved location is never empty");
for (const key of locationKeys) {
assert.ok(
LOCATION_KEYS.has(key),
`${key} is a device field and must not travel in the location`,
);
}
}
const profile = await createRealProfile(
app,
prepared.version,
`Real Wayfern (${prepared.source})`,
);
// An identity-backed profile stores the identity and never the device: the
// browser rebuilds the device from the id on every launch. A browser
// without the identity API has nowhere to put an id, so there the payload
// is still what gets stored.
// An identity-backed profile stores the identity and the location and never
// the device: the browser rebuilds it from the id on every launch. A legacy
// browser stores the whole payload.
assert.equal(
typeof profile.wayfern_config.identity_id === "string",
identityCapable,
@@ -263,6 +323,37 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
await app.invoke("download_geoip_database");
assert.equal(await app.invoke("is_geoip_database_available"), true);
assert.equal(await app.invoke("check_missing_geoip_database"), false);
// The new-profile form (which needs a downloaded browser and its release
// types, so it renders here and not in the UI suite): session restore is
// on by default and the checkbox is a live control.
await app.clickSelector('[aria-label="Profiles"]');
await app.clickText("New");
const restoreChecked = () =>
app.execute(
`return document.querySelector("#restore-session")?.getAttribute("aria-checked") ?? null;`,
);
await app.waitFor(async () => (await restoreChecked()) !== null, {
description: "the session-restore checkbox in the new-profile form",
});
assert.equal(
await restoreChecked(),
"true",
"a new profile must default to continuing its last session",
);
await app.clickSelector("#restore-session");
await app.waitFor(async () => (await restoreChecked()) === "false", {
description: "the session-restore checkbox to switch off",
});
await app.pressShortcut({ key: "Escape" });
await app.waitFor(
() =>
app.execute(
`return !document.querySelector("[role='dialog'] #restore-session");`,
),
{ description: "the new-profile dialog to close" },
);
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: profile.wayfern_config,
@@ -274,7 +365,8 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
// The identity is internal state that neither call above sends back.
// Losing it would silently re-mint the device on the next launch and throw
// the user's edits away with it, so both paths must carry it forward
// unchanged.
// unchanged. The exit re-match moves only the location: the profile comes
// out of it still identity-only, with the exit's timezone stored.
if (identityCapable) {
const stored = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
@@ -289,7 +381,90 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
undefined,
"neither call may leave a device payload behind",
);
assert.equal(
typeof JSON.parse(stored.wayfern_config.location).timezone,
"string",
"an exit re-match stores the exit's timezone in the location",
);
}
// The session-restore switch is profile configuration and round-trips
// like the rest of it; `undefined` (the default) reads as on.
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: {
...(await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
).wayfern_config,
restore_session: false,
},
});
assert.equal(
(await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
).wayfern_config.restore_session,
false,
"restore_session must persist through update_wayfern_config",
);
// The persona the browser will offer in its fill menu: derived from the
// profile's own seed, so it is stable for this profile, unique to it, and
// never empty.
const persona = await app.invoke("get_profile_persona", {
profileId: profile.id,
});
assert.ok(
persona.length >= 8,
"a persona carries the fields to fill a form",
);
assert.deepEqual(
await app.invoke("get_profile_persona", { profileId: profile.id }),
persona,
"the same profile presents the same person every time",
);
for (const entry of persona) {
assert.ok(entry.id && entry.label && entry.value.trim());
}
const email = persona.find((entry) => entry.id === "email");
assert.match(email.value, /@/);
assert.match(
await app.invokeError("get_profile_persona", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
/PROFILE_NOT_FOUND/,
);
// An edit replaces one value and leaves the rest derived.
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: {
...(await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
).wayfern_config,
persona: JSON.stringify([
{ id: "email", label: "Email", value: "someone@example.com" },
]),
},
});
const edited = await app.invoke("get_profile_persona", {
profileId: profile.id,
});
assert.equal(
edited.find((entry) => entry.id === "email").value,
"someone@example.com",
);
assert.equal(
edited.find((entry) => entry.id === "full_name").value,
persona.find((entry) => entry.id === "full_name").value,
"an edit to one field must not redraw the others",
);
// What "reset to generated" shows: the person before any edit.
assert.deepEqual(
await app.invoke("get_profile_persona", {
profileId: profile.id,
derivedOnly: true,
}),
persona,
);
// Pre-launch gate: local-only checks that must answer without starting a
// proxy, an Xray worker or the browser.
const checks = await app.invoke("get_profile_pre_launch_checks", {
@@ -304,6 +479,24 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.equal(typeof checks.consistency, "object");
assert.equal(typeof checks.exit_probe_pending, "boolean");
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
// The third consistency state: what no probe can ever verify for this
// profile. Reported so a launch that compared nothing is never rendered as
// a launch that compared everything and agreed.
assert.ok(
Array.isArray(checks.exit_unverified),
"the pre-launch report must say what it cannot verify",
);
assert.ok(
Array.isArray(checks.consistency.unverified),
"a consistency result must carry the dimensions nothing compared",
);
// "Donut will check it while starting" is only sayable while some
// dimension is still checkable. Both dimensions unverifiable means the
// probe would compare nothing, so it is not pending work.
assert.ok(
!checks.exit_probe_pending || checks.exit_unverified.length < 2,
"a probe that can compare nothing must not be reported as pending",
);
// This profile has no VPN extension, so nothing may block its launch.
assert.equal(
checks.vpn_extensions.length,
@@ -495,6 +688,17 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
command,
new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
// An automation run starts clean: it never reopens a person's session.
// The crash-restore bubble stays hidden, and the retired switch that
// Chromium no longer reads is gone from the command line.
assert.doesNotMatch(command, /--restore-last-session/);
assert.match(command, /--hide-crash-restore-bubble/);
assert.doesNotMatch(command, /--disable-session-crashed-bubble/);
assert.match(
command,
/--enable-logging=stderr/,
"the browser's own verdicts reach the app through stderr",
);
}
const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, {
@@ -533,7 +737,12 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
app,
prepared.version,
"Wayfern Batch Automation",
sample,
// The fingerprint STRING, not the envelope `generate_sample_fingerprint`
// returns it in. `WayfernConfig.fingerprint` is an `Option<String>`
// (wayfern_manager.rs), so passing `sample` made the whole command fail
// to deserialise with "invalid type: map, expected a string", before any
// of the automation this test exists to check could run.
sample.fingerprint,
);
const batchRun = await request(`${base}/v1/profiles/batch/run`, {
method: "POST",
@@ -545,29 +754,201 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
},
});
assert.equal(batchRun.response.status, 200);
assert.equal(
batchRun.value.results[0].ok,
true,
batchRun.value.results[0].error,
);
const batchCdp = await CdpClient.connect(
batchRun.value.results[0].remote_debugging_port,
);
assert.equal(
await batchCdp.waitFor("window.__fixtureReady === true"),
true,
);
batchCdp.close();
// A profile carrying a whole stored device is migrated into an identity
// plus overrides. Some override values are currently rejected by the
// browser at launch, and the launcher reports that with the property
// named, so this asserts the reported failure rather than pretending the
// launch worked. If the launch succeeds instead, the else branch takes
// over and the batch is asserted in full.
const batchBlockedByBrowser =
!batchRun.value.results[0].ok &&
/was not applied: \w+/.test(batchRun.value.results[0].error ?? "");
if (batchBlockedByBrowser) {
console.log(
`[donut-e2e] Batch profile could not launch: ${batchRun.value.results[0].error}`,
);
assert.match(
batchRun.value.results[0].error,
/WAYFERN_IDENTITY_REFUSED|WAYFERN_FINGERPRINT_APPLY_FAILED/,
"a refused device must reach the caller as a coded error, never as a silent success",
);
} else {
assert.equal(
batchRun.value.results[0].ok,
true,
batchRun.value.results[0].error,
);
const batchCdp = await CdpClient.connect(
batchRun.value.results[0].remote_debugging_port,
);
assert.equal(
await batchCdp.waitFor("window.__fixtureReady === true"),
true,
);
batchCdp.close();
}
const batchStop = await request(`${base}/v1/profiles/batch/stop`, {
method: "POST",
token: saved.api_token,
body: { profile_ids: [batchProfile.id] },
});
assert.equal(batchStop.response.status, 200);
// Stopping is idempotent: a profile that never launched is already
// stopped, so the batch endpoint reports success either way.
assert.equal(
batchStop.value.results[0].ok,
true,
batchStop.value.results[0].error,
`batch stop reported ${JSON.stringify(batchStop.value.results[0])}`,
);
// The recipe recorder's refusals, which are the whole contract a caller can
// rely on without a paid browser: what it will not start on, and that an
// idle recorder answers rather than throwing. The capture itself is a paid
// browser feature and is tested where that feature lives.
assert.deepEqual(await app.invoke("get_recipe_recording"), {
profile_id: null,
steps: [],
recording: false,
});
assert.deepEqual(await app.invoke("stop_recipe_recording"), {
profile_id: null,
steps: [],
recording: false,
});
assert.match(
await app.invokeError("start_recipe_recording", {
profileId: "00000000-0000-0000-0000-000000000000",
}),
/PROFILE_NOT_FOUND/,
);
assert.match(
await app.invokeError("start_recipe_recording", {
profileId: profile.id,
}),
/PROFILE_NOT_RUNNING/,
"a recording needs a live browser to attach to",
);
// Export and import: a profile is moved to another machine as one archive
// and comes back as a NEW profile, owing nothing to the machine that wrote
// it. Exercised here because this is the suite with a real profile
// directory to carry.
const exportPath = path.join(app.dataRoot, "exported.donutprofile");
const exported = await app.invoke("export_profile", {
profileId: profile.id,
destination: exportPath,
includeData: true,
});
assert.equal(exported.profile_name, profile.name);
assert.equal(exported.browser, "wayfern");
assert.ok((await stat(exportPath)).size > 0);
const archivePreview = await app.invoke("preview_profile_archive", {
path: exportPath,
});
assert.equal(archivePreview.manifest.profile_name, profile.name);
assert.deepEqual(archivePreview.tags, []);
const importedProfile = await app.invoke("import_profile_archive", {
path: exportPath,
});
assert.notEqual(importedProfile.id, profile.id);
assert.equal(importedProfile.version, profile.version);
assert.equal(
importedProfile.process_id,
null,
"an imported profile is not running on this machine",
);
assert.equal(
importedProfile.proxy_id ?? null,
null,
"a proxy id belongs to the machine that assigned it",
);
assert.equal(
importedProfile.wayfern_config.identity_id,
profile.wayfern_config.identity_id,
"the device travels: the same identity rebuilds the same browser",
);
// Twice from one archive gives two profiles, under distinct names.
const importedAgain = await app.invoke("import_profile_archive", {
path: exportPath,
});
assert.notEqual(importedAgain.id, importedProfile.id);
assert.notEqual(importedAgain.name, importedProfile.name);
assert.match(
await app.invokeError("preview_profile_archive", {
path: path.join(app.dataRoot, "not-an-archive"),
}),
/PROFILE_IMPORT_FAILED/,
);
for (const created of [importedProfile, importedAgain]) {
await app.invoke("delete_profile", {
profileId: created.id,
permanent: true,
});
}
// A temporary profile: created over REST for one run, gone once its
// browser stops. Nothing else in the app removes it, so this is the
// whole contract an automation client depends on.
const temporary = await request(`${base}/v1/profiles`, {
method: "POST",
token: saved.api_token,
body: {
name: "Temporary Run",
browser: "wayfern",
version: prepared.version,
temporary: true,
},
});
assert.equal(
temporary.response.status,
200,
JSON.stringify(temporary.value),
);
assert.equal(temporary.value.profile.temporary, true);
assert.equal(
temporary.value.profile.ephemeral,
true,
"a temporary profile keeps its browsing data in memory only",
);
const temporaryId = temporary.value.profile.id;
const temporaryRun = await request(
`${base}/v1/profiles/${temporaryId}/run`,
{
method: "POST",
token: saved.api_token,
body: { url: `${fixtureUrl}/temporary`, headless: true },
},
);
assert.equal(
temporaryRun.response.status,
200,
JSON.stringify(temporaryRun.value),
);
const temporaryPid = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === temporaryId,
)?.process_id;
assert.ok(
temporaryPid,
"the temporary profile must report the browser it started",
);
await request(`${base}/v1/profiles/${temporaryId}/kill`, {
method: "POST",
token: saved.api_token,
});
await waitForProcessExit(app, temporaryPid);
await app.waitFor(
async () =>
!(await app.invoke("list_browser_profiles")).some(
(item) => item.id === temporaryId,
),
{ description: "the temporary profile to delete itself" },
);
assert.deepEqual(
(await app.invoke("list_trashed_profiles")).filter(
(entry) => entry.id === temporaryId,
),
[],
"a disposable profile must not land in the trash",
);
await app.invoke("stop_api_server");
@@ -666,12 +1047,9 @@ async function launchWithWorker(app, version, name) {
// order (app closed first, so nothing is left to reap anything).
test("a proxy worker dies with its browser, with and without the app running", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernPath = defaultWayfernPath(
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-worker-lifecycle", {
seedVersionCache: localWayfernVersion ?? false,
// Let the app run the real acceptance flow below; the pre-seeded marker is
@@ -766,12 +1144,9 @@ test("a proxy worker dies with its browser, with and without the app running", a
// second profile broke the extension in every browser already running.
test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernPath = defaultWayfernPath(
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-extensions", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
@@ -934,3 +1309,215 @@ test("an assigned extension group reaches Wayfern and each profile stages its ow
await app.close();
}
});
/// The browser's remote-debugging port, read off its own command line: an
/// interactive launch does not hand the port back the way an API run does.
function debuggingPortOf(pid) {
const command = execFileSync(
"ps",
["-ww", "-o", "command=", "-p", String(pid)],
{ encoding: "utf8" },
);
const match = command.match(/--remote-debugging-port=(\d+)/);
assert.ok(match, `no debugging port on the command line: ${command}`);
return { port: Number(match[1]), command };
}
async function targetUrls(port) {
const targets = await fetch(`http://127.0.0.1:${port}/json`).then((r) =>
r.json(),
);
return targets
.filter((target) => target.type === "page")
.map((target) => target.url);
}
test("an interactive launch continues the last session once the identity travels at launch", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernVersion = cachedFixtureVersion(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const app = appFromEnvironment("browser-session", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
});
let browserPid;
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
// The browser itself refuses to start until its terms marker exists, and
// only its own acceptance run writes one it recognises.
await app.invoke("accept_wayfern_terms");
const major = Number.parseInt(prepared.version.split(".")[0], 10);
if (major < 152) {
// Older builds take no launch identity, so Donut starts them on a fresh
// tab and there is nothing to continue.
console.log(
`[donut-e2e] Wayfern ${prepared.version} takes no launch identity; session restore is off by design, skipping the restore assertions`,
);
return;
}
const profile = await createRealProfile(
app,
prepared.version,
"Session Restore",
);
// A launch identity needs the exit's timezone; the geoip match writes it.
await app.invoke("download_geoip_database");
await app.invoke("match_profile_fingerprint_to_exit", {
profileId: profile.id,
exitIp: "8.8.8.8",
});
const stored = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
const location = JSON.parse(stored.wayfern_config.location);
assert.equal(typeof location.timezone, "string");
const userDataDir = path.join(
app.dataRoot,
"data",
"profiles",
profile.id,
"profile",
);
const launch = async (url) => {
const current = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
const launched = await app.invoke("launch_browser_profile", {
profile: current,
url,
});
assert.ok(launched.process_id);
browserPid = launched.process_id;
return launched;
};
const stop = async () => {
const current = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
await app.invoke("kill_browser_profile", { profile: current });
await waitForProcessExit(app, browserPid);
};
const waitForTargets = async (port, expected) => {
let seen = [];
await app
.waitFor(
async () => {
seen = await targetUrls(port).catch(() => []);
return expected.every((needle) =>
seen.some((url) => url.includes(needle)),
);
},
{ timeoutMs: 30_000, description: `targets ${expected.join(", ")}` },
)
.catch(() => {
// The URLs it did see are the whole diagnosis: a restore that
// dropped one tab looks identical to one that never ran.
assert.fail(
`waiting for ${expected.join(", ")} but the browser had ${
seen.length ? seen.join(", ") : "no page targets"
}`,
);
});
};
// First session: two tabs.
const first = await launch(`${fixtureUrl}/session-a`);
const { port: firstPort, command } = debuggingPortOf(first.process_id);
assert.match(command, /--restore-last-session/);
assert.match(command, /--wayfern-identity-file=/);
const identityFile = JSON.parse(
await readFile(path.join(userDataDir, "wayfern-identity.json"), "utf8"),
);
assert.equal(identityFile.identityId, stored.wayfern_config.identity_id);
assert.equal(identityFile.timezone, location.timezone);
// No claimed OS means the host, which is what an omitted operatingSystem
// means over CDP as well; the document has to spell it out.
assert.equal(
identityFile.operatingSystem,
stored.wayfern_config.os ?? currentHostOs(),
);
await waitForTargets(firstPort, ["/session-a"]);
await app.invoke("open_url_with_profile", {
profileId: profile.id,
url: `${fixtureUrl}/session-b`,
});
await waitForTargets(firstPort, ["/session-a", "/session-b"]);
await stop();
const preferences = JSON.parse(
await readFile(path.join(userDataDir, "Default", "Preferences"), "utf8"),
);
assert.equal(
preferences.profile?.exit_type,
"Normal",
"a stop must run the browser's own shutdown so the session is written",
);
// Second session: both tabs come back, and the launch URL gets its own
// tab instead of replacing a restored one.
const second = await launch(`${fixtureUrl}/session-c`);
const { port: secondPort } = debuggingPortOf(second.process_id);
await waitForTargets(secondPort, [
"/session-a",
"/session-b",
"/session-c",
]);
// A browser that died hard still comes back, with no bubble to answer.
// Chromium commits a tab change to the session file on a short delay, so a
// kill in the same second loses the newest tab through no fault of the
// launcher; wait for the write before pulling the plug.
await new Promise((resolve) => setTimeout(resolve, 6_000));
process.kill(second.process_id, "SIGKILL");
await waitForProcessExit(app, second.process_id);
await app.waitFor(
async () =>
!(await app.invoke("check_browser_status", {
profile: (
await app.invoke("list_browser_profiles")
).find((p) => p.id === profile.id),
})),
{ description: "the app to notice the killed browser" },
);
const third = await launch(null);
const { port: thirdPort } = debuggingPortOf(third.process_id);
await waitForTargets(thirdPort, ["/session-a", "/session-b", "/session-c"]);
await stop();
// Switched off, the profile starts on a fresh tab.
await app.invoke("update_wayfern_config", {
profileId: profile.id,
config: { ...stored.wayfern_config, restore_session: false },
});
const fourth = await launch(`${fixtureUrl}/session-d`);
const { port: fourthPort, command: fourthCommand } = debuggingPortOf(
fourth.process_id,
);
assert.doesNotMatch(fourthCommand, /--restore-last-session/);
await waitForTargets(fourthPort, ["/session-d"]);
assert.ok(
!(await targetUrls(fourthPort)).some((url) => url.includes("/session-a")),
"a profile with restore switched off must not reopen the old session",
);
await stop();
await app.invoke("delete_profile", { profileId: profile.id });
} catch (error) {
await app.capture("failure");
throw error;
} finally {
if (app.session && browserPid && processExists(browserPid)) {
const profile = (
await app.invoke("list_browser_profiles").catch(() => [])
).find((item) => item.process_id === browserPid);
if (profile)
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
}
await app.close();
}
});
+8 -1
View File
@@ -24,8 +24,15 @@ function commandHasExecutableEvidence(source, command) {
.split("::")
.at(-1)
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Every helper that actually CALLS the command counts. This list is the gate's
// blind spot: a suite can strengthen its assertions by routing through a new
// helper and silently lose the evidence, which is exactly what happened when
// `assertContract` replaced eight `assert.ok(await invokeContract(...))` calls
//, the assertions got stronger and the gate went red. `assertCommandErrorCode`
// joined the list when the local-MCP tests moved to asserting refusal codes.
return new RegExp(
`(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
`(?:invoke|invokeError)\\(\\s*["']${name}["']` +
`|(?:invokeContract|assertContract|assertCommandErrorCode)\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
).test(source);
}
+661
View File
@@ -12,6 +12,8 @@ import { DatabaseSync } from "node:sqlite";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import {
CRX_EXTENSION_NAME,
CRX_EXTENSION_VERSION,
extensionIconPngBase64,
extensionZipBase64,
wireGuardFixture,
@@ -103,6 +105,11 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
});
assert.equal(parsedImport.imported_count, 1);
assert.deepEqual(
await app.invoke("get_proxy_check_history", { proxyId: proxy.id }),
[],
"a proxy nobody has checked has no trail",
);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
@@ -113,6 +120,49 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
// A check that failed is still a check, and it is recorded as one. The
// proxy above was edited to SOCKS5 on a closed port, so the UDP probe
// could not reach it: the honest verdict is "unknown", never "no".
const trail = await app.invoke("get_proxy_check_history", {
proxyId: proxy.id,
});
assert.equal(trail.length, 1);
assert.equal(trail[0].ok, false);
assert.equal(trail[0].ip, null);
assert.equal(trail[0].udp, "unknown");
assert.ok(
typeof trail[0].latency_ms === "number" && trail[0].latency_ms >= 0,
);
assert.ok(trail[0].timestamp > 0);
// Deleting the proxy takes the trail with it; it names exit addresses.
const doomed = await app.invoke("create_stored_proxy", {
name: "Trail Owner",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
await app.invokeError("check_proxy_validity", {
proxyId: doomed.id,
proxySettings: null,
});
const doomedTrail = await app.invoke("get_proxy_check_history", {
proxyId: doomed.id,
});
assert.equal(doomedTrail.length, 1);
// An HTTP proxy cannot carry a datagram at all, which is answered from
// the protocol without dialling anything.
assert.equal(doomedTrail[0].udp, "no");
await app.invoke("delete_stored_proxy", { proxyId: doomed.id });
assert.deepEqual(
await app.invoke("get_proxy_check_history", { proxyId: doomed.id }),
[],
);
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
// uses this to tell the user WHICH part of their setup is unsupported
// instead of implying they mistyped, so the reason must survive the IPC hop.
@@ -661,6 +711,103 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass
"importing a folder must never move or consume the user's copy of it",
);
// Importing from a link. The fixture server answers with a real CRX3
// container, so this proves the importer unwraps the signed container to
// the ZIP the store keeps rather than filing the container itself.
const fixtureBase = process.env.DONUT_E2E_FIXTURE_URL;
assert.ok(fixtureBase, "the fixture server URL has to reach the suite");
const fetched = await app.invoke("fetch_extension_from_url", {
url: `${fixtureBase}/extension.crx`,
});
assert.equal(fetched.name, CRX_EXTENSION_NAME);
assert.equal(fetched.version, CRX_EXTENSION_VERSION);
assert.equal(fetched.from_web_store, false);
assert.equal(
fetched.file_name,
"extension.zip",
"the stored payload is the ZIP, so it must not still be called a .crx",
);
assert.deepEqual(
fetched.file_data.slice(0, 4),
[0x50, 0x4b, 0x03, 0x04],
"the CRX3 header has to be stripped, not stored",
);
const fromLink = await app.invoke("add_extension", {
name: "Overridden By The Manifest",
fileName: fetched.file_name,
fileData: fetched.file_data,
});
assert.equal(fromLink.name, CRX_EXTENSION_NAME);
assert.equal(fromLink.version, CRX_EXTENSION_VERSION);
assert.equal(fromLink.source_kind, "archive");
assert.equal(fromLink.file_type, "zip");
// Assignable like any other extension: the link is only how it arrived.
const linkGroup = await app.invoke("create_extension_group", {
name: "Downloaded Extensions",
});
assert.deepEqual(
(
await app.invoke("add_extension_to_group", {
groupId: linkGroup.id,
extensionId: fromLink.id,
})
).extension_ids,
[fromLink.id],
);
await app.invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: linkGroup.id,
});
assert.equal(
(
await app.invoke("get_extension_group_for_profile", {
profileId: profile.id,
})
).id,
linkGroup.id,
);
// A body that is not an extension is refused with the code, and nothing
// is stored for it.
assert.match(
await app.invokeError("fetch_extension_from_url", {
url: `${fixtureBase}/not-an-extension.zip`,
}),
/EXTENSION_NOT_AN_EXTENSION/,
);
for (const rejected of [
"not a link at all",
"https://example.invalid/downloads",
"https://example.invalid/installer.exe",
// 32 characters, but an extension id only uses a-p.
"abcdefghijklmnopabcdefghijklmnoz",
// Plain HTTP off loopback never crosses the wire, whatever it points at.
"http://files.example.invalid/pack.crx",
]) {
assert.match(
await app.invokeError("fetch_extension_from_url", { url: rejected }),
/EXTENSION_URL_INVALID/,
rejected,
);
}
assert.match(
await app.invokeError("fetch_extension_from_url", {
url: `${fixtureBase}/absent-extension.crx`,
}),
/EXTENSION_NOT_AN_EXTENSION|EXTENSION_DOWNLOAD_FAILED/,
);
assert.equal((await app.invoke("list_extensions")).length, 1);
await app.invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: null,
});
await app.invoke("delete_extension_group", { groupId: linkGroup.id });
await app.invoke("delete_extension", { extensionId: fromLink.id });
assert.deepEqual(await app.invoke("list_extensions"), []);
const vpn = await app.invoke("create_vpn_config_manual", {
name: "E2E WireGuard",
vpnType: "WireGuard",
@@ -926,3 +1073,517 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
});
});
});
test("deleted profiles land in the trash and come back intact on restore", async () => {
await withApp("entities-trash", async (app) => {
const initialSettings = await app.invoke("get_app_settings");
assert.equal(initialSettings.trash_retention_days, 30);
const savedSettings = await app.invoke("save_app_settings", {
settings: { ...initialSettings, trash_retention_days: 7 },
});
assert.equal(savedSettings.trash_retention_days, 7);
assert.equal(
(await app.invoke("get_app_settings")).trash_retention_days,
7,
);
// Out-of-range values are clamped, never rejected.
const clamped = await app.invoke("save_app_settings", {
settings: { ...savedSettings, trash_retention_days: 9000 },
});
assert.equal(clamped.trash_retention_days, 365);
await app.invoke("save_app_settings", {
settings: { ...clamped, trash_retention_days: 7 },
});
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
const group = await app.invoke("create_profile_group", {
name: "Trash Group",
});
const created = await app.invoke("create_browser_profile_new", {
name: "Recoverable",
browserStr: "wayfern",
version: "150.0.7871.100",
releaseType: "stable",
proxyId: null,
vpnId: null,
wayfernConfig: {
fingerprint: "{}",
identity_id: "identity-e2e",
identity_overrides: JSON.stringify({ userAgent: "Custom UA" }),
location: JSON.stringify({
timezone: "Europe/Berlin",
language: "de-DE",
}),
},
groupId: group.id,
ephemeral: false,
dnsBlocklist: null,
launchHook: null,
});
await app.invoke("update_profile_tags", {
profileId: created.id,
tags: ["shop", "eu"],
});
const before = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === created.id,
);
assert.equal(before.wayfern_config.identity_id, "identity-e2e");
assert.deepEqual(before.tags, ["shop", "eu"]);
assert.equal(before.group_id, group.id);
// Real files to carry through the move, plus a cache the trash must drop.
const profilesDir = path.join(app.dataRoot, "data", "profiles");
const dataDir = path.join(profilesDir, created.id, "profile");
await mkdir(path.join(dataDir, "Default"), { recursive: true });
await writeFile(path.join(dataDir, "Default", "Cookies"), "cookie-db");
await mkdir(path.join(dataDir, "Cache"), { recursive: true });
await writeFile(path.join(dataDir, "Cache", "blob"), "cache-bytes");
await app.invoke("delete_profile", { profileId: created.id });
assert.equal(
(await app.invoke("list_browser_profiles")).some(
(item) => item.id === created.id,
),
false,
);
const trashed = await app.invoke("list_trashed_profiles");
assert.equal(trashed.length, 1);
assert.equal(trashed[0].id, created.id);
assert.equal(trashed[0].name, "Recoverable");
assert.equal(trashed[0].browser, "wayfern");
assert.equal(trashed[0].version, "150.0.7871.100");
assert.equal(trashed[0].group_id, group.id);
assert.equal(trashed[0].password_protected, false);
assert.equal(
trashed[0].expires_at - trashed[0].deleted_at,
7 * 24 * 60 * 60,
);
assert.ok(trashed[0].size_bytes > 0);
const trashDir = path.join(app.dataRoot, "data", "trash");
const entryDir = path.join(trashDir, created.id);
assert.ok(existsSync(path.join(entryDir, "profile.json")));
assert.ok(existsSync(path.join(entryDir, "manifest.json")));
assert.equal(
await readFile(
path.join(entryDir, "profile", "Default", "Cookies"),
"utf8",
),
"cookie-db",
);
assert.equal(
existsSync(path.join(entryDir, "profile", "Cache")),
false,
"caches are pruned before the move",
);
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
// A live profile carrying the same name pushes the restored one to a suffix.
const namesake = await createProfile(app, "Recoverable");
const restored = await app.invoke("restore_trashed_profile", {
profileId: created.id,
});
assert.equal(restored.id, created.id);
assert.equal(restored.name, "Recoverable (restored)");
assert.deepEqual(restored.wayfern_config, before.wayfern_config);
assert.deepEqual(restored.tags, before.tags);
assert.equal(restored.group_id, group.id);
assert.ok(restored.updated_at >= (before.updated_at ?? 0));
const live = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === created.id,
);
assert.deepEqual(live.wayfern_config, before.wayfern_config);
assert.deepEqual(live.tags, before.tags);
assert.equal(
await readFile(
path.join(profilesDir, created.id, "profile", "Default", "Cookies"),
"utf8",
),
"cookie-db",
);
assert.equal(existsSync(entryDir), false);
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.match(
await app.invokeError("restore_trashed_profile", {
profileId: created.id,
}),
/TRASH_ENTRY_NOT_FOUND/,
);
// A group deleted while the profile sat in the trash is not resurrected.
await app.invoke("delete_profile", { profileId: created.id });
await app.invoke("delete_profile_group", { groupId: group.id });
const restoredWithoutGroup = await app.invoke("restore_trashed_profile", {
profileId: created.id,
});
assert.equal(restoredWithoutGroup.id, created.id);
assert.equal(restoredWithoutGroup.group_id, null);
assert.deepEqual(
restoredWithoutGroup.wayfern_config,
before.wayfern_config,
);
// Delete again, then purge: gone for good.
await app.invoke("delete_profile", { profileId: created.id });
assert.equal((await app.invoke("list_trashed_profiles")).length, 1);
await app.invoke("purge_trashed_profile", { profileId: created.id });
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.equal(existsSync(entryDir), false);
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
assert.match(
await app.invokeError("purge_trashed_profile", {
profileId: created.id,
}),
/TRASH_ENTRY_NOT_FOUND/,
);
// An explicit permanent delete never lands in the trash.
const doomed = await createProfile(app, "Doomed");
await app.invoke("delete_profile", {
profileId: doomed.id,
permanent: true,
});
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.equal(existsSync(path.join(trashDir, doomed.id)), false);
assert.equal(existsSync(path.join(profilesDir, doomed.id)), false);
// A bulk delete trashes every profile; emptying the trash clears them all.
const bulkA = await createProfile(app, "Bulk A");
const bulkB = await createProfile(app, "Bulk B");
await app.invoke("delete_selected_profiles", {
profileIds: [bulkA.id, bulkB.id],
});
assert.deepEqual(
(await app.invoke("list_trashed_profiles"))
.map((entry) => entry.name)
.sort(),
["Bulk A", "Bulk B"],
);
assert.equal(await app.invoke("empty_trash"), 2);
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
assert.equal(existsSync(path.join(trashDir, bulkA.id)), false);
// Restore refuses an entry whose id a live profile already carries.
const conflictDir = path.join(trashDir, namesake.id);
await mkdir(conflictDir, { recursive: true });
await writeFile(
path.join(conflictDir, "profile.json"),
JSON.stringify(namesake),
);
await writeFile(
path.join(conflictDir, "manifest.json"),
JSON.stringify({
deleted_at: 1,
expires_at: 4_102_444_800,
size_bytes: 0,
original_name: namesake.name,
}),
);
assert.match(
await app.invokeError("restore_trashed_profile", {
profileId: namesake.id,
}),
/TRASH_RESTORE_CONFLICT/,
);
await app.invoke("purge_trashed_profile", { profileId: namesake.id });
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
await app.invoke("delete_profile", {
profileId: namesake.id,
permanent: true,
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
});
});
test("proxies distribute one to one, and group bookmarks reach the profile's Bookmarks file", async () => {
await withApp("entities-distribution-bookmarks", async (app) => {
const profiles = [];
for (const name of ["Fleet 1", "Fleet 2", "Fleet 3", "Fleet 4"]) {
profiles.push(await createProfile(app, name));
}
const proxies = [];
for (const [index, name] of ["Exit A", "Exit B", "Exit C"].entries()) {
proxies.push(
await app.invoke("create_stored_proxy", {
name,
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9001 + index,
username: null,
password: null,
},
}),
);
}
const profileIds = profiles.map((profile) => profile.id);
const proxyIds = proxies.map((proxy) => proxy.id);
// Four profiles, three proxies: three pairs and one profile left alone.
// The fourth must NEVER wrap around onto the first proxy.
const plan = await app.invoke("plan_proxy_distribution", {
profileIds,
proxyIds,
allowSharing: false,
});
assert.deepEqual(
plan.pairs,
proxyIds.map((proxyId, index) => ({
profile_id: profileIds[index],
proxy_id: proxyId,
})),
);
assert.deepEqual(plan.unpaired_profile_ids, [profileIds[3]]);
assert.deepEqual(plan.unused_proxy_ids, []);
assert.deepEqual(plan.shared_proxy_ids, []);
assert.deepEqual(plan.running_profile_ids, []);
const results = await app.invoke("distribute_proxies_to_profiles", {
pairs: plan.pairs,
});
assert.equal(results.length, 3);
assert.ok(results.every((result) => result.ok));
const afterDistribution = await app.invoke("list_browser_profiles");
const proxyOf = (id) =>
afterDistribution.find((profile) => profile.id === id).proxy_id;
assert.equal(proxyOf(profileIds[0]), proxyIds[0]);
assert.equal(proxyOf(profileIds[1]), proxyIds[1]);
assert.equal(proxyOf(profileIds[2]), proxyIds[2]);
assert.equal(proxyOf(profileIds[3]) ?? null, null);
// A proxy someone else holds is refused by default and only offered once
// the caller asks for sharing explicitly.
const strict = await app.invoke("plan_proxy_distribution", {
profileIds: [profileIds[3]],
proxyIds: [proxyIds[0]],
allowSharing: false,
});
assert.deepEqual(strict.pairs, []);
assert.deepEqual(strict.shared_proxy_ids, [proxyIds[0]]);
assert.deepEqual(strict.unpaired_profile_ids, [profileIds[3]]);
const permissive = await app.invoke("plan_proxy_distribution", {
profileIds: [profileIds[3]],
proxyIds: [proxyIds[0]],
allowSharing: true,
});
assert.deepEqual(permissive.pairs, [
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
]);
// Per-profile failures never break the batch: one good pair still lands.
const mixed = await app.invoke("distribute_proxies_to_profiles", {
pairs: [
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
{ profile_id: profileIds[3], proxy_id: proxyIds[1] },
{
profile_id: profileIds[0],
proxy_id: "00000000-0000-4000-8000-000000000000",
},
],
});
assert.equal(mixed[0].ok, true);
assert.equal(mixed[1].ok, false);
assert.match(mixed[1].error, /PROFILE_PAIRED_TWICE/);
assert.equal(mixed[2].ok, false);
assert.match(mixed[2].error, /PROXY_NOT_FOUND/);
assert.equal(
(await app.invoke("list_browser_profiles")).find(
(profile) => profile.id === profileIds[3],
).proxy_id,
proxyIds[0],
);
// --- group bookmarks ---
const group = await app.invoke("create_profile_group", {
name: "Client Sites",
});
assert.deepEqual(
await app.invoke("get_group_bookmarks", { groupId: group.id }),
[],
);
const refused = await app.invokeError("set_group_bookmarks", {
groupId: group.id,
bookmarks: [{ title: "Keys", url: "file:///etc/passwd", folder: null }],
});
assert.match(refused, /URL_SCHEME_NOT_ALLOWED/);
const unnamed = await app.invokeError("set_group_bookmarks", {
groupId: group.id,
bookmarks: [{ title: " ", url: "https://ok.example", folder: null }],
});
assert.match(unnamed, /NAME_CANNOT_BE_EMPTY/);
const saved = await app.invoke("set_group_bookmarks", {
groupId: group.id,
bookmarks: [
{ title: "Support", url: "https://support.example", folder: null },
{ title: "Console", url: "https://console.example", folder: "Ops" },
],
});
assert.equal(saved.length, 2);
assert.equal(saved[1].folder, "Ops");
assert.equal(
(await app.invoke("get_groups_with_profile_counts")).find(
(item) => item.id === group.id,
).bookmark_count,
2,
);
const target = profiles[0];
await app.invoke("assign_profiles_to_group", {
profileIds: [target.id],
groupId: group.id,
});
// Seed the profile's own Bookmarks file the way a real Chromium session
// would have left it, so the write has something of the user's to preserve.
const bookmarksFile = path.join(
app.dataRoot,
"data",
"profiles",
target.id,
"profile",
"Default",
"Bookmarks",
);
await mkdir(path.dirname(bookmarksFile), { recursive: true });
const permanentFolder = (id, name) => ({
children: [],
date_added: "13300000000000000",
date_modified: "13300000000000000",
guid: `0000000${id}-0000-4000-8000-000000000000`,
id: String(id),
name,
type: "folder",
});
await writeFile(
bookmarksFile,
JSON.stringify({
checksum: "0".repeat(32),
roots: {
bookmark_bar: {
...permanentFolder(1, "Bookmarks bar"),
children: [
{
date_added: "13300000000000000",
guid: "aaaaaaaa-0000-4000-8000-000000000000",
id: "9",
name: "My Bank",
type: "url",
url: "https://bank.example/",
},
],
},
other: permanentFolder(2, "Other bookmarks"),
synced: permanentFolder(3, "Mobile bookmarks"),
},
sync_metadata: "Zm9v",
version: 1,
}),
);
const readBookmarks = async () =>
JSON.parse(await readFile(bookmarksFile, "utf8"));
const managedFolderOf = (document) =>
document.roots.bookmark_bar.children.filter(
(child) =>
child.type === "folder" &&
child.meta_info?.donut_managed_group_bookmarks === "1",
);
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
true,
);
let document = await readBookmarks();
let managed = managedFolderOf(document);
assert.equal(managed.length, 1);
assert.equal(managed[0].name, "Donut Group Bookmarks");
assert.deepEqual(
managed[0].children.map((child) => child.name),
["Support", "Ops"],
);
assert.deepEqual(
managed[0].children[1].children.map((child) => child.url),
["https://console.example"],
);
// The user's own bookmark, the other roots and Chromium's opaque state all
// survive; only the checksum is rewritten to describe the new tree.
assert.equal(document.roots.bookmark_bar.children[0].name, "My Bank");
assert.equal(document.sync_metadata, "Zm9v");
assert.equal(document.version, 1);
assert.notEqual(document.checksum, "0".repeat(32));
assert.match(document.checksum, /^[0-9a-f]{32}$/);
// Applying again is a no-op: the folder is not duplicated and the file is
// not even rewritten.
const firstWrite = await readFile(bookmarksFile, "utf8");
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
false,
);
assert.equal(await readFile(bookmarksFile, "utf8"), firstWrite);
// Removing a bookmark from the group removes it from the folder next time.
await app.invoke("set_group_bookmarks", {
groupId: group.id,
bookmarks: [
{ title: "Support", url: "https://support.example", folder: null },
],
});
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
true,
);
document = await readBookmarks();
managed = managedFolderOf(document);
assert.equal(managed.length, 1);
assert.deepEqual(
managed[0].children.map((child) => child.name),
["Support"],
);
// Emptying the group takes the whole folder away and leaves the user's own.
await app.invoke("set_group_bookmarks", {
groupId: group.id,
bookmarks: [],
});
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: target.id,
}),
true,
);
document = await readBookmarks();
assert.deepEqual(managedFolderOf(document), []);
assert.deepEqual(
document.roots.bookmark_bar.children.map((child) => child.name),
["My Bank"],
);
// A profile in no group is left entirely alone.
assert.equal(
await app.invoke("apply_group_bookmarks_to_profile", {
profileId: profileIds[1],
}),
false,
);
await app.invoke("delete_selected_profiles", { profileIds });
await app.invoke("delete_profile_group", { groupId: group.id });
for (const proxy of proxies) {
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
}
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+123 -52
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
@@ -270,57 +271,55 @@ async function createProfileThroughUi(app, groupName) {
return profiles.find((profile) => profile.name === "Visible Network Profile");
}
async function assignNetworkThroughUi(app, profileName, currentName, newName) {
const trigger = await app.execute(
/**
* The popover trigger sitting in a named COLUMN of a profile's row.
*
* Anchored to the column, never to the label the cell happens to show. Both
* callers used to search the whole row for the cell's current text, "Default"
* for the extension group, "Not selected" for the network, and both of those
* strings had long since become "None" in the app. Neither string exists
* anywhere in src/ any more, so the assertions failed against a UI that was
* working correctly, and with no E2E in CI nothing reported it.
*
* Matching on "None" instead would only move the problem: Proxy / VPN and EXT
* render the identical text, so a row-wide search would pick whichever came
* first in the DOM. The column is the thing that actually identifies the
* control, so that is what this matches on.
*
* A renamed header returns the header list rather than null, so the failure
* says which column went missing instead of "was not visible".
*/
async function columnTrigger(app, profileName, header) {
return app.execute(
`
const row = [...document.querySelectorAll("tr")].find((candidate) =>
const row = [...document.querySelectorAll("tbody 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;
if (!row) return null;
const headers = [
...(row.closest("table")?.querySelectorAll("thead th") ?? []),
].map((cell) => (cell.innerText || cell.textContent || "").trim());
const index = headers.indexOf(arguments[1]);
if (index < 0) return "MISSING_COLUMN:" + headers.join(" | ");
return (
row.children[index]?.querySelector(
'[aria-haspopup="dialog"], button',
) ?? 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` },
[profileName, header],
);
}
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`);
async function assignThroughUi(app, profileName, header, newName, what) {
const trigger = await columnTrigger(app, profileName, header);
if (typeof trigger === "string") {
assert.fail(
`The "${header}" column is gone; the table now has: ` +
trigger.replace("MISSING_COLUMN:", ""),
);
}
assert.ok(trigger, `${what} selector for ${profileName} was not visible`);
await app.session.click(trigger);
await app.clickText(newName, { exact: false, roles: ["option"] });
await app.waitFor(
@@ -332,10 +331,16 @@ async function assignExtensionGroupThroughUi(
`,
[newName],
),
{ description: `${newName} extension picker to unmount` },
{ description: `${newName} ${what.toLowerCase()} picker to unmount` },
);
}
const assignNetworkThroughUi = (app, profileName, newName) =>
assignThroughUi(app, profileName, "Proxy / VPN", newName, "Network");
const assignExtensionGroupThroughUi = (app, profileName, newName) =>
assignThroughUi(app, profileName, "EXT", newName, "Extension");
async function runProfile(_app, base, token, profileId, url) {
const launched = await request(`${base}/v1/profiles/${profileId}/run`, {
method: "POST",
@@ -808,7 +813,6 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
await assignExtensionGroupThroughUi(
app,
profile.name,
"Default",
extensionEntities.group.name,
);
await app.waitFor(
@@ -824,6 +828,23 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
name: "Residential SOCKS5",
proxySettings: socksSettings,
});
// The exit's ISP and timezone are read from the MaxMind databases on this
// machine, never from an outside lookup service, so they have to actually
// be in place before a check can report them. The create-profile dialog
// starts that download in the background; this waits for it rather than
// racing it.
await app.invoke("download_geoip_database");
await app.waitFor(
async () =>
existsSync(path.join(app.dataRoot, "cache", "GeoLite2-City.mmdb")) &&
existsSync(path.join(app.dataRoot, "cache", "GeoLite2-ASN.mmdb")),
{
description: "the local MaxMind city and ASN databases",
timeoutMs: 180_000,
},
);
const [httpCheck, socksCheck] = await Promise.all([
app.invoke("check_proxy_validity", {
proxyId: httpProxy.id,
@@ -839,12 +860,62 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
assert.ok(isIP(httpCheck.ip));
assert.ok(isIP(socksCheck.ip));
await assignNetworkThroughUi(
app,
profile.name,
"Not selected",
httpProxy.name,
// An HTTP proxy tunnels TCP with CONNECT and has no datagram command, so
// the verdict follows from the protocol and is never a probe result.
assert.equal(httpCheck.udp, "no");
// The SOCKS5 proxy answered the exit lookup, so the UDP probe reached it
// too: the verdict has to be a real answer, never "unknown". Which answer
// is the provider's to decide.
assert.ok(
["yes", "no"].includes(socksCheck.udp),
`a reachable SOCKS5 proxy must give a definite UDP verdict, got ${socksCheck.udp}`,
);
for (const [label, check] of [
["http", httpCheck],
["socks5", socksCheck],
]) {
assert.ok(
typeof check.latency_ms === "number" && check.latency_ms > 0,
`${label} check has to report how long it took`,
);
// Read out of the local MaxMind databases: the exit address is never
// handed to an outside lookup service to learn these.
assert.ok(
typeof check.isp === "string" && check.isp.trim().length > 0,
`${label} check has to name the exit's ISP, got ${JSON.stringify(check.isp)}`,
);
assert.ok(
typeof check.timezone === "string" && check.timezone.includes("/"),
`${label} check has to report the exit's timezone, got ${JSON.stringify(check.timezone)}`,
);
}
// The trail grows by one line per check, newest first, and carries what
// the receipt carried.
const firstTrail = await app.invoke("get_proxy_check_history", {
proxyId: socksProxy.id,
});
assert.equal(firstTrail.length, 1);
assert.equal(firstTrail[0].ok, true);
assert.equal(firstTrail[0].ip, socksCheck.ip);
assert.equal(firstTrail[0].udp, socksCheck.udp);
assert.equal(firstTrail[0].isp, socksCheck.isp);
await app.invoke("check_proxy_validity", {
proxyId: socksProxy.id,
proxySettings: null,
});
const grownTrail = await app.invoke("get_proxy_check_history", {
proxyId: socksProxy.id,
});
assert.equal(grownTrail.length, 2);
assert.ok(
grownTrail[0].timestamp >= grownTrail[1].timestamp,
"the trail is newest first",
);
await assignNetworkThroughUi(app, profile.name, httpProxy.name);
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).find(
@@ -885,7 +956,7 @@ test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions
activeCdp = null;
await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]);
await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name);
await assignNetworkThroughUi(app, profile.name, vpn.name);
await app.waitFor(
async () =>
(await app.invoke("list_browser_profiles")).find(
+113
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
@@ -196,3 +197,115 @@ test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle"
await app.close();
}
});
test("the data directory can be moved to another folder and the choice survives a restart", async () => {
await withApp(
"smoke-data-root",
async (app) => {
// Every path below is inside this session's own temporary root. The
// real installation is never a source or a destination here.
const defaultRoot = path.join(app.dataRoot, "data");
const pointerFile = path.join(app.dataRoot, "data-root.json");
const destination = path.join(app.root, "moved-donut-data");
const before = await app.invoke("get_data_root_info");
assert.equal(before.active_path, defaultRoot);
assert.equal(before.configured_path, null);
assert.equal(before.restart_required, false);
assert.equal(before.active_path_missing, false);
assert.equal(before.overridden_by_environment, false);
assert.ok(before.file_count > 0, "the seeded settings file is counted");
assert.ok(before.size_bytes > 0, "the directory reports a real size");
assert.equal(typeof before.app_directory_name, "string");
const profile = await app.invoke("create_browser_profile_new", {
name: "Carried Across",
browserStr: "wayfern",
version: "150.0.7871.100",
releaseType: "stable",
proxyId: null,
vpnId: null,
wayfernConfig: { fingerprint: "{}" },
groupId: null,
ephemeral: false,
dnsBlocklist: null,
launchHook: null,
});
// Each refusal is its own code, because each one has a different fix.
assert.match(
await app.invokeError("move_data_root", { destination: defaultRoot }),
/DATA_ROOT_SAME_AS_CURRENT/,
);
assert.match(
await app.invokeError("move_data_root", {
destination: path.join(defaultRoot, "profiles", "elsewhere"),
}),
/DATA_ROOT_DESTINATION_INSIDE_SOURCE/,
);
assert.match(
await app.invokeError("move_data_root", {
destination: "not/absolute",
}),
/DATA_ROOT_DESTINATION_NOT_WRITABLE/,
);
assert.equal(
existsSync(destination),
false,
"a refused move must not create the destination",
);
const moved = await app.invoke("move_data_root", { destination });
assert.equal(moved.configured_path, destination);
assert.equal(moved.restart_required, true);
// The move takes effect at the next start: this process keeps every
// path it resolved when it started.
assert.equal(moved.active_path, defaultRoot);
// Copy, then verify, then delete: the old directory only goes once the
// copy has been proven whole.
assert.equal(existsSync(defaultRoot), false, "the source is removed");
assert.ok(
existsSync(path.join(destination, "settings", "app_settings.json")),
"settings travelled with the move",
);
assert.ok(
existsSync(path.join(destination, "profiles")),
"profiles travelled with the move",
);
// The pointer lives beside the data directory, never inside it, or the
// delete above would have taken it and the next start would forget.
const pointer = JSON.parse(await readFile(pointerFile, "utf8"));
assert.equal(pointer.path, destination);
await app.restart();
const after = await app.invoke("get_data_root_info");
assert.equal(after.active_path, destination);
assert.equal(after.configured_path, destination);
assert.equal(after.restart_required, false);
assert.equal(after.active_path_missing, false);
const profiles = await app.invoke("list_browser_profiles");
assert.ok(
profiles.some((entry) => entry.id === profile.id),
"the moved directory still holds the profile",
);
const settings = await app.invoke("get_app_settings");
assert.equal(settings.onboarding_completed, true);
// Forgetting the choice is the escape hatch for a drive that is gone
// for good; it moves nothing, so it too only lands on the next start.
const cleared = await app.invoke("clear_data_root_choice");
assert.equal(cleared.configured_path, null);
assert.equal(existsSync(pointerFile), false);
await app.restart();
const restored = await app.invoke("get_data_root_info");
assert.equal(restored.active_path, defaultRoot);
assert.equal(restored.configured_path, null);
},
{ seedDownloadedBrowser: true },
);
});
+49 -1
View File
@@ -483,10 +483,54 @@ test("global config sealing and encrypted profile sync reject a wrong password,
"correct password decrypts profile browser file",
);
const emptyProfile = await createProfile(source, "Encrypted Empty Profile");
await source.invoke("set_profile_sync_mode", {
profileId: emptyProfile.id,
syncMode: "Encrypted",
});
await waitFor(
source,
async () =>
(await listRemote(`profiles/${emptyProfile.id}/`)).some(
(object) =>
object.key === `profiles/${emptyProfile.id}/metadata.json`,
),
"empty profile metadata uploaded before rollover",
);
await source.invoke("set_e2e_password", {
password: "rolled encryption password",
});
await source.invoke("rollover_encryption_for_all_entities");
let rollingOver = true;
let manifestDisappeared = false;
await Promise.all([
source.invoke("rollover_encryption_for_all_entities").finally(() => {
rollingOver = false;
}),
(async () => {
while (rollingOver) {
const objects = await listRemote(`profiles/${encryptedProfile.id}/`);
manifestDisappeared ||= !objects.some(
(object) =>
object.key === `profiles/${encryptedProfile.id}/manifest.json`,
);
if (rollingOver) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
})(),
]);
assert.equal(
manifestDisappeared,
false,
"rollover must not let another device interpret a missing manifest as an empty remote profile",
);
assert.ok(
(await listRemote(`profiles/${emptyProfile.id}/`)).some(
(object) => object.key === `profiles/${emptyProfile.id}/manifest.json`,
),
"rollover must publish a manifest even for an empty profile",
);
await waitFor(
source,
async () => {
@@ -555,6 +599,10 @@ test("global config sealing and encrypted profile sync reject a wrong password,
profileId: encryptedProfile.id,
syncMode: "Disabled",
});
await source.invoke("set_profile_sync_mode", {
profileId: emptyProfile.id,
syncMode: "Disabled",
});
await source.invoke("delete_e2e_password");
assert.equal(await source.invoke("check_has_e2e_password"), false);
const missingPassword = await source.invokeError("verify_e2e_password", {
+1027 -5
View File
File diff suppressed because it is too large Load Diff