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
+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();
}
});