mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-16 14:45:30 +02:00
refactor: cleanup
This commit is contained in:
+623
-36
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+575
-379
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+123
-52
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user