import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import net from "node:net"; 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 { cachedFixtureVersion, currentHostOs, prepareWayfern, writeUnpackedExtension, } from "../lib/fixtures.mjs"; const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL; async function request(url, { method = "GET", token, body } = {}) { const response = await fetch(url, { method, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }), }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); let value = null; if (text) { try { value = JSON.parse(text); } catch { value = text; } } return { response, value }; } function processExists(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch { return false; } } function processCommandLine(pid) { assert.ok(Number.isInteger(pid) && pid > 0); if (process.platform === "win32") { return execFileSync( "powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`, ], { encoding: "utf8" }, ); } return execFileSync("ps", ["-ww", "-p", String(pid), "-o", "command="], { encoding: "utf8", }); } async function waitForProcessExit(app, pid) { await app.waitFor(() => !processExists(pid), { timeoutMs: 20_000, description: `Wayfern process ${pid} to exit`, }); } function assertIdleResourceBounds(pid) { if (process.platform === "win32") return; const output = execFileSync("ps", ["-o", "rss=,%cpu=", "-p", String(pid)], { encoding: "utf8", }).trim(); const [rssText, cpuText] = output.split(/\s+/); const rssKiB = Number(rssText); const cpuPercent = Number(cpuText); assert.ok( rssKiB > 0 && rssKiB < 2_000_000, `Wayfern main process RSS is ${rssKiB} KiB`, ); assert.ok( cpuPercent >= 0 && cpuPercent < 200, `Wayfern main process CPU is ${cpuPercent}%`, ); } function realWayfernTermsPath() { if (process.platform === "darwin") { return path.join( os.homedir(), "Library", "Application Support", "Wayfern", "license-accepted", ); } if (process.platform === "win32") { return path.join( process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "Wayfern", "license-accepted", ); } return path.join( process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "Wayfern", "license-accepted", ); } async function snapshotFile(file) { try { const [contents, metadata] = await Promise.all([ readFile(file), stat(file, { bigint: true }), ]); return { exists: true, contents: contents.toString("base64"), size: metadata.size.toString(), mtime: metadata.mtimeNs.toString(), }; } catch (error) { if (error.code === "ENOENT") return { exists: false }; throw error; } } /** * 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, browserStr: "wayfern", version, releaseType: "stable", proxyId: null, vpnId: null, wayfernConfig: { fingerprint, randomize_fingerprint_on_launch: false, geoip: false, }, groupId: null, ephemeral: false, dnsBlocklist: null, launchHook: null, }); } test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and process cleanup", async () => { assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); const realTermsFile = realWayfernTermsPath(); const realTermsBefore = await snapshotFile(realTermsFile); const localWayfernVersion = cachedFixtureVersion( process.env.DONUT_E2E_PROJECT_ROOT, ); const app = appFromEnvironment("browser-wayfern", { seedVersionCache: localWayfernVersion ?? false, wayfernTermsAccepted: false, }); let cdp; let browserPid; try { const prepared = await prepareWayfern( app, process.env.DONUT_E2E_PROJECT_ROOT, ); if (!app.session) await app.start(); assert.equal(await app.invoke("check_wayfern_downloaded"), true); 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", { browserStr: "wayfern", }) ).includes(prepared.version), ); assert.equal( await app.invoke("check_browser_exists", { browserStr: "wayfern", version: prepared.version, }), true, ); assert.deepEqual(await app.invoke("check_missing_binaries"), []); assert.deepEqual(await app.invoke("ensure_all_binaries_exist"), []); assert.deepEqual(await app.invoke("ensure_active_browsers_downloaded"), []); assert.deepEqual(await app.invoke("get_supported_browsers"), ["wayfern"]); assert.equal( await app.invoke("is_browser_supported_on_platform", { browserStr: "wayfern", }), true, ); assert.ok( ( await app.invoke("fetch_browser_versions_cached_first", { browserStr: "wayfern", }) ).some((item) => item.version === prepared.version), ); assert.ok( ( await app.invoke("fetch_browser_versions_with_count_cached_first", { browserStr: "wayfern", }) ).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, prepared.version, ); assert.match( await app.invokeError("cancel_download", { browserStr: "wayfern", version: prepared.version, }), /No active download/, ); const sample = await app.invoke("generate_sample_fingerprint", { browser: "wayfern", version: prepared.version, configJson: JSON.stringify({ geoip: false }), }); const fingerprint = JSON.parse(sample.fingerprint); assert.ok( Object.keys(fingerprint).length >= 10, "Wayfern returned an incomplete fingerprint", ); // A browser with the identity API must hand back the UUID the device was // 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( typeof sample.identity_id === "string", 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 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, "a created profile must carry the identity its device came from", ); assert.equal( profile.wayfern_config.fingerprint === undefined, identityCapable, "an identity-backed profile must store no device payload", ); if (!identityCapable) { assert.ok( Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10, ); } assert.equal(await app.invoke("check_missing_geoip_database"), true); assert.equal(await app.invoke("is_geoip_database_available"), false); 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. It sits under the // collapsed advanced options. await app.clickSelector('[aria-label="Profiles"]'); await app.clickText("New"); await app.clickText(en.createProfile.advancedOptions, { roles: ["button"], }); 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, }); await app.invoke("match_profile_fingerprint_to_exit", { profileId: profile.id, exitIp: "8.8.8.8", }); // 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. 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, ); assert.equal( stored.wayfern_config.identity_id, profile.wayfern_config.identity_id, "the identity must survive update_wayfern_config and an exit re-match", ); assert.equal( stored.wayfern_config.fingerprint, 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", { profileId: profile.id, }); assert.ok(Array.isArray(checks.vpn_extensions)); assert.equal( typeof checks.scan_state, "string", "the scan must report whether it saw the whole profile", ); assert.equal(typeof checks.consistency, "object"); assert.equal(typeof checks.exit_probe_pending, "boolean"); assert.equal(typeof checks.exit_measurement_unreliable, "boolean"); // A profile with no proxy or VPN has no route to check before launch. assert.equal(checks.route_unreachable, null); // 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, 0, "a clean profile must not report a VPN extension", ); assert.equal( checks.consent_token, null, "a consent token is only minted when a cached mismatch is blocking", ); // Extension detection, against manifests written where Chromium puts // them. The three cases are the whole point of the classifier: a real VPN // is named as one, a known VPN with an unrevealing name is caught by its // id, and a download manager holding the same `proxy` permission is // reported as a capability and never as a VPN. // `DONUTBROWSER_DATA_ROOT` puts the data dir at /data, so this // is app_dirs::profiles_dir() plus the layout Chromium itself uses. const extensionsDir = path.join( app.dataRoot, "data", "profiles", profile.id, "profile", "Default", "Extensions", ); const seedExtension = async (id, version, manifest) => { const dir = path.join(extensionsDir, id, `${version}_0`); await mkdir(dir, { recursive: true }); await writeFile( path.join(dir, "manifest.json"), JSON.stringify(manifest), ); }; const IDM_ID = "ngpampappnmepgilojfohadhhmbhlaek"; const HOTSPOT_SHIELD_ID = "nlbejmccbhkncgokjcmghpfloaajcffj"; const NAMED_VPN_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; await seedExtension(IDM_ID, "6.43.1", { name: "IDM Integration Module", version: "6.43.1", description: "Download files with Internet Download Manager", permissions: ["downloads", "storage", "proxy", "nativeMessaging"], }); await seedExtension(HOTSPOT_SHIELD_ID, "10.0.0", { name: "Hotspot Shield", version: "10.0.0", permissions: ["proxy"], }); await seedExtension(NAMED_VPN_ID, "1.0.0", { name: "Turbo VPN Free", version: "1.0.0", permissions: ["proxy"], }); const withExtensions = await app.invoke("get_profile_pre_launch_checks", { profileId: profile.id, }); const detected = new Map( withExtensions.vpn_extensions.map((item) => [item.key, item]), ); assert.equal(detected.size, 3, "every seeded extension must be reported"); assert.equal(detected.get(`crx:${NAMED_VPN_ID}`).confidence, "confirmed"); assert.equal( detected.get(`crx:${HOTSPOT_SHIELD_ID}`).confidence, "confirmed", "a known VPN id must be named even when its name gives nothing away", ); assert.equal( detected.get(`crx:${IDM_ID}`).confidence, "capability", "a download manager holding the proxy permission is not a VPN", ); assert.ok( detected.get(`crx:${IDM_ID}`).proxy_control, "it does still hold the permission, which is why it is listed at all", ); assert.equal( withExtensions.exit_measurement_unreliable, true, "a proxy-capable extension makes the exit measurement a caveat", ); // Acknowledgements are per-profile and must be accepted for both kinds. await app.invoke("ack_launch_gate", { profileId: profile.id, ackFingerprint: false, ackExtensionKeys: ["crx:e2e-nonexistent-extension"], }); await app.invoke("ack_launch_gate", { profileId: profile.id, ackFingerprint: true, ackExtensionKeys: [`crx:${IDM_ID}`], }); const afterAck = await app.invoke("get_profile_pre_launch_checks", { profileId: profile.id, }); assert.deepEqual( afterAck.vpn_extensions.map((item) => item.key).sort(), [`crx:${HOTSPOT_SHIELD_ID}`, `crx:${NAMED_VPN_ID}`].sort(), "an acknowledged extension stops being reported, the others do not", ); assert.match( await app.invokeError("get_profile_pre_launch_checks", { profileId: "00000000-0000-0000-0000-000000000000", }), /PROFILE_NOT_FOUND/, ); const directProfile = (await app.invoke("list_browser_profiles")).find( (item) => item.id === profile.id, ); const directLaunch = await app.invoke("launch_browser_profile", { profile: directProfile, url: `${fixtureUrl}/direct-command`, }); assert.ok(directLaunch.process_id); await app.invoke("open_url_with_profile", { profileId: profile.id, url: `${fixtureUrl}/direct-open`, }); await app.invoke("kill_browser_profile", { profile: directLaunch }); await waitForProcessExit(app, directLaunch.process_id); const settings = await app.invoke("get_app_settings"); const saved = await app.invoke("save_app_settings", { settings: { ...settings, api_enabled: true, api_port: 0, api_token: null, onboarding_completed: true, }, }); const port = await app.invoke("start_api_server", { port: 0 }); const base = `http://127.0.0.1:${port}`; const launched = await request(`${base}/v1/profiles/${profile.id}/run`, { method: "POST", token: saved.api_token, body: { url: `${fixtureUrl}/wayfern`, headless: true }, }); assert.equal(launched.response.status, 200, JSON.stringify(launched.value)); assert.equal(launched.value.headless, true); cdp = await CdpClient.connect(launched.value.remote_debugging_port); await cdp.waitFor(`document.title === "Donut E2E Browser Fixture"`, { description: "fixture page title", }); assert.equal( await cdp.evaluate("document.querySelector('#path').textContent"), "/wayfern", ); assert.equal( await cdp.evaluate( "document.querySelector('#fixture-button').click(); document.querySelector('#fixture-button').dataset.clicked", ), "yes", ); const echo = await cdp.evaluate( `fetch(${JSON.stringify(`${fixtureUrl}/api/echo`)}, { method: "POST", body: "wayfern-cdp-body" }).then((response) => response.json())`, ); assert.equal(echo.method, "POST"); assert.equal(echo.body, "wayfern-cdp-body"); assert.ok(echo.userAgent.length > 20); assert.match(await cdp.evaluate("document.cookie"), /donut_e2e=browser-ok/); // A click that only changes the page ends a moment after the click, not // at the load timeout; a link click still waits for its load and says so. const agentClick = async (locator) => { const started = Date.now(); const answer = await request( `${base}/v1/profiles/${profile.id}/agent/click`, { method: "POST", token: saved.api_token, body: { locator } }, ); assert.equal(answer.response.status, 200, JSON.stringify(answer.value)); return { ...answer.value, ms: Date.now() - started }; }; const inPlace = await agentClick({ role: "button", name: "Clicked" }); assert.equal(inPlace.navigated, false); assert.ok(inPlace.ms < 5000, `an in-place click took ${inPlace.ms} ms`); const link = await agentClick({ role: "link", name: "Open the linked page", }); assert.equal(link.navigated, true); assert.equal( await cdp.evaluate("document.querySelector('#path').textContent"), "/linked", ); // The browser's own index never carries `id`, so the page applies this // locator; the node a resolution names is the element itself. const byIdLocator = { attributes: [{ name: "id", value: "fixture-button" }], }; const resolved = await request( `${base}/v1/profiles/${profile.id}/agent/resolve-locator`, { method: "POST", token: saved.api_token, body: { locator: byIdLocator }, }, ); assert.equal(resolved.response.status, 200, JSON.stringify(resolved.value)); const resolvedNode = await cdp.command("DOM.describeNode", { backendNodeId: resolved.value.backendNodeId, }); assert.equal(resolvedNode.node.nodeName, "BUTTON"); assert.ok( resolvedNode.node.attributes.includes("fixture-button"), JSON.stringify(resolvedNode.node), ); const byId = await agentClick(byIdLocator); assert.equal(byId.navigated, false); assert.equal( await cdp.evaluate( "document.querySelector('#fixture-button').dataset.clicked", ), "yes", ); // The MCP browser tools, through the engine an agent reaches over the // remote bridge. Each action says where the page is afterwards. let rpcId = 0; let mcpSession = null; const mcp = async (method, params) => { const response = await fetch(`${base}/v1/e2e/mcp`, { method: "POST", headers: { authorization: `Bearer ${saved.api_token}`, "content-type": "application/json", ...(mcpSession ? { "mcp-session-id": mcpSession } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }), }); assert.equal(response.status, 200, await response.clone().text()); const answer = await response.json(); mcpSession = answer.session_id ?? mcpSession; assert.equal(answer.body.error, undefined, JSON.stringify(answer.body)); return answer.body.result; }; const tool = async (name, args = {}) => { const result = await mcp("tools/call", { name, arguments: { profile_id: profile.id, ...args }, }); assert.notEqual(result.isError, true, JSON.stringify(result)); return result.content[0]; }; const toolJson = async (name, args) => JSON.parse((await tool(name, args)).text); await mcp("initialize", { protocolVersion: "2025-06-18", clientInfo: { name: "Donut E2E", version: "1" }, }); // The device the profile presents, through each way it is read: the // fingerprint form, REST and MCP. An identity-backed profile stores no // device, so every read derives it, and it is what the running page reports. const shown = JSON.parse( await app.invoke("get_profile_fingerprint", { profileId: profile.id }), ); assert.ok(Object.keys(shown).length >= 10, JSON.stringify(shown)); assert.equal(shown.platform, await cdp.evaluate("navigator.platform")); assert.equal( shown.hardwareConcurrency, await cdp.evaluate("navigator.hardwareConcurrency"), ); const restFingerprint = await request( `${base}/v1/profiles/${profile.id}/fingerprint`, { token: saved.api_token }, ); assert.equal( restFingerprint.response.status, 200, JSON.stringify(restFingerprint.value), ); assert.deepEqual(JSON.parse(restFingerprint.value.fingerprint), shown); assert.deepEqual( JSON.parse((await toolJson("get_profile_fingerprint")).fingerprint), shown, ); const back = await tool("go_back"); assert.match(back.text, /^Went back/); assert.match(back.text, /Page: Donut E2E Browser Fixture <.*\/wayfern>/); assert.equal( await cdp.evaluate("document.querySelector('#path').textContent"), "/wayfern", ); const scrolled = await toolJson("scroll", { direction: "down" }); assert.equal(scrolled.moved, true, JSON.stringify(scrolled)); assert.ok(scrolled.scroll_y > 0); assert.equal(scrolled.at_top, false); await cdp.evaluate("document.querySelector('#fixture-input').focus()"); const pressed = await tool("press_key", { key: "Enter" }); assert.match(pressed.text, /^Pressed Enter/); assert.equal( await cdp.evaluate( "document.querySelector('#fixture-input').dataset.entered", ), "yes", ); const found = await toolJson("wait_for", { selector: "#fixture-end" }); assert.equal(found.met, true); assert.equal(found.title, "Donut E2E Browser Fixture"); const missing = await toolJson("wait_for", { text: "Text that is never on the fixture", timeout_ms: 300, }); assert.equal(missing.met, false); // Each listed element carries an id that stays the same while it lives; // a batch acts on one by id and reports each step. const listing = (await tool("get_interactive_elements")).text; const buttonLine = listing .split("\n") .find((line) => line.includes('id="fixture-button"')); const elementId = /^\[\d+ (e\d+)/.exec(buttonLine ?? "")?.[1]; assert.ok(elementId, listing); assert.doesNotMatch( (await tool("get_interactive_elements")).text, / new[\] ]/, "nothing is new on a second listing of the same page", ); await cdp.evaluate( "delete document.querySelector('#fixture-button').dataset.clicked; true", ); const batch = await toolJson("run_actions", { actions: [ { tool: "click_by_index", arguments: { element_id: elementId } }, { tool: "get_page_info" }, ], }); assert.equal(batch.completed, 2, JSON.stringify(batch)); assert.equal(batch.stopped, null); assert.equal( await cdp.evaluate( "document.querySelector('#fixture-button').dataset.clicked", ), "yes", ); // Form tools on a native select and a hidden file input added to the page. const toolError = async (name, args = {}) => { const response = await fetch(`${base}/v1/e2e/mcp`, { method: "POST", headers: { authorization: `Bearer ${saved.api_token}`, "content-type": "application/json", "mcp-session-id": mcpSession, }, body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name, arguments: { profile_id: profile.id, ...args } }, }), }); const answer = await response.json(); assert.ok(answer.body.error, JSON.stringify(answer.body)); return answer.body.error; }; await cdp.evaluate(`(() => { const form = document.createElement('form'); form.innerHTML = '' + '' + '' + ''; document.body.prepend(form); const country = form.querySelector('#fixture-country'); country.addEventListener('change', () => { country.dataset.changed = country.value; }); form.querySelector('#fixture-file').addEventListener('change', (event) => { event.target.dataset.trusted = String(event.isTrusted); }); return true; })()`); const picked = await toolJson("select_option", { selector: "#fixture-country", option: "France", }); assert.deepEqual( picked.selected.map((option) => option.value), ["fr"], JSON.stringify(picked), ); assert.equal(picked.changed, true); assert.equal( await cdp.evaluate( "document.querySelector('#fixture-country').dataset.changed", ), "fr", ); const byLocator = await toolJson("select_option", { locator: { attributes: [{ name: "id", value: "fixture-country" }] }, option: "de", }); assert.equal(byLocator.selected[0].label, "Germany"); const unchanged = await toolJson("select_option", { selector: "#fixture-country", option: "Germany", }); assert.equal(unchanged.changed, false); const both = await toolJson("select_option", { selector: "#fixture-langs", options: ["English", "de"], }); assert.deepEqual( both.selected.map((option) => option.value), ["en", "de"], ); const noSuchOption = await toolError("select_option", { selector: "#fixture-country", option: "Mars", }); assert.match(noSuchOption.message, /No option matches.*Germany/s); const notSelect = await toolError("select_option", { selector: "#fixture-button", option: "x", }); assert.match(notSelect.message, /not a