import assert from "node:assert/strict"; import { mkdir, realpath, writeFile } from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import test from "node:test"; import Color from "color"; import en from "../../src/i18n/locales/en.json" with { type: "json" }; import { curlSnippet, javascriptSnippet, LOCAL_API_EXAMPLES, maskToken, oneLine, } from "../../src/lib/api-examples.ts"; import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts"; import { withApp } from "../lib/app.mjs"; import { CRX_EXTENSION_NAME, CRX_EXTENSION_VERSION, extensionZipBase64, wireGuardFixture, writeUnpackedExtension, } from "../lib/fixtures.mjs"; const THEME_VARIABLES = [ "--background", "--foreground", "--card", "--card-foreground", "--popover", "--popover-foreground", "--primary", "--primary-foreground", "--secondary", "--secondary-foreground", "--muted", "--muted-foreground", "--accent", "--accent-foreground", "--destructive", "--destructive-foreground", "--success", "--success-foreground", "--warning", "--warning-foreground", "--border", "--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5", ]; const DRACULA_THEME = THEMES.find((theme) => theme.id === "dracula").colors; const AYU_LIGHT_THEME = THEMES.find((theme) => theme.id === "ayu-light").colors; async function tableRows(app, tableId) { return app.execute( `return [...document.querySelectorAll('[data-table-id="' + arguments[0] + '"] tr[data-table-row]')].map(row => row.dataset.tableRow);`, [tableId], ); } /** Selection checkboxes, and the browser icons that stand in for them, that sit * more than half a pixel off the center of their cell. */ async function offCenterSelectionControls(app, tableSelector) { return app.execute( `return [...document.querySelectorAll(arguments[0] + ' :is(th, td) :is([role="checkbox"], [role="checkbox"] + [aria-hidden="true"] > svg)')] .map((node) => { const cell = node.closest("th, td").getBoundingClientRect(); const rect = node.getBoundingClientRect(); return { control: node.getAttribute("aria-label") ?? "icon", x: rect.left + rect.width / 2 - (cell.left + cell.width / 2), y: rect.top + rect.height / 2 - (cell.top + cell.height / 2), }; }) .filter(({ x, y }) => Math.abs(x) > 0.5 || Math.abs(y) > 0.5);`, [tableSelector], ); } async function tableShiftClick(app, selector) { // tauri-wd 0.2.1 omits modifiers from pointer events. Send the click with // Shift through the native WebView; the real checkbox handler selects rows. await app.execute( `const target = document.querySelector(arguments[0]); target.focus(); target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true }));`, [selector], ); } async function openProfileCreation(app) { await app.clickText("New", { roles: ["button"] }); await app.waitForText(en.createProfile.title); await app.waitFor( () => app.execute(`return Boolean(document.querySelector('#profile-name'));`), { description: "profile name field" }, ); } async function submitProfileCreation(app, name) { await app.clickTextIn('[role="dialog"]', en.common.buttons.create, { roles: ["button"], }); await app.waitFor( () => app.execute(`return !document.querySelector('#profile-name');`), { description: `created profile ${name}` }, ); return (await app.invoke("list_browser_profiles")).find( (profile) => profile.name === name, ); } test("profile creation stays compact and saves basic, protected, and ephemeral profiles", async () => { await withApp( "ui-profile-creation", async (app) => { // The UI suite has no browser binary or live network routes. Record // the chosen route, then create locally with a fixed device. The real // backend still saves the options and encrypts the profile. The network // suite covers creation with a real browser and working proxy. await stubCommand(app, "is_geoip_database_available", true); await app.execute(` const originalFetch = window.fetch; window.__donutProfileRequests = []; window.fetch = function(input, init) { const url = typeof input === 'string' ? input : input?.url ?? ''; if (url.endsWith('/create_browser_profile_new')) { const payload = JSON.parse(init.body); window.__donutProfileRequests.push(structuredClone(payload)); payload.proxyId = null; payload.vpnId = null; payload.wayfernConfig = { ...payload.wayfernConfig, fingerprint: '{}' }; return originalFetch.call(window, input, { ...init, body: JSON.stringify(payload) }); } return originalFetch.apply(window, arguments); }; `); await app.session.command("POST", "/window/rect", { width: 1280, height: 900, }); await openProfileCreation(app); await app.fillSelector("#profile-name", "Simple profile"); await app.waitFor( () => app.execute( `return [...document.querySelectorAll('[role="dialog"] button')].some(button => button.textContent.trim() === arguments[0] && !button.disabled);`, [en.common.buttons.create], ), { description: "creation is available" }, ); const basic = await app.execute(` const dialog = document.querySelector('#profile-name').closest('[role="dialog"]'); const rect = dialog.getBoundingClientRect(); return { width: rect.width, height: rect.height, text: dialog.innerText, tabs: dialog.querySelectorAll('[role="tab"]').length, fields: [...dialog.querySelectorAll('input:not([type="hidden"])')].map(input => input.id), advancedVisible: Boolean(dialog.querySelector('#launch-hook-url')), checkboxes: [...dialog.querySelectorAll('[role="checkbox"]')].map(input => input.id), }; `); assert.ok( basic.width <= 512 && basic.height < 450, JSON.stringify(basic), ); assert.equal(basic.tabs, 0); assert.deepEqual(basic.fields, ["profile-name", "profile-count"]); assert.deepEqual(basic.checkboxes, ["enable-password", "ephemeral"]); assert.equal(basic.advancedVisible, false); assert.ok(!basic.text.includes(en.profiles.ephemeralDescription)); assert.ok( !basic.text.includes(en.createProfile.passwordProtect.description), ); const chrome = await app.execute(` const dialog = document.querySelector('#profile-name').closest('[role="dialog"]'); const border = (node) => getComputedStyle(node).borderTopWidth; return { name: border(document.querySelector('#profile-name')), options: [...dialog.querySelectorAll('#profile-proxy, [role="checkbox"]')].map(border), proxyName: document.querySelector('#profile-proxy').getAttribute('aria-labelledby') .split(' ').map((id) => document.getElementById(id)?.textContent.trim()), }; `); assert.equal(chrome.name, "0px", "the name field has no box"); assert.deepEqual(chrome.options, ["0px", "0px", "0px"]); assert.deepEqual(chrome.proxyName, [ en.createProfile.proxy.title, en.createProfile.proxy.noProxy, ]); await app.capture("profile-create-compact-wide"); // The device's operating system is chosen on the dialog itself. Without // a plan that includes cross-OS fingerprints, only this computer's // system can be picked. const hostOs = { darwin: "macOS", win32: "Windows" }[process.platform] ?? "Linux"; assert.equal( await app.execute( `return document.querySelector('#profile-os').textContent.trim();`, ), hostOs, ); await app.clickSelector("#profile-os"); await app.waitFor( () => app.execute( `return document.querySelectorAll('[cmdk-item]').length;`, ), { description: "operating system options" }, ); const osOptions = await app.execute(` return [...document.querySelectorAll('[cmdk-item]')].map((item) => ({ text: item.textContent.trim(), disabled: item.getAttribute('aria-disabled') === 'true', })); `); assert.equal(osOptions.length, 5, JSON.stringify(osOptions)); assert.deepEqual( osOptions.filter((option) => !option.disabled).map((o) => o.text), [hostOs], ); assert.ok( osOptions .filter((option) => option.disabled) .every((option) => option.text.endsWith("PRO")), JSON.stringify(osOptions), ); await app.pressShortcut({ key: "Escape" }); await app.waitFor( () => app.execute( `return !document.querySelector('[cmdk-item]') && Boolean(document.querySelector('#profile-name'));`, ), { description: "the OS menu to close and the dialog to stay" }, ); // Adding a route is part of the route list, and stays there when the // search finds nothing. await app.clickSelector("#profile-proxy"); await app.fillSelector("[cmdk-input]", "no such route"); await app.waitForText(en.createProfile.proxy.notFound); await app.clickText(en.createProfile.proxy.addProxy, { roles: ["option"], }); await app.waitFor( () => app.execute(`return Boolean(document.querySelector('#proxy-name'));`), { description: "proxy form from the route list" }, ); await app.pressShortcut({ key: "Escape" }); await app.waitFor( () => app.execute( `return !document.querySelector('#proxy-name') && document.querySelector('#profile-name')?.value === arguments[0];`, ["Simple profile"], ), { description: "creation dialog kept after the proxy form closes" }, ); await app.clickSelector("#profile-proxy"); await app.clickText(en.common.labels.none, { roles: ["option"] }); const simple = await submitProfileCreation(app, "Simple profile"); assert.ok(simple); assert.equal(simple.ephemeral, false); assert.equal(simple.password_protected, false); assert.equal(simple.proxy_id, null); assert.equal(simple.vpn_id, null); assert.ok(simple.wayfern_config.screen_max_width > 0); assert.ok(simple.wayfern_config.screen_max_height > 0); const proxy = await app.invoke("create_stored_proxy", { name: "Creation proxy", proxySettings: { proxy_type: "http", host: "127.0.0.1", port: 9, username: null, password: null, }, }); const vpn = await app.invoke("create_vpn_config_manual", { name: "Creation VPN", vpnType: "WireGuard", configData: wireGuardFixture(), }); await app.invoke("plugin:event|emit", { event: "vpn-configs-changed", payload: null, }); const extensions = await app.invoke("create_extension_group", { name: "Creation extensions", }); await openProfileCreation(app); await app.fillSelector("#profile-name", "Protected profile"); await app.clickSelector("#profile-proxy"); await app.fillSelector("[cmdk-input]", "Creation proxy"); await app.clickText(proxy.name, { roles: ["option"] }); await app.clickSelector("#enable-password"); await app.fillSelector("#profile-password", "short"); await app.clickTextIn('[role="dialog"]', en.common.buttons.create, { roles: ["button"], }); await app.waitForText( en.profilePassword.errors.tooShort.replace("{{min}}", "8"), ); await app.fillSelector("#profile-password", "creation test password"); await app.fillSelector("#profile-password-confirm", "different password"); await app.clickTextIn('[role="dialog"]', en.common.buttons.create, { roles: ["button"], }); await app.waitForText(en.profilePassword.errors.mismatch); await app.fillSelector( "#profile-password-confirm", "creation test password", ); await app.clickText(en.createProfile.advancedOptions, { roles: ["button"], }); await app.fillSelector( "#launch-hook-url", "https://example.com/hooks/created", ); await chooseSelectOption( app, "#profile-dns-blocklist", en.dnsBlocklist.normal, ); // The DNS field reads the value back from the backend: a value it cannot // use blocks creation, a plain address says it is not encrypted, and a // preset is encrypted. const dnsStatus = (state) => app.waitFor( () => app.execute( `return document.querySelector('[data-slot="dns-server-status"]')?.dataset.state === arguments[0];`, [state], ), { description: `DNS server status ${state}` }, ); const createEnabled = () => app.execute( `return [...document.querySelectorAll('[role="dialog"] button')].some(button => button.textContent.trim() === arguments[0] && !button.disabled);`, [en.common.buttons.create], ); await dnsStatus("default"); await app.waitForText(en.dnsServer.summary.default); await app.fillSelector("#profile-dns-server", "udp://1.1.1.1"); await dnsStatus("invalid"); await app.waitForText(en.backendErrors.dnsServerInvalid); await app.waitFor(async () => !(await createEnabled()), { description: "an invalid DNS server blocks creation", }); await app.fillSelector("#profile-dns-server", "10.0.0.53"); await dnsStatus("valid"); await app.waitForText( en.dnsServer.summary.tcp.replace("{{server}}", "10.0.0.53"), ); await app.clickText("Quad9", { roles: ["button"] }); await app.waitForText( en.dnsServer.summary.https.replace("{{server}}", "dns.quad9.net"), ); assert.equal( await app.execute( `return document.querySelector('#profile-dns-server').value;`, ), "https://dns.quad9.net/dns-query", ); await app.waitFor(createEnabled, { description: "a valid DNS server allows creation", }); await chooseSelectOption( app, "#profile-extension-group", `${extensions.name} (0)`, ); await app.clickSelector("#restore-session"); assert.equal( await app.execute( `return document.querySelector('#profile-name').closest('[role="dialog"]').querySelectorAll('[role="tab"]').length;`, ), 0, ); await app.execute( `document.querySelector('[data-slot="profile-create-fields"]').scrollTop = 0;`, ); await app.capture("profile-create-advanced-wide"); await app.clickText(en.createProfile.advancedOptions, { roles: ["button"], }); assert.equal( await app.execute( `return document.querySelector('#profile-name').value;`, ), "Protected profile", ); assert.equal( await app.execute( `return document.querySelector('#profile-password').value;`, ), "creation test password", ); assert.equal( await app.execute( `return document.querySelector('#profile-proxy').textContent.trim();`, ), proxy.name, ); await app.clickText(en.createProfile.advancedOptions, { roles: ["button"], }); assert.equal( await app.execute( `return document.querySelector('#launch-hook-url').value;`, ), "https://example.com/hooks/created", ); assert.equal( await app.execute( `return document.querySelector('#restore-session').getAttribute('aria-checked');`, ), "false", ); await app.clickText(en.createProfile.advancedOptions, { roles: ["button"], }); await app.session.command("POST", "/window/rect", { width: 480, height: 700, }); await app.capture("profile-create-password-narrow"); const fit = await app.execute(` const dialog = document.querySelector('#profile-name').closest('[role="dialog"]'); const rect = dialog.getBoundingClientRect(); const footer = dialog.querySelector('[data-slot="dialog-footer"]').getBoundingClientRect(); return rect.left >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight && dialog.scrollWidth <= dialog.clientWidth && footer.bottom <= rect.bottom; `); assert.equal(fit, true); const protectedProfile = await submitProfileCreation( app, "Protected profile", ); assert.ok(protectedProfile); assert.equal(protectedProfile.password_protected, true); assert.equal(protectedProfile.ephemeral, false); const protectedRequest = await app.execute( `return window.__donutProfileRequests.at(-1);`, ); assert.equal(protectedRequest.proxyId, proxy.id); assert.equal(protectedRequest.vpnId, undefined); assert.equal(protectedProfile.dns_blocklist, "normal"); assert.equal( protectedProfile.dns_server, "https://dns.quad9.net/dns-query", ); assert.equal( protectedProfile.launch_hook, "https://example.com/hooks/created", ); assert.equal(protectedProfile.extension_group_id, extensions.id); assert.equal(protectedProfile.wayfern_config.restore_session, false); await app.invoke("verify_profile_password", { profileId: protectedProfile.id, password: "creation test password", }); await openProfileCreation(app); await app.fillSelector("#profile-name", "Ephemeral profile"); assert.equal( await app.execute( `return document.querySelector('#enable-password').getAttribute('aria-checked');`, ), "false", ); await app.capture("profile-create-compact-narrow"); await app.clickText(en.createProfile.advancedOptions, { roles: ["button"], }); assert.equal( await app.execute( `return document.querySelector('#launch-hook-url').value;`, ), "", ); assert.equal( await app.execute( `return document.querySelector('#profile-dns-blocklist').textContent.trim();`, ), en.dnsBlocklist.none, ); assert.equal( await app.execute( `return document.querySelector('#profile-dns-server').value;`, ), "", ); assert.equal( await app.execute( `return document.querySelector('#profile-extension-group').textContent.trim();`, ), en.profileInfo.values.none, ); await app.capture("profile-create-advanced-narrow"); await app.clickText(en.createProfile.advancedOptions, { roles: ["button"], }); await app.clickSelector("#profile-proxy"); await app.fillSelector("[cmdk-input]", "Creation VPN"); await app.clickText(vpn.name, { exact: false, roles: ["option"] }); await app.clickSelector("#enable-password"); await app.fillSelector("#profile-password", "discarded password"); await app.clickSelector("#ephemeral"); assert.equal( await app.execute( `return document.querySelector('#enable-password').getAttribute('aria-checked');`, ), "false", ); assert.equal( await app.execute( `return document.querySelector('#profile-password') === null;`, ), true, ); await app.clickSelector("#enable-password"); assert.equal( await app.execute( `return document.querySelector('#ephemeral').getAttribute('aria-checked');`, ), "false", ); assert.equal( await app.execute( `return document.querySelector('#profile-password').value;`, ), "", ); await app.clickSelector("#ephemeral"); const ephemeral = await submitProfileCreation(app, "Ephemeral profile"); assert.ok(ephemeral); assert.equal(ephemeral.ephemeral, true); assert.equal(ephemeral.password_protected, false); const ephemeralRequest = await app.execute( `return window.__donutProfileRequests.at(-1);`, ); assert.equal(ephemeralRequest.proxyId, undefined); assert.equal(ephemeralRequest.vpnId, vpn.id); assert.equal(ephemeral.launch_hook, null); }, { seedDownloadedBrowser: true }, ); }); test("profile table filters, range selection, view settings, and keyboard edits work together", async () => { await withApp( "ui-table-controls", async (app) => { await app.session.command("POST", "/window/rect", { width: 1480, height: 900, }); const profiles = []; for (let i = 0; i < 32; i++) profiles.push( await createUiProfile(app, `Table ${String(i + 1).padStart(2, "0")}`), ); for (const profile of profiles.slice(0, 2)) await app.invoke("update_profile_tags", { profileId: profile.id, tags: ["table-work"], }); await app.waitFor( async () => (await tableRows(app, "profiles")).length >= 4, { description: "profile table rows" }, ); const chrome = await app.execute( ` const root = document.querySelector('[data-table-id="profiles"]'); const border = (node) => getComputedStyle(node).borderTopWidth; const filters = [...root.querySelectorAll('[data-slot="table-filters"] button')] .find((button) => button.textContent.trim() === arguments[0]); return { controls: [ filters, root.querySelector('[data-slot="table-sort-trigger"]'), root.querySelector('[data-slot="table-view-trigger"]'), ].map(border), rowRules: [...root.querySelectorAll('tr[data-table-row] > td')] .filter((cell) => parseFloat(getComputedStyle(cell).borderBottomWidth) > 0).length, }; `, [en.tables.filters], ); assert.deepEqual(chrome.controls, ["0px", "0px", "0px"]); assert.equal(chrome.rowRules, 0, "rows are not divided by rules"); assert.deepEqual( await offCenterSelectionControls(app, '[data-table-id="profiles"]'), [], ); const row = (index) => `tr[data-table-row="${profiles[index].id}"]`; await app.clickSelector(`${row(0)} [role="checkbox"]`); await tableShiftClick(app, `${row(3)} [role="checkbox"]`); await app.waitFor( () => app.execute( `return document.querySelectorAll('tr[data-table-row][aria-selected="true"]').length === 4;`, ), { description: "Shift-click selects four rows" }, ); assert.equal( await app.execute( `return document.querySelector('[data-table-id="profiles"] thead [role="checkbox"]').getAttribute('data-state');`, ), "indeterminate", ); assert.match( await app.execute( `return document.querySelector('[data-slot="table-action-bar"]').textContent;`, ), /4/, ); const searchSelector = `input[placeholder="${en.header.searchPlaceholder}"]`; // Quoted: an unquoted "02" is its own term, which also prefix-matches // any profile whose random id starts with 02. await app.fillSelector(searchSelector, '"Table 02"'); await app.waitFor( async () => JSON.stringify(await tableRows(app, "profiles")) === JSON.stringify([profiles[1].id]), { description: "profile text search" }, ); assert.equal( await app.execute( `return Boolean(document.querySelector('[data-slot="table-action-bar"]'));`, ), false, "search clears all action targets", ); await app.clickSelector(`[aria-label="${en.header.clearSearch}"]`); await app.clickTextIn( '[data-table-id="profiles"] [data-slot="table-filters"]', en.tables.filters, { roles: ["button"] }, ); await app.clickSelector( `[cmdk-item][data-value="${en.profileTable.tagsHeader} table-work"]`, ); await app.pressShortcut({ key: "Escape" }); await app.waitFor( async () => (await tableRows(app, "profiles")).length === 2, { description: "visible tag filter" }, ); assert.match( await app.execute( `return document.querySelector('[data-slot="table-result-count"]').textContent;`, ), /2.*32/, ); await app.clickTextIn( '[data-table-id="profiles"] [data-slot="table-filters"]', en.tables.clearFilters, { roles: ["button"] }, ); await app.clickSelector( '[data-table-id="profiles"] [data-slot="table-sort-trigger"]', ); await app.clickText(en.tables.addSort, { roles: ["button"] }); await app.clickSelector( `[data-sort-index="1"] button[aria-label="${en.tables.moveSortUp}"]`, ); await app.pressShortcut({ key: "Escape" }); await app.waitFor( async () => (await app.invoke("get_table_preferences", { tableId: "profiles" })) .sorting[0].id === "created_at", { description: "ordered sort rules saved" }, ); assert.match( await app.execute( `return document.querySelector('[data-slot="table-sort-trigger"]').textContent;`, ), new RegExp(en.search.fields.created), ); await app.clickSelector( '[data-table-id="profiles"] [data-slot="table-view-trigger"]', ); await app.clickSelector("#table-visible-profiles-note"); await app.clickSelector(`[aria-label="${en.tables.rowHeight}"]`); await app.clickText(en.tables.comfortable, { roles: ["option"] }); await app.pressShortcut({ key: "Escape" }); await app.waitFor( async () => (await app.invoke("get_table_preferences", { tableId: "profiles" })) .density === "comfortable", { description: "comfortable rows saved" }, ); assert.equal( await app.execute( `return Boolean(document.querySelector('[data-table-column="note"]'));`, ), false, ); assert.equal( await app.execute( `return Math.round(document.querySelector('tr[data-table-row]').getBoundingClientRect().height);`, ), 48, ); const resizeName = `[data-table-id="profiles"] [role="separator"][aria-label="${en.tables.resizeColumn.replace("{{column}}", en.common.labels.name)}"]`; const columnWidth = () => app.execute( `return document.querySelector(arguments[0]).parentElement.getBoundingClientRect().width;`, [resizeName], ); const initialWidth = await columnWidth(); await app.execute(`document.querySelector(arguments[0]).focus();`, [ resizeName, ]); await app.pressShortcut({ key: "\uE014" }); await app.waitFor( async () => (await app.invoke("get_table_preferences", { tableId: "profiles" })) .sizing.name > 48, { description: "keyboard column resize saved" }, ); const saved = await app.invoke("get_table_preferences", { tableId: "profiles", }); assert.ok(Math.abs((await columnWidth()) - initialWidth - 16) < 2); assert.ok(Math.abs((await columnWidth()) - saved.sizing.name) < 2); await app.capture("table-controls-wide"); await app.restart(); await app.waitFor( () => app.execute( `return document.querySelector('tr[data-table-row]')?.getBoundingClientRect().height === 48;`, ), { description: "saved view restored after restart" }, ); assert.deepEqual( await app.invoke("get_table_preferences", { tableId: "profiles" }), saved, ); assert.ok(Math.abs((await columnWidth()) - saved.sizing.name) < 2); await app.clickSelector( '[data-table-id="profiles"] [data-slot="table-view-trigger"]', ); await app.clickText(en.tables.resetView, { roles: ["button"] }); await app.pressShortcut({ key: "Escape" }); await app.waitFor( () => app.execute( `return document.querySelector('tr[data-table-row]')?.getBoundingClientRect().height === 36;`, ), { description: "default view restored" }, ); await app.session.command("POST", "/window/rect", { width: 1280, height: 500, }); await app.waitFor( () => app.execute(`return !document.querySelector(arguments[0]);`, [ row(24), ]), { description: "keyboard target starts outside the rendered rows" }, ); await app.execute(`document.querySelector(arguments[0]).focus();`, [ `${row(0)} [data-table-column="name"]`, ]); for (let i = 0; i < 24; i++) { await app.pressShortcut({ key: "\uE015" }); await app.waitFor( () => app.execute( `return document.activeElement?.closest('tr')?.dataset.tableRow === arguments[0];`, [profiles[i + 1].id], ), { description: `keyboard row ${i + 2}` }, ); } await app.waitFor( () => app.execute( `return document.activeElement?.closest('tr')?.dataset.tableRow === arguments[0];`, [profiles[24].id], ), { description: "keyboard crosses the virtual row boundary" }, ); await app.pressShortcut({ key: "\uE032" }); await app.fillSelector(`${row(24)} input`, "Table renamed by keyboard"); await app.pressShortcut({ key: "\uE006" }); await app.waitFor( async () => (await app.invoke("list_browser_profiles")).find( (profile) => profile.id === profiles[24].id, )?.name === "Table renamed by keyboard", { description: "F2 edit saved through the backend" }, ); await app.session.command("POST", "/window/rect", { width: 640, height: 600, }); await app.capture("table-controls-narrow"); assert.equal( await app.execute( `return document.documentElement.scrollWidth <= innerWidth;`, ), true, "controls do not widen the window", ); await app.clickSelector( '[data-table-id="profiles"] thead [role="checkbox"]', ); await app.waitFor( () => app.execute( ` const scroll = document.querySelector('[data-table-id="profiles"] .scroll-fade'); scroll.scrollTop = scroll.scrollHeight; const rows = [...scroll.querySelectorAll('tr[data-table-row]')]; const bar = document.querySelector('[data-slot="table-action-bar"]'); return rows.at(-1)?.dataset.tableRow === arguments[0] && rows.at(-1).getBoundingClientRect().bottom <= bar?.getBoundingClientRect().top - 4; `, [profiles[24].id], ), { description: "last row clears the wrapped bulk action bar" }, ); await app.capture("table-controls-narrow-selection"); }, { seedDownloadedBrowser: true }, ); }); test("a proxy with a change-IP link offers Change IP on its row", async () => { await withApp("ui-proxy-rotation", async (app) => { const linked = await app.invoke("create_stored_proxy", { name: "Rotating mobile proxy", proxySettings: { proxy_type: "http", host: "127.0.0.1", port: 9, username: null, password: null, }, }); await app.invoke("set_proxy_rotation_url", { proxyId: linked.id, rotationUrl: `${process.env.DONUT_E2E_FIXTURE_URL}/change-ip?key=ui`, }); const plain = await app.invoke("create_stored_proxy", { name: "Static proxy", proxySettings: { proxy_type: "http", host: "127.0.0.1", port: 9, username: null, password: null, }, }); await app.pressShortcut({ key: "n", ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), }); await app.waitFor( async () => (await tableRows(app, "proxies")).length === 2, { description: "proxy table" }, ); const rotateIn = (id) => `[data-table-id="proxies"] tr[data-table-row="${id}"] [data-slot="proxy-rotate"]`; assert.equal( await app.execute( "return Boolean(document.querySelector(arguments[0]));", [rotateIn(plain.id)], ), false, "a proxy without a link has no Change IP action", ); await app.clickSelector(rotateIn(linked.id)); await app.waitForText( en.proxies.rotation.requested.replace("{{name}}", linked.name), ); await app.capture("proxy-rotation-requested"); }); }); test("management tables share search, filters, selection, and saved views", async () => { await withApp("ui-management-tables", async (app) => { const proxies = []; for (const [name, protocol] of [ ["Table proxy Alpha", "http"], ["Table proxy Beta", "socks5"], ["Table proxy Gamma", "http"], ]) proxies.push( await app.invoke("create_stored_proxy", { name, proxySettings: { proxy_type: protocol, host: "127.0.0.1", port: 9, username: null, password: null, }, }), ); await app.invoke("create_profile_group", { name: "Table group Alpha" }); await app.invoke("create_profile_group", { name: "Table group Beta" }); await app.pressShortcut({ key: "n", ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), }); await app.waitFor( async () => (await tableRows(app, "proxies")).length === 3, { description: "proxy table" }, ); assert.deepEqual( await offCenterSelectionControls(app, '[data-table-id="proxies"]'), [], ); const proxyRowHeight = () => app.execute( `return document.querySelector('[data-table-id="proxies"] tr[data-table-row]')?.getBoundingClientRect().height;`, ); await app.clickSelector( `[data-table-id="proxies"] tr[data-table-row="${proxies[0].id}"] [role="checkbox"]`, ); await tableShiftClick( app, `[data-table-id="proxies"] tr[data-table-row="${proxies[2].id}"] [role="checkbox"]`, ); await app.waitFor( () => app.execute( `return document.querySelectorAll('[data-table-id="proxies"] tr[aria-selected="true"]').length === 3;`, ), { description: "management range selection" }, ); await app.fillSelector( `[data-table-id="proxies"] input[aria-label="${en.tables.search}"]`, "Beta", ); await app.waitFor( async () => (await tableRows(app, "proxies")).length === 1, { description: "proxy search" }, ); assert.equal( await app.execute( `return Boolean(document.querySelector('[data-slot="table-action-bar"]'));`, ), false, ); await app.fillSelector( `[data-table-id="proxies"] input[aria-label="${en.tables.search}"]`, "no such row", ); await app.waitForText(en.tables.noResults); await app.clickTextIn( '[data-table-id="proxies"] tbody', en.tables.clearFilters, { roles: ["button"] }, ); assert.equal((await tableRows(app, "proxies")).length, 3); await app.clickTextIn( '[data-table-id="proxies"] [data-slot="table-filters"]', en.tables.filters, { roles: ["button"] }, ); await app.clickSelector( `[cmdk-item][data-value="${en.proxies.management.protocolCol} HTTP"]`, ); await app.pressShortcut({ key: "Escape" }); await app.waitFor( async () => (await tableRows(app, "proxies")).length === 2, { description: "proxy protocol facet" }, ); await app.clickSelector( '[data-table-id="proxies"] [data-slot="table-view-trigger"]', ); await app.clickSelector("#table-visible-proxies-hostPort"); await app.pressShortcut({ key: "Escape" }); await app.waitFor( async () => (await app.invoke("get_table_preferences", { tableId: "proxies" })) .visibility.hostPort === false, { description: "management columns saved" }, ); assert.equal(await proxyRowHeight(), 36); await app.clickSelector( '[data-table-id="proxies"] [data-slot="table-view-trigger"]', ); await app.clickSelector(`[aria-label="${en.tables.rowHeight}"]`); await app.clickText(en.tables.comfortable, { roles: ["option"] }); await app.pressShortcut({ key: "Escape" }); await app.waitFor(async () => (await proxyRowHeight()) === 48, { description: "management row height changes", }); await app.capture("management-table-controls"); await app.clickSelector(`[aria-label="${en.rail.groups}"]`); await app.waitFor( async () => (await tableRows(app, "groups")).length === 2, { description: "group table" }, ); await app.fillSelector( `[data-table-id="groups"] input[aria-label="${en.tables.search}"]`, "Beta", ); await app.waitFor( async () => (await tableRows(app, "groups")).length === 1, { description: "group search" }, ); await app.invoke("create_vpn_config_manual", { name: "Table VPN Alpha", vpnType: "WireGuard", configData: wireGuardFixture(), }); await app.invoke("create_vpn_config_manual", { name: "Table VPN Beta", vpnType: "WireGuard", configData: wireGuardFixture(), }); await app.clickSelector(`[aria-label="${en.rail.network}"]`); await app.clickText(en.proxies.management.tabVpns, { roles: ["tab"], exact: false, }); await app.waitFor(async () => (await tableRows(app, "vpns")).length === 2, { description: "VPN table", }); await app.fillSelector( `[data-table-id="vpns"] input[aria-label="${en.tables.search}"]`, "Beta", ); await app.waitFor(async () => (await tableRows(app, "vpns")).length === 1, { description: "VPN search", }); await app.invoke("add_extension", { name: "Table extension", fileName: "fixture.zip", fileData: [...Buffer.from(extensionZipBase64(), "base64")], }); await app.invoke("create_extension_group", { name: "Table extension group Alpha", }); await app.invoke("create_extension_group", { name: "Table extension group Beta", }); await app.clickSelector(`[aria-label="${en.rail.extensions}"]`); await app.waitFor( async () => (await tableRows(app, "extensions")).length === 1, { description: "extension table" }, ); await app.fillSelector( `[data-table-id="extensions"] input[aria-label="${en.tables.search}"]`, "no such extension", ); await app.waitForText(en.tables.noResults); await app.clickTextIn( '[data-table-id="extensions"] tbody', en.tables.clearFilters, { roles: ["button"] }, ); await app.clickText(en.extensions.groupsTab, { roles: ["tab"], exact: false, }); await app.waitFor( async () => (await tableRows(app, "extensionGroups")).length === 2, { description: "extension group table" }, ); await app.fillSelector( `[data-table-id="extensionGroups"] input[aria-label="${en.tables.search}"]`, "Beta", ); await app.waitFor( async () => (await tableRows(app, "extensionGroups")).length === 1, { description: "extension group search" }, ); }); }); async function dismissSurface(app) { await app.pressShortcut({ key: "Escape" }); await new Promise((resolve) => setTimeout(resolve, 100)); } async function themeSnapshot(app) { return app.execute( ` const root = document.documentElement; const rootStyle = getComputedStyle(root); const bodyStyle = getComputedStyle(document.body); const variables = arguments[0]; return { mode: root.classList.contains("light") ? "light" : root.classList.contains("dark") ? "dark" : "unset", inline: Object.fromEntries( variables.map((key) => [key, root.style.getPropertyValue(key).trim()]) ), resolved: Object.fromEntries( variables.map((key) => [key, rootStyle.getPropertyValue(key).trim()]) ), bodyBackground: bodyStyle.backgroundColor, bodyForeground: bodyStyle.color, }; `, [THEME_VARIABLES], ); } async function waitForTheme(app, predicate, description) { return app.waitFor( async () => { const snapshot = await themeSnapshot(app); return predicate(snapshot) ? snapshot : false; }, { description }, ); } function themeVariablesEqual(actual, expected) { return THEME_VARIABLES.every( (key) => actual[key]?.toLowerCase() === expected[key]?.toLowerCase(), ); } async function applyThemeForContrastAudit(app, theme) { await app.execute( ` // This audit reads settled colour tokens, not the animation between // them. Tab triggers carry "transition-colors duration-150" and start // from --muted-foreground, so a computed style sampled mid-transition // returns an intermediate colour and the assertion fails on whichever // theme the machine happened to be slow on. Kill transitions for the // duration of the audit rather than racing them with a fixed sleep. let freeze = document.getElementById("donut-e2e-freeze-transitions"); if (!freeze) { freeze = document.createElement("style"); freeze.id = "donut-e2e-freeze-transitions"; freeze.textContent = "*, *::before, *::after { transition: none !important; animation: none !important; }"; document.head.appendChild(freeze); } const [colors, derived, mode] = arguments; const root = document.documentElement; root.classList.remove("light", "dark"); root.classList.add(mode); for (const [key, value] of Object.entries({ ...colors, ...derived })) { root.style.setProperty(key, value, "important"); } `, [theme.colors, getDerivedThemeColors(theme.colors), theme.mode], ); // One frame is enough once transitions are off; the value cannot drift after // style recalculation. await app.execute( `return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));`, ); } async function animatedTabContrastSnapshot(app) { return app.execute(` const rootStyle = getComputedStyle(document.documentElement); const triggers = [ ...document.querySelectorAll('[data-slot="animated-tabs-trigger"]'), ]; const active = triggers.find( (trigger) => trigger.getAttribute("data-state") === "active", ); const inactive = triggers.find( (trigger) => trigger.getAttribute("data-state") === "inactive", ); const content = (trigger) => [...(trigger?.children ?? [])].filter( (child) => child.getAttribute("data-slot") !== "animated-tabs-indicator", ); const activeContent = content(active); const inactiveContent = content(inactive); const indicator = active?.querySelector( '[data-slot="animated-tabs-indicator"]', ); return { mode: document.documentElement.classList.contains("light") ? "light" : "dark", background: rootStyle.getPropertyValue("--background").trim(), accent: rootStyle.getPropertyValue("--accent").trim(), accentForeground: rootStyle .getPropertyValue("--accent-foreground") .trim(), mutedForeground: rootStyle .getPropertyValue("--muted-foreground") .trim(), activeTitle: activeContent[0] ? getComputedStyle(activeContent[0]).color : null, activeCount: activeContent[1] ? getComputedStyle(activeContent[1]).color : null, activeBackground: indicator ? getComputedStyle(indicator).backgroundColor : null, inactiveTitle: inactiveContent[0] ? getComputedStyle(inactiveContent[0]).color : null, inactiveCount: inactiveContent[1] ? getComputedStyle(inactiveContent[1]).color : null, }; `); } function assertColorEquals(actual, expected, description) { assert.ok(actual, `${description} is missing`); assert.equal(Color(actual).hex(), Color(expected).hex(), description); } function assertContrast(foreground, background, minimum, description) { assert.ok(foreground, `${description} foreground is missing`); assert.ok(background, `${description} background is missing`); const ratio = Color(foreground).contrast(Color(background)); assert.ok( ratio >= minimum, `${description} is ${ratio.toFixed(2)}:1; expected at least ${minimum}:1`, ); } async function chooseSelectOption(app, triggerSelector, option) { await app.waitFor( () => app.execute( `const trigger = document.querySelector(arguments[0]); if (!trigger || trigger.matches(":disabled")) return false; trigger.focus(); return document.activeElement === trigger;`, [triggerSelector], ), { description: `enabled select ${triggerSelector}` }, ); await app.pressShortcut({ key: "\uE007" }); await app.waitFor( () => app.execute( `const option = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent.trim() === arguments[0]); option?.focus(); return option && document.activeElement === option;`, [option], ), { description: `select option ${option}` }, ); await app.pressShortcut({ key: "\uE007" }); await app.waitFor( () => app.execute( `return document.querySelector(arguments[0])?.textContent.includes(arguments[1]);`, [triggerSelector, option], ), { description: `selected value ${option}` }, ); } async function saveSettings(app) { await app.clickText("Save Settings", { roles: ["button"] }); await app.waitFor( () => app.execute( `return document.querySelector('[data-slot="settings-feedback"]')?.textContent.includes("Saved");`, ), { description: "Settings to confirm the saved values" }, ); } async function assertThemeAcrossNavigation(app, expected) { for (const surface of ["Network", "Extensions", "Profiles"]) { await app.clickSelector(`[aria-label="${surface}"]`); await app.waitFor( async () => JSON.stringify(await themeSnapshot(app)) === JSON.stringify(expected), { description: `theme to remain unchanged on ${surface}` }, ); } } async function dragBackgroundColorPicker(app) { await app.clickSelector('[aria-label="Background"]'); const drag = await app.waitFor( () => app.execute(` const popover = document.querySelector('[data-slot="popover-content"]'); const selection = [...(popover?.querySelectorAll("div") ?? [])].find( (node) => node.style.background.includes("linear-gradient") ); if (!selection) return null; const rect = selection.getBoundingClientRect(); const points = []; for (const yf of [0.2, 0.4, 0.6, 0.8]) { for (const xf of [0.2, 0.4, 0.6, 0.8]) { const point = { x: Math.round(rect.left + rect.width * xf), y: Math.round(rect.top + rect.height * yf), }; const hit = document.elementFromPoint(point.x, point.y); if (hit === selection || selection.contains(hit)) points.push(point); } } return points.length >= 2 ? { start: points[0], end: points[points.length - 1] } : null; `), { description: "two pointer-interactive background color picker points" }, ); await app.execute(` window.__donutE2eThemePointerEvents = []; for (const type of ["pointermove", "pointerdown", "pointerup"]) { window.addEventListener(type, (event) => { window.__donutE2eThemePointerEvents.push({ type, x: event.clientX, y: event.clientY, buttons: event.buttons, target: event.target?.className ?? event.target?.tagName ?? "", }); }, true); } `); await app.session.command("POST", "/actions", { actions: [ { type: "pointer", id: "theme-color-pointer", actions: [ { type: "pointerMove", x: drag.start.x, y: drag.start.y, origin: "viewport", }, { type: "pointerDown", button: 0 }, { type: "pause", duration: 150 }, { type: "pointerMove", x: drag.end.x, y: drag.end.y, duration: 100, origin: "viewport", }, { type: "pointerUp", button: 0 }, ], }, ], }); const pointerEvents = await app.execute( `return window.__donutE2eThemePointerEvents ?? [];`, ); assert.equal(pointerEvents[0]?.type, "pointermove"); assert.equal(pointerEvents[1]?.type, "pointerdown"); assert.equal(pointerEvents.at(-1)?.type, "pointerup"); const dragMoves = pointerEvents.slice(2, -1); assert.ok(dragMoves.length >= 1); assert.ok(dragMoves.every((event) => event.type === "pointermove")); assert.match(pointerEvents[1].target, /cursor-pointer/); assert.match(dragMoves.at(-1).target, /cursor-pointer/); assert.equal(dragMoves.at(-1).buttons, 1); await app.waitFor( () => app.execute( `return document.querySelector("#theme-preset-select")?.textContent?.includes("Your Own") === true;`, ), { description: `customized theme to be marked as Your Own after ${JSON.stringify(pointerEvents)}`, }, ); await app.clickSelector('[aria-label="Background"]'); await app.waitFor( () => app.execute( `return document.querySelector('[data-slot="popover-content"]') === null;`, ), { description: "color picker to close" }, ); } async function paintedColors(app, probes) { return app.execute( ` // Computed tints come back as color-mix() results with alpha. Paint // them over the page on a 1px canvas to read the colour a person sees. const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; const context = canvas.getContext("2d", { willReadFrequently: true }); const sentinel = "#010203"; const flatten = (layers) => { context.clearRect(0, 0, 1, 1); for (const layer of layers) { context.fillStyle = sentinel; context.fillStyle = layer; if (context.fillStyle === sentinel) return null; context.fillRect(0, 0, 1, 1); } const [r, g, b] = context.getImageData(0, 0, 1, 1).data; return "rgb(" + r + ", " + g + ", " + b + ")"; }; const page = getComputedStyle(document.body).backgroundColor; const result = { page: flatten([page]) }; for (const [name, [selector, text]] of Object.entries(arguments[0])) { const node = [...document.querySelectorAll(selector)].find( (candidate) => !text || candidate.textContent.trim() === text, ); if (!node) { result[name] = null; continue; } const style = getComputedStyle(node); result[name] = { fill: flatten([page, style.backgroundColor]), text: flatten([page, style.backgroundColor, style.color]), }; } return result; `, [probes], ); } function assertQuietTint(painted, page, maximum, description) { assert.ok(painted, `${description} is missing`); const ratio = Color(painted.fill).contrast(Color(page)); assert.ok( ratio >= 1.03 && ratio <= maximum, `${description} tint is ${ratio.toFixed(2)}:1 against the page; expected 1.03 to ${maximum}`, ); assertContrast(painted.text, painted.fill, 4.5, `${description} text`); } test("soft table and creation surfaces stay quiet and readable in every theme", async () => { await withApp( "ui-soft-surfaces", async (app) => { await app.session.command("POST", "/window/rect", { width: 1280, height: 860, }); const tagged = await createUiProfile(app, "Theme Alpha"); await createUiProfile(app, "Theme Beta"); await app.invoke("update_profile_tags", { profileId: tagged.id, tags: ["theme-tag"], }); await app.waitFor( async () => (await tableRows(app, "profiles")).length === 2, { description: "theme table rows" }, ); await app.clickSelector( `tr[data-table-row="${tagged.id}"] [role="checkbox"]`, ); const table = '[data-table-id="profiles"]'; const captured = new Set(["dracula", "tokyo-night", "ayu-light"]); for (const theme of THEMES) { await applyThemeForContrastAudit(app, theme); const colors = await paintedColors(app, { header: [`${table} thead th`, en.profileTable.tagsHeader], selected: [ `${table} tr[data-state="selected"] > td[data-table-column="name"]`, ], button: [`${table} [data-slot="table-view-trigger"]`], tag: [`${table} tbody [data-slot="badge"]`, "theme-tag"], }); assert.ok(colors.page, `${theme.id} page colour`); assertQuietTint(colors.header, colors.page, 1.35, `${theme.id} header`); assertQuietTint( colors.selected, colors.page, 1.6, `${theme.id} selected row`, ); assertQuietTint(colors.button, colors.page, 1.35, `${theme.id} button`); assertQuietTint(colors.tag, colors.page, 1.4, `${theme.id} tag`); if (captured.has(theme.id)) await app.capture(`table-${theme.id}`); } await openProfileCreation(app); await app.fillSelector("#profile-name", "Theme profile"); await app.clickSelector("#enable-password"); await app.fillSelector("#profile-password", "theme password"); for (const theme of THEMES) { await applyThemeForContrastAudit(app, theme); const colors = await paintedColors(app, { off: ["#ephemeral"], on: ["#enable-password"], name: ['[data-slot="profile-name-field"]'], password: ["#profile-password"], }); assertQuietTint(colors.off, colors.page, 1.35, `${theme.id} option`); assertQuietTint( colors.on, colors.page, 1.6, `${theme.id} chosen option`, ); assertQuietTint(colors.name, colors.page, 1.35, `${theme.id} name`); assertQuietTint( colors.password, colors.page, 1.35, `${theme.id} password field`, ); if (captured.has(theme.id)) await app.capture(`creation-${theme.id}`); } }, { seedDownloadedBrowser: true }, ); }); test("the integrations page ships only the Local API tab and never names remote control for a regular desktop", async () => { await withApp("ui-integrations", async (app) => { const modifier = process.platform === "darwin" ? { meta: true } : { ctrl: true }; await app.clickSelector('[aria-label="Integrations"]'); await app.waitForText(en.integrations.tabApi); // Local MCP is gone, and remote control is an Enterprise feature that a // desktop without the entitlement is never told about. This session is // signed out, so a second tab here means the gate opened for everyone. const tabs = async () => app.execute( `return [...document.querySelectorAll('[role="tab"]')].map((node) => node.textContent.trim());`, ); assert.deepEqual(await tabs(), [en.integrations.tabApi]); // The whole document, not just the painted text: a hidden trigger or an // unmounted-looking panel is still a mention. const html = await app.html(); for (const phrase of [ en.integrations.tabRemote, en.integrations.remote.enableLabel, en.integrations.remote.signInRequired, en.integrations.remote.endpointLabel, ]) { assert.ok( !html.includes(phrase), `remote control must stay out of sight: ${JSON.stringify(phrase)}`, ); } assert.equal( (await app.invoke("get_mcp_remote_status")).enabled, false, "opening the page must not open the bridge", ); // Mod+I flips to Remote MCP only for an entitled account, so here it // never lands on a tab that is not offered. const activeTab = async () => app.execute( `return document.querySelector('[role="tab"][data-state="active"]')?.textContent.trim() ?? null;`, ); assert.equal(await activeTab(), en.integrations.tabApi); await app.pressShortcut({ key: "i", ...modifier }); await new Promise((resolve) => setTimeout(resolve, 500)); assert.equal(await activeTab(), en.integrations.tabApi); assert.deepEqual(await tabs(), [en.integrations.tabApi]); await dismissSurface(app); }); }); test("MCP clients on this computer come first, and toolsets go to the connected clients", async () => { await withApp("ui-mcp-clients", async (app) => { await stubCommand(app, "get_remote_control_entitlement", true); await stubCommand(app, "get_mcp_remote_status", { enabled: true, connected: true, instanceId: "ui-mcp-clients", lastError: null, }); await stubCommand(app, "get_mcp_remote_credential", { present: true, tokenPrefix: "dmk_ui", }); const agent = (id, display_name, connected, detected) => ({ id, display_name, category: "cli", connected, detected, token_env: null, }); await stubCommand(app, "list_mcp_agents", [ agent("cursor", "Cursor", false, false), agent("codex", "Codex", false, true), agent("claude-code", "Claude Code", true, true), agent("pi", "Pi", false, true), agent("zed", "Zed", false, false), ]); await stubCommand(app, "add_mcp_to_agent", null); await stubCommand(app, "set_mcp_toolsets", []); const calls = (command) => app.execute( "return (window.__donutStubbedCalls ?? []).filter((c) => c.command === arguments[0]).map((c) => c.payload);", [command], ); const shown = () => app.execute( `return [...document.querySelectorAll('[data-slot="mcp-client"]')].map((node) => node.dataset.agentId);`, ); await app.clickSelector('[aria-label="Integrations"]'); await app.clickText(en.integrations.tabRemote, { roles: ["tab"] }); await app.waitForText(en.integrations.mcp.onThisComputer); assert.deepEqual( await shown(), ["claude-code", "codex", "pi"], "connected first, then the other apps found here; the rest wait", ); await app.clickSelector('[data-slot="mcp-show-more"]'); await app.waitFor(async () => (await shown()).length === 5, { description: "every client after Show more", }); await app.clickSelector('[data-slot="mcp-add-all"]'); await app.waitFor( async () => (await calls("add_mcp_to_agent")).length === 2, { description: "one add per suggested client", }, ); assert.deepEqual( (await calls("add_mcp_to_agent")).map((payload) => payload.agentId), ["codex", "pi"], ); await app.clickSelector('[data-slot="mcp-toolsets"]'); await app.waitForText(en.integrations.mcp.toolsets.title); await app.capture("mcp-toolsets"); await app.clickSelector('[data-toolset="cookies"]'); await app.waitFor( async () => (await calls("set_mcp_toolsets")).length === 1, { description: "the toolset choice saved", }, ); assert.deepEqual((await calls("set_mcp_toolsets"))[0].toolsets, [ "organize", "network", "fingerprint", "extensions", "sync", "remote", "recipes", ]); await dismissSurface(app); await dismissSurface(app); }); }); test("the local API page shows a one-line example and opens runnable examples in a dialog", async () => { await withApp("ui-integrations-examples", async (app) => { 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, }, }); const token = saved.api_token; assert.ok(token?.length >= 32); const port = await app.invoke("start_api_server", { port: 0 }); const baseUrl = `http://127.0.0.1:${port}`; await app.clickSelector('[aria-label="Integrations"]'); // The section label is set in capitals, so innerText reads it that way. await app.waitForText(en.integrations.apiExampleRequest.toUpperCase()); await app.execute( `window.__copied = []; navigator.clipboard.writeText = async (text) => { window.__copied.push(text); };`, ); const copied = () => app.execute("return window.__copied;"); // The page shows only the ends of the token, and the copy carries all of // it, so a pasted command runs as is. const quickStart = '[data-testid="integrations-api-example"]'; const shown = await app.execute( "return document.querySelector(arguments[0]).innerText;", [quickStart], ); assert.ok(!shown.includes(token), "the full token is never on screen"); assert.ok(shown.includes(maskToken(token))); assert.ok(shown.includes(`${baseUrl}/v1/profiles`)); await app.clickSelector( `[data-slot="code-snippet"]:has(${quickStart}) button`, ); await app.waitFor(async () => (await copied()).length === 1, { description: "the quick start was copied", }); assert.equal( (await copied())[0], oneLine(curlSnippet(LOCAL_API_EXAMPLES[0].request, { baseUrl, token })), ); await app.capture("integrations-local-api"); await app.clickText(en.integrations.examples.open); await app.waitForText(en.integrations.examples.apiTitle); for (const example of LOCAL_API_EXAMPLES) { await app.clickSelector(`[data-testid="code-example-${example.id}"]`); await app.waitFor( async () => ( (await dialogText(app, en.integrations.examples.apiTitle)) ?? "" ).includes(en.integrations.examples.items[example.id].description), { description: `${example.id} example` }, ); } // The JavaScript the dialog hands over is real code against the real // server: run the copy and read the profiles it prints. await app.clickSelector('[data-testid="code-example-listProfiles"]'); await app.clickText(en.integrations.examples.languages.javascript, { roles: ["tab"], }); await app.clickSelector( '[data-testid="code-examples-dialog"] [data-slot="code-snippet"] button', ); await app.waitFor(async () => (await copied()).length === 2, { description: "the JavaScript example was copied", }); const javascript = (await copied())[1]; assert.equal( javascript, javascriptSnippet(LOCAL_API_EXAMPLES[0].request, { baseUrl, token }), ); const printed = []; await new (async () => {}).constructor("fetch", "console", javascript)( fetch, { log: (value) => printed.push(value) }, ); assert.equal(printed.length, 1); assert.ok(Array.isArray(printed[0].profiles), JSON.stringify(printed[0])); // The language is the reader's choice and stays as they move on. await app.clickSelector('[data-testid="code-example-createProxy"]'); await app.waitFor( async () => ( (await dialogText(app, en.integrations.examples.apiTitle)) ?? "" ).includes("JSON.stringify({"), { description: "JavaScript stays selected" }, ); await app.capture("integrations-local-api-examples"); await dismissSurface(app); await dismissSurface(app); await app.clickSelector(`[aria-label="${en.rail.account}"]`); await app.waitForText(en.account.signedOutDescription); await app.capture("account-signed-out"); await dismissSurface(app); }); }); test("all primary navigation buttons and sub-page tabs render and remain interactive", async () => { await withApp("ui-navigation", async (app) => { const surfaces = [ ["Settings", /General|Appearance|Sync/i], ["Network", /Proxies|VPNs|DNS/i], ["Extensions", /Extensions|Groups/i], ["Integrations", /API|MCP/i], ["Account", /Account|Sign in/i], ]; for (const [label, expected] of surfaces) { await app.clickSelector(`[aria-label="${label}"]`); await app.waitFor(async () => expected.test(await app.bodyText()), { description: `${label} surface`, }); assert.match(await app.bodyText(), expected); await dismissSurface(app); } await app.clickSelector('[aria-label="Groups"]'); await app.waitForText("Create"); await dismissSurface(app); await app.clickSelector('[aria-label="More"]'); await app.waitFor( () => app.execute(`return Boolean(document.querySelector("[role='menu']"));`), { description: "More menu" }, ); await dismissSurface(app); await app.clickSelector('[aria-label="Profiles"]'); await app.clickText("New"); await app.waitFor( () => app.execute( `return Boolean(document.querySelector("[role='dialog']"));`, ), { description: "new profile dialog" }, ); assert.match(await app.bodyText(), /profile/i); await dismissSurface(app); }); }); test("every custom theme keeps tabs, counts, group pills, and rail states readable", async () => { await withApp("ui-theme-contrast", async (app) => { await app.clickSelector('[aria-label="Extensions"]'); await app.waitFor( () => app.execute( `return document.querySelectorAll('[data-slot="animated-tabs-trigger"]').length === 2;`, ), { description: "Extension tabs" }, ); for (const theme of THEMES) { await applyThemeForContrastAudit(app, theme); const snapshot = await animatedTabContrastSnapshot(app); assert.equal(snapshot.mode, theme.mode, `${theme.id} appearance mode`); assertColorEquals( snapshot.activeBackground, snapshot.accent, `${theme.id} active tab background`, ); for (const [label, color] of [ ["active tab title", snapshot.activeTitle], ["active tab count", snapshot.activeCount], ]) { assertColorEquals( color, snapshot.accentForeground, `${theme.id} ${label} token`, ); assertContrast( color, snapshot.activeBackground, 4.5, `${theme.id} ${label}`, ); } for (const [label, color] of [ ["inactive tab title", snapshot.inactiveTitle], ["inactive tab count", snapshot.inactiveCount], ]) { assertColorEquals( color, snapshot.mutedForeground, `${theme.id} ${label} token`, ); assertContrast(color, snapshot.background, 4.5, `${theme.id} ${label}`); } await app.clickSelector( '[data-slot="animated-tabs-trigger"][data-state="inactive"]', ); await app.waitFor( () => app.execute( `return Boolean(document.querySelector( '[data-slot="animated-tabs-trigger"][data-state="active"] [data-slot="animated-tabs-indicator"]' ));`, ), { description: `${theme.id} tab indicator after switching` }, ); } await app.clickSelector('[aria-label="Groups"]'); await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-slot="group-summary-pill"]'));`, ), { description: "Profile groups summary pill" }, ); for (const theme of THEMES) { await applyThemeForContrastAudit(app, theme); const snapshot = await app.execute(` const rootStyle = getComputedStyle(document.documentElement); const pill = document.querySelector( '[data-slot="group-summary-pill"]', ); const count = pill?.querySelector( '[data-slot="group-summary-count"]', ); const title = pill?.firstElementChild; const rail = document.querySelector('nav [aria-current="page"]'); return { pillBackground: pill ? getComputedStyle(pill).backgroundColor : null, pillTitle: title ? getComputedStyle(title).color : null, pillCount: count ? getComputedStyle(count).color : null, railBackground: rail ? getComputedStyle(rail).backgroundColor : null, railForeground: rail ? getComputedStyle(rail).color : null, accent: rootStyle.getPropertyValue("--accent").trim(), accentForeground: rootStyle .getPropertyValue("--accent-foreground") .trim(), }; `); assertColorEquals( snapshot.pillBackground, snapshot.accent, `${theme.id} group pill background`, ); for (const [label, color] of [ ["group pill title", snapshot.pillTitle], ["group pill count", snapshot.pillCount], ]) { assertColorEquals( color, snapshot.accentForeground, `${theme.id} ${label} token`, ); assertContrast( color, snapshot.pillBackground, 4.5, `${theme.id} ${label}`, ); } assertContrast( snapshot.railForeground, snapshot.railBackground, 3, `${theme.id} selected rail icon`, ); } }); }); test("Xray proxy form keeps the share link as one clear, validated input", async () => { await withApp("ui-vless-proxy-form", async (app) => { const uri = "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&type=tcp#E2E"; await app.clickSelector('[aria-label="Network"]'); await app.waitForText("New proxy"); await app.clickSelector('[aria-label="New proxy"]'); await app.waitForText("Add Proxy"); await app.fillSelector("#proxy-name", "E2E VLESS"); // chooseSelectOption matches the label exactly. await chooseSelectOption(app, "#proxy-type", "VLESS"); assert.equal( await app.execute( `return document.querySelector("#proxy-vless-uri") instanceof HTMLTextAreaElement;`, ), true, ); assert.equal( await app.execute( `return document.querySelector("#proxy-host") === null && document.querySelector("#proxy-port") === null && document.querySelector("#proxy-username") === null && document.querySelector("#proxy-password") === null;`, ), true, ); await app.fillSelector( "#proxy-vless-uri", "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com", ); await app.waitFor( () => app.execute( `return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "true";`, ), { description: "invalid VLESS endpoint feedback" }, ); assert.equal( await app.execute( `return [...document.querySelectorAll("[role='dialog'] button")] .find((button) => button.textContent?.trim() === "Add Proxy") ?.disabled === true;`, ), true, ); // A well-formed link for a setup Donut cannot use must say WHICH part is // unsupported, rather than implying the user mistyped it. await app.fillSelector( "#proxy-vless-uri", `${uri.replace("type=tcp", "type=kcp")}&seed=x`, ); // The hint below the field also says "transport", so only the backend's // refusal, which marks the field invalid, counts here. await app.waitFor( () => app.execute( `return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "true" && /uses a transport/i.test( document.querySelector("#proxy-vless-uri-help")?.textContent || "" );`, ), { description: "transport-specific unsupported message" }, ); // The link names its protocol, and the backend stores the proxy under it, // so the form follows the link before anything is saved. await app.fillSelector( "#proxy-vless-uri", "trojan://pw@tj.example.com:443?sni=tj.example.com", ); await app.waitFor( () => app.execute( `return document.querySelector("#proxy-type")?.textContent?.trim() === "Trojan";`, ), { description: "type follows a Trojan link" }, ); await app.fillSelector("#proxy-vless-uri", uri); // The field is not marked invalid while the backend is still reading the // link, so the enabled button is what proves it was accepted. await app.waitFor( () => app.execute( `return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "false" && [...document.querySelectorAll("[role='dialog'] button")] .find((button) => button.textContent?.trim() === "Add Proxy") ?.disabled === false;`, ), { description: "valid VLESS endpoint feedback" }, ); await app.clickTextIn('[role="dialog"]', "Add Proxy", { roles: ["button"], }); await app.waitForText("E2E VLESS"); const proxy = (await app.invoke("get_stored_proxies")).find( (item) => item.name === "E2E VLESS", ); assert.ok(proxy); assert.equal(proxy.proxy_settings.proxy_type, "vless"); assert.equal(proxy.proxy_settings.host, "vpn.example.com"); assert.equal(proxy.proxy_settings.port, 443); assert.equal(proxy.proxy_settings.username, null); assert.equal(proxy.proxy_settings.password, null); assert.match(proxy.proxy_settings.vless_uri, /^vless:\/\//); await app.invoke("delete_stored_proxy", { proxyId: proxy.id }); }); }); test("pasting a proxy string into the form fills every field", async () => { await withApp("ui-proxy-form-paste", async (app) => { // Dispatched rather than typed: the point is that the paste is spread // across the form instead of landing whole in the field it was dropped on, // and only a real ClipboardEvent carries the text the handler reads. const paste = (selector, text) => app.execute( `const field = document.querySelector(arguments[0]); const data = new DataTransfer(); data.setData("text/plain", arguments[1]); field.focus(); return field.dispatchEvent( new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data, }), );`, [selector, text], ); const fieldValues = () => app.execute( `return ["#proxy-name", "#proxy-host", "#proxy-port", "#proxy-username", "#proxy-password"] .map((selector) => document.querySelector(selector)?.value ?? null);`, ); await app.clickSelector('[aria-label="Network"]'); await app.waitForText("New proxy"); await app.clickSelector('[aria-label="New proxy"]'); await app.waitForText("Add Proxy"); await paste("#proxy-host", "socks5://carol:s3cret@1.2.3.4:1080"); await app.waitFor(async () => (await fieldValues())[1] === "1.2.3.4", { description: "host filled from the pasted proxy", }); assert.deepEqual(await fieldValues(), [ "1.2.3.4:1080", "1.2.3.4", "1080", "carol", "s3cret", ]); assert.equal( await app.execute( `return document.querySelector("#proxy-type")?.textContent?.trim();`, ), "SOCKS5", ); // No scheme in the line, so the type falls back to HTTP. await paste("#proxy-name", "5.6.7.8:8080:dave:hunter2"); await app.waitFor(async () => (await fieldValues())[1] === "5.6.7.8", { description: "scheme-less proxy string parsed", }); assert.deepEqual((await fieldValues()).slice(1), [ "5.6.7.8", "8080", "dave", "hunter2", ]); assert.equal( await app.execute( `return document.querySelector("#proxy-type")?.textContent?.trim();`, ), "HTTP", ); // A bare hostname is not a proxy string, so the form is left alone and the // browser's own paste stands. await paste("#proxy-host", "proxy.example.com"); // Settle the parse round-trip, so "nothing changed" isn't just "nothing // has come back yet". await app.invoke("get_stored_proxies"); assert.deepEqual((await fieldValues()).slice(1), [ "5.6.7.8", "8080", "dave", "hunter2", ]); }); }); test("About exposes a searchable, responsive third-party license inventory", async () => { await withApp("ui-about-licenses", async (app) => { await app.clickSelector('[aria-label="More"]'); await app.waitFor( () => app.execute(`return Boolean(document.querySelector("[role='menu']"));`), { description: "More menu" }, ); await app.clickText("About Donut Browser", { exact: false, roles: ["menuitem"], }); await app.waitForText("Open-source anti-detect browser."); const aboutText = await app.execute( `return document.querySelector("[role='dialog']")?.textContent ?? "";`, ); assert.doesNotMatch(aboutText, /AGPL-3\.0|licensed under/i); await app.clickText("Licenses", { roles: ["button"] }); await app.waitFor( () => app.execute( `return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "Licenses";`, ), { description: "Licenses view" }, ); await app.session.command("POST", "/window/rect", { width: 640, height: 400, }); const inventory = await app.execute(` const dialog = document.querySelector("[role='dialog']"); const list = dialog?.querySelector(".scroll-fade"); const search = dialog?.querySelector('input[type="search"]'); const rows = [...(dialog?.querySelectorAll("li") ?? [])].map((row) => [...row.children].map((child) => (child.textContent || "").trim()) ); const rect = dialog?.getBoundingClientRect(); return { activeSearch: document.activeElement === search, rows, scrollable: Boolean(list && list.scrollHeight > list.clientHeight), bounds: rect ? { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, viewportWidth: innerWidth, viewportHeight: innerHeight, } : null, }; `); assert.equal(inventory.activeSearch, true); assert.equal(inventory.scrollable, true); assert.ok(inventory.bounds); assert.ok(inventory.bounds.left >= 0); assert.ok(inventory.bounds.top >= 0); assert.ok(inventory.bounds.right <= inventory.bounds.viewportWidth); assert.ok(inventory.bounds.bottom <= inventory.bounds.viewportHeight); assert.ok( inventory.rows.some( ([name, license]) => name === "Xray-core" && license === "MPL-2.0", ), ); assert.ok( inventory.rows.some( ([name, license]) => name === "Donut Browser" && license === "AGPL-3.0-only", ), ); assert.ok( inventory.rows.some( ([name, license]) => name === "tauri-plugin-opener" && license === "Apache-2.0 OR MIT", ), ); assert.ok(inventory.rows.every((row) => row.length === 2)); const search = await app.session.findCss('input[type="search"]'); await app.session.sendKeys(search, "xray"); await app.waitFor( () => app.execute( `return document.querySelectorAll("[role='dialog'] li").length === 1;`, ), { description: "license search result" }, ); assert.match( await app.execute( `return document.querySelector("[role='dialog'] li")?.textContent ?? "";`, ), /Xray-core.*MPL-2\.0/s, ); await app.clickSelector('button[aria-label="Back"]'); await app.waitFor( () => app.execute( `return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "About";`, ), { description: "About view after returning from licenses" }, ); assert.equal( await app.execute( `return document.activeElement?.textContent?.trim() === "Licenses";`, ), true, ); }); }); test("settings tabs, command palette filtering, and responsive layout survive resize", async () => { await withApp("ui-settings-responsive", async (app) => { await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); for (const tab of ["Appearance", "Sync", "Encryption"]) { const exists = await app.execute( `return [...document.querySelectorAll("[role='tab']")].some( (node) => (node.textContent || "").trim() === arguments[0] );`, [tab], ); if (exists) { await app.clickText(tab, { roles: ["tab"] }); await app.waitFor( () => app.execute( `return [...document.querySelectorAll("[role='tab']")].some( (node) => (node.textContent || "").trim() === arguments[0] && node.getAttribute("data-state") === "active" );`, [tab], ), { description: `${tab} settings tab` }, ); } } await dismissSurface(app); const modifier = process.platform === "darwin" ? { meta: true } : { ctrl: true }; await app.pressShortcut({ key: "k", ...modifier }); await app.waitFor( () => app.execute(`return Boolean(document.querySelector("[cmdk-input]"));`), { description: "command palette" }, ); const input = await app.session.findCss("[cmdk-input]"); await app.session.sendKeys(input, "proxy vpn"); assert.match(await app.bodyText(), /Network|Proxy|VPN/i); await dismissSurface(app); // The native driver owns the top-level window. Resize through the WebDriver // protocol and assert the app still has usable controls at the minimum size. await app.session.command("POST", "/window/rect", { width: 640, height: 400, }); const viewport = await app.execute( "return { width: innerWidth, height: innerHeight };", ); assert.ok(viewport.width >= 600); assert.ok(viewport.height >= 350); assert.equal( await app.execute( `return document.querySelector('[aria-label="Settings"]').getBoundingClientRect().width > 0;`, ), true, ); }); }); test("first-run onboarding stays recoverable, responsive, and platform-aware", async () => { await withApp( "ui-onboarding", async (app) => { await app.waitForText("Welcome to Donut Browser"); assert.equal(await app.invoke("get_onboarding_completed"), false); await app.session.command("POST", "/window/rect", { width: 640, height: 400, }); const layout = await app.execute(` const dialog = document.querySelector("[role='dialog']"); const progress = dialog?.querySelector("[role='progressbar']"); if (!dialog || !progress) return null; const rect = dialog.getBoundingClientRect(); return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, viewportWidth: innerWidth, viewportHeight: innerHeight, progressNow: progress.getAttribute("aria-valuenow"), }; `); assert.ok(layout); assert.ok(layout.left >= 0 && layout.right <= layout.viewportWidth); assert.ok(layout.top >= 0 && layout.bottom <= layout.viewportHeight); assert.equal(layout.progressNow, "1"); await app.clickText("Next", { roles: ["button"] }); await app.waitForText("Licensing"); await app.clickText("I understand", { roles: ["button"] }); await app.waitFor( async () => /Allow microphone & camera|Setting things up|Setup failed/.test( await app.bodyText(), ), { description: "platform-appropriate onboarding step" }, ); const body = await app.bodyText(); if (process.platform !== "darwin") { assert.doesNotMatch(body, /Allow microphone & camera/); assert.match(body, /Setting things up|Setup failed/); } // Setup and the optional product tour are still unfinished, so a crash // or restart must be able to resume onboarding. assert.equal(await app.invoke("get_onboarding_completed"), false); }, { onboardingCompleted: false }, ); }); test("interface sounds turn on and off in Appearance and stay as saved", async () => { await withApp("ui-sounds-setting", async (app) => { const sounds = async () => (await app.invoke("get_app_settings")).ui_sounds; assert.equal(await sounds(), false, "the harness keeps the suite silent"); await app.clickSelector('[aria-label="Settings"]'); await app.waitForText(en.settings.appearance.soundsDescription); const toggle = `[role="switch"][aria-label="${en.settings.appearance.sounds}"]`; await app.clickSelector(toggle); await saveSettings(app); assert.equal(await sounds(), true); await app.clickSelector(toggle); await saveSettings(app); assert.equal(await sounds(), false); await app.restart(); assert.equal(await sounds(), false, "the choice survives a restart"); }); }); test("predefined theme remains rendered across navigation and restart", async () => { await withApp("ui-theme-predefined", async (app) => { await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); await chooseSelectOption(app, "#theme-select", "Light"); await saveSettings(app); const persisted = await app.invoke("get_app_settings"); assert.equal(persisted.theme, "light"); const selected = await waitForTheme( app, (snapshot) => snapshot.mode === "light" && Object.values(snapshot.inline).every((value) => value === ""), "predefined light theme to render without custom variables", ); assert.notEqual(selected.bodyBackground, ""); assert.notEqual(selected.bodyForeground, ""); await assertThemeAcrossNavigation(app, selected); await app.restart(); assert.equal((await app.invoke("get_app_settings")).theme, "light"); await app.waitFor( async () => JSON.stringify(await themeSnapshot(app)) === JSON.stringify(selected), { description: "predefined light theme after restart" }, ); }); }); test("preset and manually customized themes survive navigation and restart", async () => { await withApp("ui-theme-custom", async (app) => { await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); await chooseSelectOption(app, "#theme-select", "Custom"); await chooseSelectOption(app, "#theme-preset-select", "Dracula"); await saveSettings(app); const presetSettings = await app.invoke("get_app_settings"); assert.equal(presetSettings.theme, "custom"); assert.deepEqual(presetSettings.custom_theme, DRACULA_THEME); const preset = await waitForTheme( app, (snapshot) => snapshot.mode === "dark" && themeVariablesEqual(snapshot.inline, DRACULA_THEME) && themeVariablesEqual(snapshot.resolved, DRACULA_THEME), "Dracula preset variables to render", ); await assertThemeAcrossNavigation(app, preset); await app.restart(); assert.deepEqual( (await app.invoke("get_app_settings")).custom_theme, DRACULA_THEME, ); await app.waitFor( async () => JSON.stringify(await themeSnapshot(app)) === JSON.stringify(preset), { description: "Dracula preset after restart" }, ); await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); assert.equal( await app.execute( `return document.querySelector("#theme-select")?.textContent?.trim();`, ), "Custom", ); assert.equal( await app.execute( `return document.querySelector("#theme-preset-select")?.textContent?.trim();`, ), "Dracula", ); await dragBackgroundColorPicker(app); await saveSettings(app); const customizedSettings = await app.invoke("get_app_settings"); assert.equal(customizedSettings.theme, "custom"); assert.notEqual( customizedSettings.custom_theme["--background"].toLowerCase(), DRACULA_THEME["--background"], ); assert.deepEqual( Object.keys(customizedSettings.custom_theme).sort(), [...THEME_VARIABLES].sort(), ); const customized = await waitForTheme( app, (snapshot) => snapshot.mode === "dark" && themeVariablesEqual(snapshot.inline, customizedSettings.custom_theme) && themeVariablesEqual(snapshot.resolved, customizedSettings.custom_theme), "manually customized variables to render", ); assert.notEqual(customized.bodyBackground, preset.bodyBackground); await assertThemeAcrossNavigation(app, customized); await app.restart(); assert.deepEqual( (await app.invoke("get_app_settings")).custom_theme, customizedSettings.custom_theme, ); await app.waitFor( async () => JSON.stringify(await themeSnapshot(app)) === JSON.stringify(customized), { description: "manually customized theme after restart" }, ); }); }); test("a light custom preset keeps light component behavior after restart", async () => { await withApp("ui-theme-custom-light", async (app) => { await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); await chooseSelectOption(app, "#theme-select", "Custom"); await chooseSelectOption(app, "#theme-preset-select", "Ayu Light"); await saveSettings(app); const selected = await waitForTheme( app, (snapshot) => snapshot.mode === "light" && themeVariablesEqual(snapshot.inline, AYU_LIGHT_THEME) && themeVariablesEqual(snapshot.resolved, AYU_LIGHT_THEME), "Ayu Light variables and light mode to render", ); await assertThemeAcrossNavigation(app, selected); await app.restart(); assert.deepEqual( (await app.invoke("get_app_settings")).custom_theme, AYU_LIGHT_THEME, ); await app.waitFor( async () => JSON.stringify(await themeSnapshot(app)) === JSON.stringify(selected), { description: "Ayu Light preset after restart" }, ); }); }); const EXTENSION_STRINGS = en.extensions; /** * Answer the native directory picker from inside the webview. * * "Load unpacked" calls `open({ directory: true })` from * `@tauri-apps/plugin-dialog`, which puts an OS window on screen that no * WebDriver can reach. The call leaves the page as the `plugin:dialog|open` IPC * command, but Tauri locks its own entry points down: `invoke`, `ipc` and * `postMessage` are all installed with * `Object.defineProperty(window.__TAURI_INTERNALS__, name, { value })`, so they * are non-writable and cannot be wrapped. The seam underneath them is the * transport, which POSTs the command through `fetch` to * `ipc://localhost/`. Answering that one request with the shape Tauri * expects (`Tauri-Response: ok` plus a JSON body) resolves the picker with a * folder and needs no test-only hook in the production component. Every other * command still reaches the real backend. */ async function stubFolderPicker(app, folder) { await app.execute( `const folder = arguments[0]; if (!window.__donutOriginalFetch) { window.__donutOriginalFetch = window.fetch; } window.__donutFolderPickerCalls = []; window.fetch = function (input, init) { const url = String( typeof input === "string" ? input : (input && input.url) || "", ); let command = ""; try { command = decodeURIComponent(url.split("/").pop() || ""); } catch (_error) { command = ""; } if (command === "plugin:dialog|open") { let payload = null; try { payload = JSON.parse((init && init.body) || "null"); } catch (_error) { payload = null; } window.__donutFolderPickerCalls.push(payload); return Promise.resolve( new Response(JSON.stringify(folder), { status: 200, headers: { "content-type": "application/json", "Tauri-Response": "ok", }, }), ); } return window.__donutOriginalFetch.apply(window, arguments); }; return true;`, [folder], ); } /** Restores the real transport and returns what the picker was asked for. */ async function restoreFolderPicker(app) { return app.execute( `const calls = window.__donutFolderPickerCalls ?? []; if (window.__donutOriginalFetch) { window.fetch = window.__donutOriginalFetch; delete window.__donutOriginalFetch; } delete window.__donutFolderPickerCalls; return calls;`, ); } async function openExtensionsPage(app) { await app.clickSelector('[aria-label="Extensions"]'); await app.waitFor( () => app.execute(`return Boolean(document.querySelector(arguments[0]));`, [ `[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`, ]), { description: "extension management page" }, ); } async function stageUnpackedFolder(app, folder) { await stubFolderPicker(app, folder); await app.clickSelector(`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`); await app.waitFor( () => app.execute( `return Boolean(document.querySelector("#ext-link-folder"));`, ), { description: "staged folder import form (the intercepted directory picker has to resolve)", }, ); } async function uploadArchiveThroughUi(app, archivePath, typedName) { // The real control is a hidden file input a button clicks for the user; // WebDriver can only type a path into an input it can see. await app.execute(` const input = document.querySelector("#ext-file-input"); input.classList.remove("hidden"); input.style.position = "fixed"; input.style.left = "12px"; input.style.bottom = "12px"; `); const input = await app.session.findCss("#ext-file-input"); await app.session.sendKeys(input, archivePath); await app.waitForText(path.basename(archivePath)); await app.execute(` const input = document.querySelector("#ext-file-input"); input.classList.add("hidden"); input.removeAttribute("style"); `); await app.fillSelector( `input[placeholder="${EXTENSION_STRINGS.namePlaceholder}"]`, typedName, ); await app.clickText(en.common.buttons.add, { roles: ["button"] }); } /** The link checkbox plus the copy that is supposed to explain it. */ async function linkCheckboxState(app, id) { return app.execute( `const checkbox = document.querySelector("#" + arguments[0]); const label = document.querySelector('label[for="' + arguments[0] + '"]'); const help = label?.parentElement?.querySelector("p"); return checkbox ? { checked: checkbox.getAttribute("data-state") === "checked", label: (label?.innerText ?? "").trim(), help: (help?.innerText ?? "").trim(), } : null;`, [id], ); } function extensionRowScript(body) { return `const wanted = arguments[0]; const row = [...document.querySelectorAll("tbody tr")].find((candidate) => { const cells = [...candidate.querySelectorAll("td")]; return cells.length >= 7 && (cells[2].querySelector("button > span")?.textContent || cells[2].innerText || "").trim() === wanted; }); ${body}`; } async function extensionRow(app, name) { return app.execute( extensionRowScript(`if (!row) return null; const cells = [...row.querySelectorAll("td")]; const sync = row.querySelector('[data-slot="animated-switch"]'); return { name: (cells[2].querySelector("button > span")?.textContent || cells[2].innerText || "").trim(), source: (cells[4].innerText || "").trim(), syncChecked: sync ? sync.getAttribute("data-state") === "checked" : null, syncDisabled: sync ? sync.disabled === true : null, };`), [name], ); } async function extensionEditButton(app, name) { return app.execute( extensionRowScript( `return row ? row.querySelector("td:last-child button") : null;`, ), [name], ); } async function dialogText(app, title) { return app.execute( `const wanted = arguments[0]; const dialog = [...document.querySelectorAll("[role='dialog']")] .reverse() .find((node) => [...node.querySelectorAll("[data-slot='dialog-title']")].some( (heading) => (heading.textContent || "").trim() === wanted, ), ); return dialog ? (dialog.innerText || "").trim() : null;`, [title], ); } async function toastTexts(app) { return app.execute( `return [...document.querySelectorAll("[data-sonner-toast]")] .map((toast) => (toast.innerText || "").trim()) .filter(Boolean);`, ); } test("an uploaded archive and a loaded folder both import, each under its own source", async () => { await withApp("ui-extension-import-sources", async (app) => { const archivePath = path.join(app.root, "ui-archive-extension.zip"); await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64")); const folder = await writeUnpackedExtension( path.join(app.root, "fixtures", "ui-copied-extension"), { name: "Donut UI Copied Folder" }, ); await openExtensionsPage(app); await uploadArchiveThroughUi( app, archivePath, "Overridden By The Manifest", ); await app.waitForText("Donut E2E Fixture"); await stageUnpackedFolder(app, folder); assert.ok(await app.visibleTextIncludes(EXTENSION_STRINGS.selectedFolder)); assert.ok( await app.visibleTextIncludes(folder), "the staged import has to name the folder it is about to read", ); const staged = await linkCheckboxState(app, "ext-link-folder"); assert.equal(staged?.checked, false, "linking a folder has to be opt-in"); assert.equal(staged.help, EXTENSION_STRINGS.linkFolderOff); await app.clickText(en.common.buttons.add, { roles: ["button"] }); await app.waitForText("Donut UI Copied Folder"); const pickerCalls = await restoreFolderPicker(app); assert.equal(pickerCalls.length, 1, "Load unpacked has to open the picker"); assert.equal(pickerCalls[0].options.directory, true); assert.equal(pickerCalls[0].options.multiple, false); assert.equal( pickerCalls[0].options.title, EXTENSION_STRINGS.selectFolderTitle, ); assert.notEqual( EXTENSION_STRINGS.source.archive, EXTENSION_STRINGS.source.unpacked, ); assert.equal( (await extensionRow(app, "Donut E2E Fixture"))?.source, EXTENSION_STRINGS.source.archive, ); assert.equal( (await extensionRow(app, "Donut UI Copied Folder"))?.source, EXTENSION_STRINGS.source.unpacked, ); const extensions = await app.invoke("list_extensions"); assert.equal(extensions.length, 2); const copied = extensions.find( (extension) => extension.name === "Donut UI Copied Folder", ); assert.equal(copied.source_kind, "unpacked"); assert.equal( copied.linked_path, null, "an unlinked folder import is copied into the store, not pointed at", ); assert.equal(copied.file_type, "zip"); const archive = extensions.find( (extension) => extension.name === "Donut E2E Fixture", ); assert.equal(archive.source_kind, "archive"); assert.equal(archive.linked_path, null); }); }); test("linking a folder says what it costs, records the path, and locks that row's sync off", async () => { await withApp("ui-extension-linked-folder", async (app) => { await app.invoke("add_extension", { name: "Copied Neighbour", fileName: "ui-neighbour-extension.zip", fileData: [...Buffer.from(extensionZipBase64(), "base64")], }); const folder = await writeUnpackedExtension( path.join(app.root, "fixtures", "ui-linked-extension"), { name: "Donut UI Linked Folder" }, ); await openExtensionsPage(app); await app.waitForText("Donut E2E Fixture"); await stageUnpackedFolder(app, folder); const off = await linkCheckboxState(app, "ext-link-folder"); assert.equal(off?.checked, false); assert.equal(off.label, EXTENSION_STRINGS.linkFolder); assert.equal(off.help, EXTENSION_STRINGS.linkFolderOff); await app.clickSelector("#ext-link-folder"); const on = await app.waitFor( async () => { const state = await linkCheckboxState(app, "ext-link-folder"); return state?.checked ? state : false; }, { description: "link checkbox to turn on" }, ); assert.equal(on.help, EXTENSION_STRINGS.linkFolderOn); assert.notEqual( on.help, off.help, "the checkbox has to say what turning it on changes", ); await app.clickText(en.common.buttons.add, { roles: ["button"] }); await app.waitForText("Donut UI Linked Folder"); assert.equal((await restoreFolderPicker(app)).length, 1); const linkedRow = await extensionRow(app, "Donut UI Linked Folder"); assert.equal(linkedRow?.source, EXTENSION_STRINGS.source.linked); assert.equal(linkedRow.syncChecked, false); assert.equal(linkedRow.syncDisabled, true); assert.equal( (await extensionRow(app, "Donut E2E Fixture"))?.syncDisabled, false, "only the linked row loses its sync control", ); const linked = (await app.invoke("list_extensions")).find( (extension) => extension.name === "Donut UI Linked Folder", ); assert.equal(linked.linked_path, await realpath(folder)); assert.equal(linked.file_type, "unpacked"); assert.equal(linked.sync_enabled, false); }); }); test("the edit dialog replaces an extension's payload from a folder", async () => { await withApp("ui-extension-replace-from-folder", async (app) => { const original = await app.invoke("add_extension", { name: "Replaced Later", fileName: "ui-original-extension.zip", fileData: [...Buffer.from(extensionZipBase64(), "base64")], }); assert.equal(original.version, "1.0.0"); const folder = await writeUnpackedExtension( path.join(app.root, "fixtures", "ui-replacement-extension"), { name: "Donut UI Replacement", version: "3.1.4" }, ); await openExtensionsPage(app); await app.waitForText("Donut E2E Fixture"); const editButton = await extensionEditButton(app, "Donut E2E Fixture"); assert.ok(editButton, "the extension row's edit control was not visible"); await app.clickElement(editButton, "extension edit button"); const beforeReplace = await app.waitFor( () => dialogText(app, EXTENSION_STRINGS.editExtension), { description: "extension edit dialog" }, ); assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.label)); assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.archive)); await stubFolderPicker(app, folder); await app.clickTextIn('[role="dialog"]', EXTENSION_STRINGS.selectFolder, { roles: ["button"], }); await app.waitFor( async () => (await dialogText(app, EXTENSION_STRINGS.editExtension))?.includes( folder, ), { description: "chosen replacement folder" }, ); const replaceLink = await linkCheckboxState(app, "ext-edit-link-folder"); assert.equal(replaceLink?.checked, false); assert.equal(replaceLink.help, EXTENSION_STRINGS.linkFolderOff); await app.clickTextIn('[role="dialog"]', en.common.buttons.save, { roles: ["button"], }); await app.waitFor( async () => (await toastTexts(app)).some((text) => text.includes(EXTENSION_STRINGS.updateSuccess), ), { description: "extension update confirmation" }, ); assert.equal((await restoreFolderPicker(app)).length, 1); const extensions = await app.invoke("list_extensions"); assert.equal( extensions.length, 1, "replacing a payload must not add a second extension", ); const [updated] = extensions; assert.equal(updated.id, original.id); assert.equal(updated.source_kind, "unpacked"); assert.equal(updated.linked_path, null); assert.equal(updated.file_name, "ui-replacement-extension.zip"); assert.equal(updated.version, "3.1.4"); // The dialog's own name field stays authoritative, so the row keeps its // name while the payload underneath it is swapped. assert.equal(updated.name, "Donut E2E Fixture"); await app.waitFor( async () => (await extensionRow(app, "Donut E2E Fixture"))?.source === EXTENSION_STRINGS.source.unpacked, { description: "replaced row to report its new source" }, ); }); }); test("a folder with no manifest fails with the translated reason, not a raw code", async () => { await withApp("ui-extension-manifest-missing", async (app) => { const folder = path.join(app.root, "fixtures", "ui-not-an-extension"); await mkdir(folder, { recursive: true }); await writeFile(path.join(folder, "readme.txt"), "no manifest here\n"); await openExtensionsPage(app); await stageUnpackedFolder(app, folder); await app.clickText(en.common.buttons.add, { roles: ["button"] }); const expected = en.backendErrors.extensionManifestMissing; await app.waitFor( async () => (await toastTexts(app)).some((text) => text.includes(expected)), { description: "translated manifest-missing toast" }, ); const toasts = await toastTexts(app); assert.ok( toasts.every((text) => !text.includes("EXTENSION_MANIFEST_MISSING")), `a raw backend code reached the user: ${JSON.stringify(toasts)}`, ); assert.ok( toasts.every((text) => !text.includes(EXTENSION_STRINGS.uploadFailed)), "the generic fallback would hide which folder problem this was", ); assert.deepEqual(await app.invoke("list_extensions"), []); assert.equal((await restoreFolderPicker(app)).length, 1); }); }); test("the agent page guides setup, then shows agents, requests, notes, take-overs and the pause live", async () => { await withApp( "ui-agent", async (app) => { await app.waitForText("No profiles yet"); const painted = (testId) => app.execute( `const el = document.querySelector('[data-testid="' + arguments[0] + '"]'); if (!el) return null; const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && Number(style.opacity) === 1 && style.visibility === "visible" ? el.innerText.trim() : null;`, [testId], ); const waitPainted = (testId) => app.waitFor(() => painted(testId), { description: testId }); const absent = (testId) => app.waitFor( () => app.execute( `return !document.querySelector('[data-testid="' + arguments[0] + '"]');`, [testId], ), { description: `${testId} gone` }, ); // Signed out, the page says what is missing instead of showing controls. await app.clickSelector(`[aria-label="${en.rail.agent}"]`); const signedOut = await waitPainted("agent-setup-signed-out"); assert.match(signedOut, new RegExp(en.agent.setup.signedOutTitle)); assert.equal(await painted("agent-pause-switch"), null); // Leaving the page unmounts it, so the next visit reads the stubs below. await app.clickSelector(`[aria-label="${en.rail.profiles}"]`); await absent("agent-page"); const profile = await createUiProfile(app, "Agent UI Profile"); const now = Date.now(); try { await stubCommand(app, "cloud_get_user", { logged_in_at: new Date().toISOString(), user: { id: "ui-agent", email: "agent@example.test", plan: "pro", planPeriod: "monthly", subscriptionStatus: "active", profileLimit: 50, cloudProfilesUsed: 0, proxyBandwidthLimitMb: 0, proxyBandwidthUsedMb: 0, proxyBandwidthExtraMb: 0, isPrimaryDevice: true, }, }); await stubCommand(app, "get_mcp_remote_status", { enabled: true, connected: true, instanceId: "ui-agent-instance", lastError: null, }); const base = { session_id: "ui-s1", profile_id: null, choices: [], answer: null, answered_at: null, delivered_to: [], done: null, total: null, }; const question = { ...base, id: 9001, at: now - 5000, kind: "question", text: "Which shipping address should I use?", choices: ["Home", "Office"], state: "open", }; const help = { ...base, id: 9002, at: now - 4000, kind: "help", text: "Solve the verification on the sign-in page", profile_id: profile.id, state: "open", }; await stubCommand(app, "get_agent_console", { sessions: [ { session_id: "ui-s1", client_name: "Claude Code", client_version: "2.1.0", connected_at: now - 60_000, last_seen_at: now - 1000, calls: 3, errors: 1, ended: false, status: { message: "Checking orders", done: 2, total: 5, profile_id: null, updated_at: now - 2000, }, }, ], activity: [ { id: 9100, at: now - 3000, session_id: "ui-s1", tool: "navigate", profile_id: profile.id, profile_count: null, ok: true, error_code: null, duration_ms: 420, detail: "shop.example.com", }, { id: 9101, at: now - 2500, session_id: "ui-s1", tool: "click_locator", profile_id: profile.id, profile_count: null, ok: false, error_code: "LOCATOR_NO_MATCH", duration_ms: 90, detail: null, }, ], thread: [question, help], holds: [ { profile_id: profile.id, since: now - 4000, note: null, request_id: 9002, }, ], paused: null, quota: { limit: 2000, used: 240, resets_in_secs: 1200 }, }); await stubCommand(app, "answer_agent_request", { ...question, state: "answered", answer: "Office", answered_at: now, }); await app.invoke("plugin:event|emit", { event: "cloud-auth-changed", payload: null, }); await app.invoke("plugin:event|emit", { event: "agent-console-cleared", payload: null, }); // Two open requests reach the rail before the page is opened. await app.waitFor( async () => (await painted("agent-rail-badge")) === "2", { description: "the rail badge counts open requests" }, ); const waitingLabel = en.rail.agentWaiting.replace("{{count}}", "2"); await app.clickSelector(`[aria-label="${waitingLabel}"]`); await waitPainted("agent-needs-you"); // The page holds the seeded snapshot now; later reads go to the backend. await app.execute(`delete window.__donutStubs.get_agent_console;`); await app.capture("agent-conversation"); // The Conversation tab carries the open-request count after its label. assert.deepEqual( await app.execute( `return [...document.querySelectorAll('[role="tab"]')].map((node) => node.textContent.trim().replace(/\\d+$/, ""));`, ), [ en.agent.tabs.conversation, en.agent.tabs.profiles, en.agent.tabs.activity, en.agent.tabs.recipes, ], ); assert.match(await painted("agent-quota"), /1[,.\s ]?760/); assert.match(await painted("agent-session-ui-s1"), /Claude Code/); assert.match(await painted("agent-session-ui-s1"), /Checking orders/); assert.match( await painted("agent-request-9001"), /Which shipping address should I use\?/, ); assert.match( await painted("agent-request-9002"), /Solve the verification on the sign-in page/, ); assert.equal( await app.execute( `return document.querySelectorAll('[data-testid="agent-request-9001"] [data-testid="agent-choice"]').length;`, ), 2, ); // A suggested answer is one click, and the card leaves the list. await app.clickText("Office", { roles: ["button"] }); await absent("agent-request-9001"); const answered = await app.execute( `return (window.__donutStubbedCalls ?? []).filter((c) => c.command === "answer_agent_request").map((c) => c.payload);`, ); assert.deepEqual(answered, [{ requestId: 9001, answer: "Office" }]); // Real backend events update the open page: a note, then the pause. await app.invoke("send_agent_note", { text: "Use the EU shipping profile today", }); await app.waitFor( async () => ((await painted("agent-thread")) ?? "").includes( "Use the EU shipping profile today", ), { description: "the note in the thread" }, ); await app.invoke("set_agents_paused", { paused: true, note: "Changing proxies", }); assert.match( await waitPainted("agent-paused-banner"), /Changing proxies/, ); await app.clickSelector('[data-testid="agent-resume"]'); await absent("agent-paused-banner"); assert.equal((await app.invoke("get_agent_console")).paused, null); // Profiles: the agent's profile is listed with its last error, and the // person can take it over and hand it back. await app.clickSelector('[data-testid="agent-tab-profiles"]'); const row = await waitPainted(`agent-profile-${profile.id}`); assert.match(row, /Agent UI Profile/); assert.match(row, /LOCATOR_NO_MATCH/); assert.deepEqual( await offCenterSelectionControls( app, '[data-testid="agent-profiles"]', ), [], ); await app.clickSelector( `[data-testid="agent-profile-${profile.id}"] [data-testid="agent-take-over"]`, ); await app.fillSelector( '[data-testid="agent-take-over-note"]', "Fixing the address", ); await app.clickSelector('[data-testid="agent-take-over-confirm"]'); await app.waitFor( async () => (await app.invoke("get_agent_console")).holds.some( (hold) => hold.profile_id === profile.id && hold.note === "Fixing the address", ), { description: "the take-over reached the backend" }, ); await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-testid="agent-profile-' + arguments[0] + '"] [data-testid="agent-hand-back"]'));`, [profile.id], ), { description: "the row offers hand back" }, ); await app.clickSelector( `[data-testid="agent-profile-${profile.id}"] [data-testid="agent-hand-back"]`, ); await app.clickSelector('[data-testid="agent-hand-back-confirm"]'); await app.waitFor( async () => (await app.invoke("get_agent_console")).holds.length === 0, { description: "the hand-back reached the backend" }, ); await app.clickSelector('[data-testid="agent-tab-activity"]'); const activity = await waitPainted("agent-activity-list"); assert.match(activity, /shop\.example\.com/); assert.match(activity, /LOCATOR_NO_MATCH/); await app.capture("agent-page"); await dismissSurface(app); // The palette reaches the same page. const modifier = process.platform === "darwin" ? { meta: true } : { ctrl: true }; await app.pressShortcut({ key: "k", ...modifier }); await app.waitFor( () => app.execute( `return Boolean(document.querySelector("[cmdk-input]"));`, ), { description: "command palette" }, ); const input = await app.session.findCss("[cmdk-input]"); await app.session.sendKeys(input, en.shortcuts.goAgent); await app.clickText(en.shortcuts.goAgent, { exact: false, roles: ["option", "button", "menuitem"], }); await waitPainted("agent-page"); await dismissSurface(app); } finally { await restoreStubs(app); } }, { settings: { paid_welcome_seen_for: ["ui-agent"] } }, ); }); async function createUiProfile(app, name) { return app.invoke("create_browser_profile_new", { name, browserStr: "wayfern", version: "150.0.7871.100", releaseType: "stable", proxyId: null, vpnId: null, wayfernConfig: { fingerprint: "{}" }, groupId: null, ephemeral: false, dnsBlocklist: null, launchHook: null, }); } async function distributionSummary(app) { return app.execute( `return document.querySelector('[data-testid="distribute-summary"]')?.innerText ?? "";`, ); } test("the distribute-proxies dialog opens from the action bar, counts the pairing, and answers every control", async () => { await withApp( "ui-proxy-distribution", async (app) => { for (const name of ["Fleet One", "Fleet Two", "Fleet Three"]) { await createUiProfile(app, name); } for (const [index, name] of ["Exit One", "Exit Two"].entries()) { await app.invoke("create_stored_proxy", { name, proxySettings: { proxy_type: "http", host: "127.0.0.1", port: 9101 + index, username: null, password: null, }, }); } await app.waitForText("Fleet Three"); await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`); await app.clickText(en.tables.moreActions, { roles: ["button"] }); await app.clickText(en.profiles.actionBar.distributeProxies, { roles: ["menuitem"], }); await app.waitForText(en.proxyDistribution.title); // Two proxies for three profiles: the dialog has to say so up front, and // it must never pretend the third profile is covered. await app.waitFor( async () => (await distributionSummary(app)).includes("2 of 3"), { description: "distribution summary" }, ); const summary = await distributionSummary(app); assert.match(summary, /2 of 3/); // The remainder is never silently dropped: the one profile no proxy was // left for is named. Which one depends on the table's order, so assert // that exactly one of the three is named rather than pinning the sort. const named = ["Fleet One", "Fleet Two", "Fleet Three"].filter((name) => summary.includes(name), ); assert.deepEqual( named.length, 1, `the unpaired profile must be named exactly once, got: ${summary}`, ); // Every control answers a click. Dropping the proxies empties the plan... await app.clickSelector('[data-testid="distribute-toggle-proxies"]'); await app.waitFor( async () => (await distributionSummary(app)).includes("0 of 3"), { description: "summary after clearing the proxies" }, ); // ...and putting them back restores it. await app.clickSelector('[data-testid="distribute-toggle-proxies"]'); await app.waitFor( async () => (await distributionSummary(app)).includes("2 of 3"), { description: "summary after reselecting the proxies" }, ); const switchState = () => app.execute( `return document.querySelector('[aria-label="${en.proxyDistribution.allowSharingLabel}"]')?.getAttribute("aria-checked") ?? null;`, ); assert.equal(await switchState(), "false", "sharing is off by default"); await app.clickSelector( `[aria-label="${en.proxyDistribution.allowSharingLabel}"]`, ); await app.waitFor(async () => (await switchState()) === "true", { description: "sharing switch turning on", }); // Dropping every profile leaves nothing to do, and the primary action // must not offer to do it. await app.clickSelector('[data-testid="distribute-toggle-profiles"]'); await app.waitFor( async () => (await distributionSummary(app)).includes("0 of 0"), { description: "summary after clearing the profiles" }, ); assert.equal( await app.execute( `const nodes = [...document.querySelectorAll("button")]; const node = nodes.find((n) => (n.innerText ?? "").trim().includes(arguments[0])); return node ? node.disabled : null;`, [en.proxyDistribution.distributeButton], ), true, "an empty plan must not offer a Distribute button that does nothing", ); await dismissSurface(app); }, { seedDownloadedBrowser: true }, ); }); test("bulk edit opens from the action bar and changes only the ticked rows", async () => { await withApp( "ui-bulk-edit", async (app) => { for (const name of ["Batch One", "Batch Two"]) { await createUiProfile(app, name); } await app.invoke("update_profile_note", { profileId: (await app.invoke("list_browser_profiles"))[0].id, note: "Keep me", }); await app.waitForText("Batch Two"); await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`); await app.clickSelector(`[aria-label="${en.bulkEdit.action}"]`); await app.waitForText(en.bulkEdit.title); const applyDisabled = () => app.execute( `return document.querySelector('[data-slot="bulk-edit-apply"]')?.disabled ?? null;`, ); assert.equal( await applyDisabled(), true, "nothing ticked, nothing to apply", ); await app.waitForText(en.bulkEdit.apply_other.replace("{{count}}", "2")); await app.clickSelector("#bulk-edit-tagsAdd"); await app.fillSelector('[data-field="tagsAdd"] input', "ui-bulk, shop"); assert.equal(await applyDisabled(), false); await app.capture("bulk-edit-dialog"); await app.clickSelector('[data-slot="bulk-edit-apply"]'); await app.waitFor( async () => (await app.invoke("list_browser_profiles")).every( (profile) => profile.tags?.includes("ui-bulk") && profile.tags.includes("shop"), ), { description: "tags on every selected profile" }, ); await app.waitFor( async () => !(await app.visibleTextIncludes(en.bulkEdit.title)), { description: "dialog closing after apply" }, ); const notes = (await app.invoke("list_browser_profiles")).map( (profile) => profile.note ?? null, ); assert.ok(notes.includes("Keep me"), "an unticked field stays as it is"); }, { seedDownloadedBrowser: true }, ); }); test("templates save from the create dialog and the inspector, and several profiles go to one batch", async () => { await withApp( "ui-templates", async (app) => { await stubCommand(app, "is_geoip_database_available", true); const base = await createUiProfile(app, "Template Base"); await app.waitForText(base.name); const templates = () => app.invoke("list_profile_templates"); await openProfileCreation(app); await app.fillSelector("#profile-name", "Batch"); await app.clickSelector('[data-slot="template-picker"]'); await app.waitForText(en.templates.empty); await app.fillSelector('[data-slot="template-name"]', "Quick"); await app.clickSelector('[data-slot="template-save"]'); await app.waitFor( async () => (await templates()).some((t) => t.name === "Quick"), { description: "the template saved from the form" }, ); const [quick] = await templates(); assert.equal(quick.settings.name_prefix, "Batch"); await app.waitFor( () => app.execute( `return document.querySelector('[data-slot="template-picker"]')?.innerText.includes("Quick") ?? false;`, ), { description: "the saved template shown as applied" }, ); await app.clickSelector('[data-slot="profile-count-more"]'); await app.clickSelector('[data-slot="profile-count-more"]'); await app.waitForText( en.templates.createMany_other.replace("{{count}}", "3"), ); assert.equal( await app.execute( `return document.querySelector('[data-slot="profile-count-hint"]') !== null;`, ), true, ); assert.equal( await app.execute( `return document.querySelector('#enable-password')?.disabled ?? null;`, ), true, "a password is for one profile only", ); await app.capture("templates-create-many"); await stubCommand(app, "bulk_create_profiles", { created: [{ id: "a" }, { id: "b" }, { id: "c" }], error: null, cancelled: false, }); await app.clickSelector('[data-slot="profile-create-submit"]'); await app.waitFor( () => app.execute(`return !document.querySelector('#profile-name');`), { description: "the dialog closing after the batch" }, ); const [call] = await app.execute( `return (window.__donutStubbedCalls ?? []).filter((c) => c.command === "bulk_create_profiles");`, ); assert.equal(call.payload.count, 3); assert.equal(call.payload.name, "Batch"); assert.equal(call.payload.settings.name_prefix, "Batch"); await app.waitForText( en.templates.created_other.replace("{{count}}", "3"), ); await openProfileCreation(app); await app.clickSelector('[data-slot="template-picker"]'); await app.clickSelector('[data-slot="template-option"]'); await app.waitFor( () => app.execute( `return document.querySelector('#profile-name')?.value === "Batch";`, ), { description: "the template's name prefix filling an empty name" }, ); await app.clickSelector('[data-slot="template-picker"]'); await app.clickSelector('[data-slot="template-delete"]'); await app.clickSelector('[data-slot="template-delete-confirm"]'); await app.waitFor(async () => (await templates()).length === 0, { description: "the template deleted from the picker", }); await dismissSurface(app); await dismissSurface(app); await app.clickSelector( `[data-slot="profile-inspect-trigger"][data-profile-id="${base.id}"]`, ); await app.clickSelector('[data-slot="profile-info-save-template"]'); await app.waitForText(en.templates.saveFromProfileDescription); await app.clickSelector('[data-slot="save-template-confirm"]'); await app.waitFor( async () => (await templates()).some((t) => t.name === "Template Base"), { description: "the template saved from the inspector" }, ); }, { seedDownloadedBrowser: true }, ); }); test("the group bookmark editor adds, reorders and removes a row", async () => { await withApp("ui-group-bookmarks", async (app) => { const group = await app.invoke("create_profile_group", { name: "Client" }); await app.clickSelector('[aria-label="Groups"]'); await app.waitForText("Client"); await app.clickSelector('[data-testid="group-bookmarks-button"]'); await app.waitForText(en.groupBookmarks.description); assert.ok(await app.visibleTextIncludes(en.groupBookmarks.empty)); const rowCount = () => app.execute( `return document.querySelectorAll('[data-testid="group-bookmark-row"]').length;`, ); const titles = () => app.execute( `return [...document.querySelectorAll('[data-testid="group-bookmark-title"]')].map((n) => n.value);`, ); const rowSelector = (index, testid) => `[data-testid="group-bookmark-rows"] > div:nth-child(${index}) [data-testid="${testid}"]`; await app.clickSelector('[data-testid="group-bookmark-add"]'); await app.waitFor(async () => (await rowCount()) === 1, { description: "the first bookmark row", }); await app.clickSelector('[data-testid="group-bookmark-add"]'); await app.waitFor(async () => (await rowCount()) === 2, { description: "the second bookmark row", }); await app.clickSelector('[data-testid="group-bookmark-add"]'); await app.waitFor(async () => (await rowCount()) === 3, { description: "the third bookmark row", }); await app.fillSelector(rowSelector(1, "group-bookmark-title"), "Support"); await app.fillSelector( rowSelector(1, "group-bookmark-url"), "https://support.example", ); await app.fillSelector(rowSelector(2, "group-bookmark-title"), "Console"); await app.fillSelector( rowSelector(2, "group-bookmark-url"), "https://console.example", ); await app.fillSelector(rowSelector(2, "group-bookmark-folder"), "Ops"); await app.fillSelector(rowSelector(3, "group-bookmark-title"), "Scratch"); await app.fillSelector( rowSelector(3, "group-bookmark-url"), "https://scratch.example", ); assert.deepEqual(await titles(), ["Support", "Console", "Scratch"]); // Reorder: the second row moves above the first. await app.clickSelector(rowSelector(2, "group-bookmark-move-up")); await app.waitFor(async () => (await titles())[0] === "Console", { description: "the reordered first row", }); assert.deepEqual(await titles(), ["Console", "Support", "Scratch"]); // Remove: the last row goes away and nothing else moves. await app.clickSelector(rowSelector(3, "group-bookmark-remove")); await app.waitFor(async () => (await rowCount()) === 2, { description: "the removed row", }); assert.deepEqual(await titles(), ["Console", "Support"]); await app.clickText(en.common.buttons.save); await app.waitFor( async () => (await app.invoke("get_group_bookmarks", { groupId: group.id })) .length === 2, { description: "the saved bookmark list" }, ); assert.deepEqual( await app.invoke("get_group_bookmarks", { groupId: group.id }), [ { title: "Console", url: "https://console.example", folder: "Ops" }, { title: "Support", url: "https://support.example" }, ], ); await dismissSurface(app); }); }); test("importing from a link validates the input and names the extension before it is saved", async () => { await withApp("ui-extension-from-link", async (app) => { const fixtureBase = process.env.DONUT_E2E_FIXTURE_URL; assert.ok(fixtureBase, "the fixture server URL has to reach the suite"); await openExtensionsPage(app); await app.clickSelector(`[aria-label="${EXTENSION_STRINGS.fromUrl}"]`); await app.waitFor( () => app.execute( `return Boolean(document.querySelector("#ext-url-input"));`, ), { description: "the link import form" }, ); // Nothing to fetch yet, so the action is not offered. assert.equal( await app.execute( `return [...document.querySelectorAll("button")] .find((button) => (button.textContent || "").trim() === arguments[0]) ?.disabled ?? null;`, [EXTENSION_STRINGS.fetchExtension], ), true, "Fetch has to stay disabled until there is something to fetch", ); // A link that is not an extension source is refused, in the user's // language, and nothing is staged. await app.fillSelector("#ext-url-input", "https://example.invalid/page"); await app.clickText(EXTENSION_STRINGS.fetchExtension, { roles: ["button"], }); await app.waitFor( async () => (await toastTexts(app)).some((text) => text.includes(en.backendErrors.extensionUrlInvalid), ), { description: "the refusal for a link that is not an extension" }, ); assert.equal( await app.execute( `return document.querySelector('[data-slot="extension-fetched-identity"]') === null;`, ), true, "a refused link must not stage anything", ); // The real thing: a CRX3 the fixture server serves. The staged form has to // show the identity read out of the archive's own manifest, not the file // name, before the user commits to storing it. await app.fillSelector("#ext-url-input", `${fixtureBase}/extension.crx`); await app.clickText(EXTENSION_STRINGS.fetchExtension, { roles: ["button"], }); await app.waitFor( () => app.execute( `return document.querySelector('[data-slot="extension-fetched-identity"]')?.innerText?.trim() ?? null;`, ), { description: "the parsed identity of the downloaded extension" }, ); const identity = await app.execute( `return document.querySelector('[data-slot="extension-fetched-identity"]').innerText.trim();`, ); assert.ok( identity.includes(CRX_EXTENSION_NAME), `the staged identity has to name the extension, got: ${identity}`, ); assert.ok( identity.includes(CRX_EXTENSION_VERSION), `the staged identity has to carry the version, got: ${identity}`, ); assert.ok( await app.visibleTextIncludes(`${fixtureBase}/extension.crx`), "the staged import has to name where the package came from", ); assert.deepEqual( await app.invoke("list_extensions"), [], "fetching stages the archive; it must not store it", ); await app.clickText(en.common.buttons.add, { roles: ["button"] }); await app.waitForText(CRX_EXTENSION_NAME); const stored = await app.invoke("list_extensions"); assert.equal(stored.length, 1); assert.equal(stored[0].name, CRX_EXTENSION_NAME); assert.equal(stored[0].version, CRX_EXTENSION_VERSION); assert.equal(stored[0].file_type, "zip"); assert.equal(stored[0].source_kind, "archive"); }); }); test("a checked proxy shows its UDP verdict in the table and its check trail in the details", async () => { await withApp("ui-proxy-check-trail", async (app) => { const proxy = await app.invoke("create_stored_proxy", { name: "Trail HTTP Proxy", proxySettings: { proxy_type: "http", host: "127.0.0.1", // Discard port: the check fails fast, which is a real check outcome // and exactly what the trail has to be able to show. port: 9, username: null, password: null, }, }); await app.invokeError("check_proxy_validity", { proxyId: proxy.id, proxySettings: null, }); await app.clickSelector('[aria-label="Network"]'); await app.waitForText(proxy.name); // An HTTP proxy cannot carry a datagram, so the table says so without // anyone opening anything. await app.waitFor( async () => (await app.execute( `return document.querySelector('[data-slot="proxy-udp-verdict"]')?.dataset?.udp ?? null;`, )) === "no", { description: "the UDP verdict cell" }, ); assert.equal( ( await app.execute( `return document.querySelector('[data-slot="proxy-udp-verdict"]').innerText.trim();`, ) ).toLowerCase(), en.proxyCheck.udpNo.toLowerCase(), ); await app.clickSelector(`[aria-label="${en.appFeedback.routeDetails}"]`); await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-slot="proxy-check-history"]'));`, ), { description: "the check trail" }, ); await app.waitFor( async () => (await app.execute( `return document.querySelectorAll('[data-slot="proxy-check-history-entry"]').length;`, )) >= 1, { description: "at least one remembered check" }, ); const trail = await app.execute( `return [...document.querySelectorAll('[data-slot="proxy-check-history-entry"]')] .map((entry) => entry.innerText.replace(/\\s+/g, " ").trim());`, ); assert.ok(trail.length >= 1); assert.ok( trail[0].includes(en.proxyCheck.historyFailed), `the newest line has to report the failure, got: ${trail[0]}`, ); assert.ok( await app.visibleTextIncludes(en.proxyCheck.historyTitle), "the trail needs a heading that says what it is", ); }); }); /** * Answer one Tauri command from inside the webview. * * Same seam as {@link stubFolderPicker}: the synchroniser panel needs a live * session to control, and a real one launches browsers. Every other command * still reaches the real backend, so the panel is exercised as it ships. */ async function stubCommand(app, command, reply) { await app.execute( `const wanted = arguments[0]; const reply = arguments[1]; if (!window.__donutOriginalFetch) { window.__donutOriginalFetch = window.fetch; } window.__donutStubbedCalls = window.__donutStubbedCalls ?? []; window.__donutStubs = window.__donutStubs ?? {}; window.__donutStubs[wanted] = reply; if (!window.__donutStubInstalled) { window.__donutStubInstalled = true; window.fetch = function (input, init) { const url = String( typeof input === "string" ? input : (input && input.url) || "", ); let name = ""; try { name = decodeURIComponent(url.split("/").pop() || ""); } catch (_error) { name = ""; } if (window.__donutStubs[name] !== undefined) { let payload = null; try { payload = JSON.parse((init && init.body) || "null"); } catch (_error) { payload = null; } window.__donutStubbedCalls.push({ command: name, payload }); return Promise.resolve( new Response(JSON.stringify(window.__donutStubs[name]), { status: 200, headers: { "content-type": "application/json", "Tauri-Response": "ok", }, }), ); } return window.__donutOriginalFetch.apply(window, arguments); }; } return true;`, [command, reply], ); } async function restoreStubs(app) { return app.execute( `const calls = window.__donutStubbedCalls ?? []; if (window.__donutOriginalFetch) { window.fetch = window.__donutOriginalFetch; delete window.__donutOriginalFetch; } delete window.__donutStubbedCalls; delete window.__donutStubs; delete window.__donutStubInstalled; return calls;`, ); } const SYNC = en.profiles.synchronizer; test("the synchroniser panel lists a live session and its controls act on the real state", async () => { await withApp("ui-synchronizer-panel", async (app) => { const session = { id: "ui-panel-session", leader_profile_id: "leader-id", leader_profile_name: "Panel leader", paused: false, followers: [ { profile_id: "follower-one", profile_name: "Panel follower one", failed_at_url: null, held: false, }, { profile_id: "follower-two", profile_name: "Panel follower two", failed_at_url: "https://example.invalid/lost", held: false, }, ], }; const panel = '[data-slot="synchronizer-panel"]'; const followerState = (id) => app.execute( `const row = document.querySelector('[data-slot="synchronizer-panel-follower"][data-profile-id="' + arguments[0] + '"]'); if (!row) return null; return { held: row.dataset.held === "true", badge: row.querySelector('[data-slot="synchronizer-panel-follower-state"]')?.innerText?.trim() ?? null, holdLabel: row.querySelector('[data-slot="synchronizer-panel-hold"]')?.innerText?.trim() ?? null, };`, [id], ); try { // The profiles page has to be mounted before any of this means // anything: an event emitted before the hook subscribes is simply gone. await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-slot="profile-workspace"]'));`, ), { description: "the profiles page" }, ); assert.equal( await app.execute( `return Boolean(document.querySelector(arguments[0]));`, [panel], ), false, "no session, no panel", ); // Re-emitted until it lands, because subscribing is asynchronous and a // missed event is indistinguishable from a broken panel. await app.waitFor( async () => { await app.invoke("plugin:event|emit", { event: "sync-session-changed", payload: session, }); return app.execute( `return Boolean(document.querySelector(arguments[0]));`, [panel], ); }, { description: "the session panel" }, ); assert.equal( await app.execute( `return document.querySelector('[data-slot="synchronizer-panel-leader"]').innerText.trim();`, ), session.leader_profile_name, ); assert.deepEqual( await app.execute( `return [...document.querySelectorAll('[data-slot="synchronizer-panel-follower"]')] .map((row) => row.dataset.profileId);`, ), ["follower-one", "follower-two"], "followers stay in the order they were chosen", ); assert.deepEqual(await followerState("follower-one"), { held: false, badge: SYNC.stateMirroring, holdLabel: SYNC.holdOut, }); // The desynced follower reports the failure instead of a state badge. assert.equal(await app.visibleTextIncludes(SYNC.stateDesynced), true); await app.capture("synchronizer-panel"); // The backend has no such session, so pausing must fail and the panel // must keep telling the truth rather than flipping hopefully. await app.clickSelector('[data-slot="synchronizer-panel-pause"]'); await app.waitFor( () => app.visibleTextIncludes(en.backendErrors.syncSessionNotFound), { description: "the refusal surfaced to the user" }, ); assert.equal( await app.execute( `return document.querySelector('[data-slot="synchronizer-panel-pause"]').innerText.trim();`, ), SYNC.pauseMirroring, "a refused pause must not read as paused", ); assert.equal( await app.execute( `return Boolean(document.querySelector('[data-slot="synchronizer-panel-paused-note"]'));`, ), false, ); // With the backend agreeing, the same click has to land. await stubCommand(app, "set_sync_session_paused", { ...session, paused: true, }); await app.clickSelector('[data-slot="synchronizer-panel-pause"]'); await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-slot="synchronizer-panel-paused-note"]'));`, ), { description: "the paused state" }, ); assert.equal( await app.execute( `return document.querySelector('[data-slot="synchronizer-panel-pause"]').innerText.trim();`, ), SYNC.resumeMirroring, ); assert.equal( (await followerState("follower-one")).badge, SYNC.statePaused, ); await app.capture("synchronizer-panel-paused"); // Holding one follower out leaves the other exactly as it was. await stubCommand(app, "set_sync_follower_held", { ...session, followers: [ { ...session.followers[0], held: true }, session.followers[1], ], }); await app.clickSelector( '[data-slot="synchronizer-panel-follower"][data-profile-id="follower-one"] [data-slot="synchronizer-panel-hold"]', ); await app.waitFor( async () => (await followerState("follower-one")).held === true, { description: "the held-out follower" }, ); assert.deepEqual(await followerState("follower-one"), { held: true, badge: SYNC.stateHeld, holdLabel: SYNC.rejoin, }); // The layout the user picked is the layout the backend is asked for. await stubCommand(app, "arrange_sync_windows", session); await app.clickSelector('[data-slot="synchronizer-panel-layout"]'); await app.clickText(SYNC.layout.cascade, { roles: ["option", "menuitem", "button"], }); await app.waitFor( async () => (await app.execute( `return document.querySelector('[data-slot="synchronizer-panel-layout"]').innerText.trim();`, )) === SYNC.layout.cascade, { description: "the chosen layout" }, ); await app.clickSelector('[data-slot="synchronizer-panel-arrange"]'); await app.waitFor( async () => (await app.execute( `return (window.__donutStubbedCalls ?? []).filter((call) => call.command === "arrange_sync_windows").length;`, )) === 1, { description: "the arrange request" }, ); const calls = await app.execute( `return window.__donutStubbedCalls.map((call) => call.command + ":" + JSON.stringify(call.payload));`, ); const arrange = calls.find((call) => call.startsWith("arrange_sync_windows"), ); assert.match(arrange, /"layout":"cascade"/); assert.match(arrange, /"sessionId":"ui-panel-session"/); await app.capture("synchronizer-panel-arranged"); await app.waitFor( async () => { await app.invoke("plugin:event|emit", { event: "sync-session-ended", payload: session.id, }); return ( (await app.execute( `return Boolean(document.querySelector(arguments[0]));`, [panel], )) === false ); }, { description: "the panel leaving with the session" }, ); } finally { await restoreStubs(app); } }); }); const TIPS_DIALOG = '[data-slot="tips-dialog"]'; async function openTipsFromRail(app) { await app.clickSelector('[aria-label="More"]'); await app.waitFor( () => app.execute(`return Boolean(document.querySelector("[role='menu']"));`), { description: "More menu" }, ); await app.clickSelector('[data-slot="rail-open-tips"]'); await app.waitFor( () => app.execute( `return document.querySelector(arguments[0])?.dataset.mode === "browse";`, [TIPS_DIALOG], ), { description: "the tips catalog" }, ); } test("tips open from the rail, walk the catalog, and deep-link into the feature", async () => { await withApp("ui-tips-browse", async (app) => { await openTipsFromRail(app); assert.ok(await app.visibleTextIncludes(en.tips.items.dnsBlocklist.title)); // Every essential is listed; a plan tip needs a plan, and there is none. const listed = await app.execute( `return [...document.querySelectorAll('[data-slot="tips-list-item"]')].map((node) => node.dataset.tipId);`, ); assert.ok(listed.includes("dnsBlocklist")); assert.ok(listed.includes("trash")); assert.ok(!listed.includes("cookieBot")); assert.ok(!listed.includes("team")); // The drawing is live SVG in the scene panel, not a picture. assert.equal( await app.execute( `return document.querySelectorAll('[data-slot="tip-scene-panel"] svg[data-slot="tip-scene"]').length;`, ), 1, ); await app.clickSelector('[data-slot="tip-next"]'); await app.waitFor( () => app.execute( `return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId === "proxyCheck";`, ), { description: "the second tip" }, ); assert.ok(await app.visibleTextIncludes(en.tips.items.proxyCheck.title)); // Picking from the catalog moves the sliding indicator onto that entry. await app.clickSelector( '[data-slot="tips-list-item"][data-tip-id="trash"]', ); await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-slot="tips-list-item"][data-tip-id="trash"] [data-slot="tips-list-indicator"]'));`, ), { description: "the indicator on the chosen tip" }, ); assert.ok(await app.visibleTextIncludes(en.tips.items.trash.title)); assert.equal( await app.execute( `return document.querySelectorAll('[data-slot="tips-list-indicator"]').length;`, ), 1, "exactly one entry is marked current", ); await app.capture("tips-browse"); // Seen tips are remembered, so the automatic flow never repeats them. await app.waitFor( async () => { const state = await app.invoke("get_tips_state"); return ["dnsBlocklist", "proxyCheck", "trash"].every((id) => state.seen.includes(id), ); }, { description: "seen tips persisted" }, ); // The action lands inside the feature: the DNS tip opens settings on // its DNS section, and the dialog is gone by then. await app.clickSelector( '[data-slot="tips-list-item"][data-tip-id="dnsBlocklist"]', ); await app.clickSelector('[data-slot="tip-action"]'); await app.waitFor( () => app.execute( `return document.activeElement?.dataset?.settingsSection === "dns";`, ), { description: "the DNS settings section focused" }, ); assert.equal( await app.execute( `return Boolean(document.querySelector(arguments[0]));`, [TIPS_DIALOG], ), false, ); // The chord opens the catalog too, and the switch turns the automatic // flow off and persists that. await app.pressShortcut({ ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), shift: true, key: "h", }); await app.waitFor( () => app.execute( `return document.querySelector(arguments[0])?.dataset.mode === "browse";`, [TIPS_DIALOG], ), { description: "the tips catalog from the keyboard" }, ); assert.equal( await app.execute( `return document.querySelector('[data-slot="tips-auto-show"]').getAttribute("data-state");`, ), "unchecked", "the harness seeds the automatic flow off", ); await app.clickSelector('[data-slot="tips-auto-show"]'); await app.waitFor( async () => (await app.invoke("get_tips_state")).auto_show === true, { description: "the preference persisted" }, ); await dismissSurface(app); await app.waitFor( () => app.execute(`return !document.querySelector(arguments[0]);`, [ TIPS_DIALOG, ]), { description: "the dialog closed" }, ); }); }); test("one tip for an unused feature opens by itself as a single card, then waits 2 to 5 days", async () => { // Every tip but two is seen already, and the only profile blocks ads, so // the DNS tip is in use and the trash tip is the one the automatic flow // may open. The flow is switched on only after that profile exists. const unseen = ["dnsBlocklist", "trash"]; const seen = Object.keys(en.tips.items).filter((id) => !unseen.includes(id)); await withApp( "ui-tips-auto", async (app) => { const profile = await createUiProfile(app, "Tips Blocker"); await app.invoke("update_profile_dns_blocklist", { profileId: profile.id, dnsBlocklist: "light", }); await app.invoke("set_tips_auto_show", { enabled: true }); await app.restart(); await app.waitFor( () => app.execute( `return document.querySelector(arguments[0])?.dataset.mode === "single";`, [TIPS_DIALOG], ), { description: "the automatic tip", timeoutMs: 30_000 }, ); assert.equal( await app.execute( `return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId;`, ), "trash", "the DNS tip is skipped because a profile already blocks ads", ); assert.ok(await app.visibleTextIncludes(en.tips.items.trash.title)); assert.deepEqual( await app.execute( `return ['tips-list-item', 'tip-previous', 'tip-next'].map((slot) => document.querySelectorAll('[data-slot="' + slot + '"]').length);`, ), [0, 0, 0], "one tip at a time: no catalog and no pager", ); assert.equal( await app.execute( `return document.querySelector('[data-slot="tip-advance"]')?.textContent.trim();`, ), en.tips.done, ); await app.capture("tips-auto"); const state = await app.waitFor( async () => { const current = await app.invoke("get_tips_state"); return current.seen.includes("trash") && current.auto_due === false ? current : null; }, { description: "the automatic tip recorded" }, ); assert.ok( !state.seen.includes("dnsBlocklist"), "the skipped tip stays unseen", ); const gap = state.next_auto_show_at - state.last_auto_shown_at; assert.ok( gap >= 2 * 24 * 60 * 60 && gap <= 5 * 24 * 60 * 60, `the next automatic tip waits 2 to 5 days, not ${gap} seconds`, ); // Done closes the card instead of walking to another tip. await app.clickSelector('[data-slot="tip-advance"]'); await app.waitFor( () => app.execute(`return !document.querySelector(arguments[0]);`, [ TIPS_DIALOG, ]), { description: "the dialog closed" }, ); // A restart inside the gap shows nothing, and keeps the same wait. await app.restart(); await app.waitForText("Tips Blocker"); await new Promise((resolve) => setTimeout(resolve, 4_500)); assert.equal( await app.execute( `return Boolean(document.querySelector(arguments[0]));`, [TIPS_DIALOG], ), false, ); assert.equal( (await app.invoke("get_tips_state")).next_auto_show_at, state.next_auto_show_at, ); }, { settings: { tips_seen: seen } }, ); }); test("a freshly paid account is welcomed once and walked to its plan tips", async () => { await withApp("ui-paid-welcome", async (app) => { await app.waitForText("No profiles yet"); try { // A pro account that signed in a moment ago, as the desktop would hold // it after a device-code login. Only the IPC read of the cached user is // stubbed; the plan observation and the tips state run for real. await stubCommand(app, "cloud_get_user", { logged_in_at: new Date().toISOString(), user: { id: "ui-paid-welcome", email: "paid@example.test", plan: "pro", planPeriod: "monthly", subscriptionStatus: "active", profileLimit: 50, cloudProfilesUsed: 0, proxyBandwidthLimitMb: 0, proxyBandwidthUsedMb: 0, proxyBandwidthExtraMb: 0, isPrimaryDevice: true, }, }); await app.invoke("plugin:event|emit", { event: "cloud-auth-changed", payload: null, }); await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`, ), { description: "the paid welcome" }, ); assert.ok( await app.visibleTextIncludes( en.paidWelcome.title.replace("{{plan}}", "Pro"), ), ); assert.deepEqual( await app.execute( `return [...document.querySelectorAll('[data-slot="paid-welcome-item"]')].map((node) => node.dataset.tipId);`, ), [ "cloudBackup", "cookieBot", "crossOs", "automation", "agent", "remoteControl", ], "every capability the plan grants, in catalog order, and nothing it lacks", ); await app.capture("paid-welcome"); // A row opens the catalog on that tip, with the plan tips now listed. await app.clickSelector( '[data-slot="paid-welcome-item"][data-tip-id="cookieBot"]', ); await app.waitFor( () => app.execute( `return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId === "cookieBot";`, ), { description: "the Cookie Bot tip" }, ); assert.equal( await app.execute( `return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`, ), false, ); const listed = await app.execute( `return [...document.querySelectorAll('[data-slot="tips-list-item"]')].map((node) => node.dataset.tipId);`, ); assert.ok(listed.includes("cookieBot") && listed.includes("agent")); assert.ok(listed.includes("dnsBlocklist")); await app.capture("tips-plan-catalog"); await dismissSurface(app); await app.waitFor( () => app.execute(`return !document.querySelector(arguments[0]);`, [ TIPS_DIALOG, ]), { description: "the catalog closed" }, ); // Greeted once: the same account signing in again is not welcomed twice. await app.invoke("plugin:event|emit", { event: "cloud-auth-changed", payload: null, }); await new Promise((resolve) => setTimeout(resolve, 1_500)); assert.equal( await app.execute( `return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`, ), false, ); assert.equal( await app.invoke("observe_cloud_plan", { userId: "ui-paid-welcome", paid: true, freshLogin: true, }), false, ); } finally { await restoreStubs(app); } }); }); test("profile statuses start from a common set and are picked from the table", async () => { await withApp("ui-profile-statuses", async (app) => { const profile = await createUiProfile(app, "Profile With Status"); await app.waitForText(profile.name); const cell = '[data-slot="profile-status-cell"]'; const statusOf = async () => (await app.invoke("list_browser_profiles")).find( (item) => item.id === profile.id, ).status_id ?? null; await app.clickSelector(cell); await app.clickText(en.profileStatuses.manage, { roles: ["option"] }); await app.waitForText(en.profileStatuses.empty); await app.capture("profile-statuses-empty"); await app.clickText(en.profileStatuses.addStarter, { roles: ["button"] }); await app.waitFor( async () => (await app.invoke("list_profile_statuses")).length === 4, { description: "the starter statuses" }, ); assert.deepEqual( (await app.invoke("list_profile_statuses")).map((item) => item.name), [ en.profileStatuses.starter.active, en.profileStatuses.starter.warming, en.profileStatuses.starter.review, en.profileStatuses.starter.banned, ], ); await app.capture("profile-statuses-starter"); await dismissSurface(app); await app.clickSelector(cell); await app.clickText(en.profileStatuses.starter.banned, { exact: false, roles: ["option"], }); const banned = (await app.invoke("list_profile_statuses")).find( (item) => item.name === en.profileStatuses.starter.banned, ); await app.waitFor(async () => (await statusOf()) === banned.id, { description: "the picked status", }); await app.waitFor( () => app.execute( `return document.querySelector(arguments[0])?.textContent.includes(arguments[1]);`, [cell, en.profileStatuses.starter.banned], ), { description: "the cell shows the status" }, ); await app.capture("profile-status-cell"); await app.clickSelector(cell); await app.clickText(en.profileStatuses.none, { roles: ["option"] }); await app.waitFor(async () => (await statusOf()) === null, { description: "the status cleared", }); }); }); test("the start pages section saves one page per line and reports a bad page", async () => { await withApp("ui-start-pages", async (app) => { const profile = await createUiProfile(app, "Profile Start Pages"); await app.waitForText(profile.name); const stored = async () => (await app.invoke("list_browser_profiles")).find( (item) => item.id === profile.id, ).start_urls ?? []; await app.clickSelector( `[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`, ); await app.clickSelector( '[data-slot="profile-info-section"][data-section="startPages"]', ); await app.waitForText(en.startPages.description); const field = '[data-slot="profile-start-pages-input"]'; await app.fillSelector(field, "mail.example\nfile:///etc/hosts"); await app.clickText(en.common.buttons.save, { exact: true }); await app.waitForText( en.backendErrors.startUrlInvalid.replace("{{url}}", "file:///etc/hosts"), ); assert.deepEqual(await stored(), []); await app.fillSelector(field, "mail.example\nhttps://shop.example/cart"); await app.clickText(en.common.buttons.save, { exact: true }); await app.waitFor( async () => JSON.stringify(await stored()) === JSON.stringify(["https://mail.example/", "https://shop.example/cart"]), { description: "the saved start pages" }, ); await app.capture("profile-start-pages"); await app.clickSelector('[data-slot="profile-info-close"]'); }); }); test("2FA codes come from a pasted key and from a profile's saved keys", async () => { await withApp("ui-two-factor", async (app) => { const profile = await createUiProfile(app, "Profile Two Factor"); await app.waitForText(profile.name); await app.pressShortcut({ key: "o", shift: true, ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), }); await app.waitForText(en.totp.toolDescription); await app.fillSelector("#totp-tool-key", "JBSWY3DPEHPK3PXP"); await app.waitFor( () => app.execute( `return /^\\d{3} \\d{3}$/.test(document.querySelector('[data-slot="totp-tool-dialog"] [data-slot="totp-code"] span')?.textContent ?? "");`, ), { description: "a code from the pasted key" }, ); await app.capture("totp-tool"); await dismissSurface(app); await app.clickSelector( `[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`, ); await app.clickSelector( '[data-slot="profile-info-section"][data-section="twoFactor"]', ); await app.waitForText(en.totp.empty); await app.fillSelector( '[data-slot="profile-totp-key"]', "otpauth://totp/Mail:bob?secret=JBSWY3DPEHPK3PXP&issuer=Mail", ); await app.clickText(en.totp.add, { roles: ["button"] }); await app.waitFor( () => app.execute( `return document.querySelectorAll('[data-slot="profile-totp-entry"] [data-slot="totp-code"]').length === 1;`, ), { description: "the saved key shows a live code" }, ); await app.capture("profile-totp-section"); assert.equal( (await app.invoke("list_profile_totp", { profileId: profile.id }))[0] .label, "Mail", ); await app.clickSelector('[data-slot="profile-info-close"]'); }); }); test("the profile network section saves, refuses and clears a DNS server", async () => { await withApp("ui-dns-server", async (app) => { const profile = await createUiProfile(app, "Profile DNS Server"); await app.waitForText(profile.name); const stored = async () => (await app.invoke("list_browser_profiles")).find( (item) => item.id === profile.id, ).dns_server ?? null; const field = '[data-slot="dns-server-field"] input'; const saveEnabled = () => app.execute( `return [...document.querySelectorAll('[data-slot="dns-server-field"] button[type="submit"]')].some((button) => !button.disabled);`, ); await app.clickSelector( `[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`, ); await app.clickSelector( '[data-slot="profile-info-section"][data-section="network"]', ); await app.waitForText(en.profileInfo.sectionDesc.network); await app.waitForText(en.dnsServer.summary.default); await app.fillSelector(field, "1.1.1.1"); await app.waitForText( `${en.dnsServer.summary.https.replace("{{server}}", "cloudflare-dns.com")} ${en.dnsServer.summary.upgraded}`, ); await app.capture("profile-dns-server-upgraded"); await app.clickText(en.common.buttons.save, { exact: true }); await app.waitFor(async () => (await stored()) === "1.1.1.1", { description: "the saved DNS server", }); await app.waitFor( () => app.execute( `return !document.querySelector('[data-slot="dns-server-field"] button[type="submit"]');`, ), { description: "nothing left to save" }, ); await app.fillSelector(field, "not a server"); await app.waitForText(en.backendErrors.dnsServerInvalid); assert.equal(await saveEnabled(), false); assert.equal(await stored(), "1.1.1.1"); await app.fillSelector(field, "tcp://1.1.1.1"); await app.waitForText( en.dnsServer.summary.tcp.replace("{{server}}", "1.1.1.1"), ); await app.capture("profile-dns-server-plain"); // Typed away rather than cleared, so the field sees real input events. await app.fillSelector(field, "x"); await app.session.sendKeys( await app.execute("return document.querySelector(arguments[0]);", [ field, ]), "\uE003", ); await app.waitForText(en.dnsServer.summary.default); await app.clickText(en.common.buttons.save, { exact: true }); await app.waitFor(async () => (await stored()) === null, { description: "the cleared DNS server", }); await app.clickSelector('[data-slot="profile-info-close"]'); }); }); test("Pro users can edit and save launch arguments in each profile", async () => { await withApp( "ui-launch-args", async (app) => { const profile = await createUiProfile(app, "Profile Launch Arguments"); await app.waitForText(profile.name); const openEditor = async () => { await app.clickSelector( `[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`, ); await app.clickSelector( '[data-slot="profile-info-section"][data-section="fingerprint"]', ); await app.waitForText(en.fingerprint.launchArgs); }; await openEditor(); assert.equal( await app.execute( 'return document.querySelector("#wayfern-launch-args").disabled;', ), true, ); await app.clickSelector('[data-slot="profile-info-close"]'); try { await stubCommand(app, "cloud_get_user", { logged_in_at: "2020-01-01T00:00:00Z", user: { id: "launch-args-pro", email: "launch-args@example.test", plan: "pro", planPeriod: "monthly", subscriptionStatus: "active", profileLimit: 50, isPrimaryDevice: true, }, }); await app.invoke("plugin:event|emit", { event: "cloud-auth-changed", payload: null, }); await openEditor(); await app.waitFor( () => app.execute( 'return !document.querySelector("#wayfern-launch-args").disabled;', ), { description: "Pro launch arguments editor" }, ); const input = "--disable-gpu\n--renderer-process-limit=4"; await app.fillSelector("#wayfern-launch-args", input); assert.equal( await app.execute( 'return document.querySelector("#wayfern-launch-args").value;', ), input, ); await app.capture("launch-arguments-pro"); await app.clickText(en.common.buttons.save, { exact: true }); await app.waitFor( async () => (await app.invoke("list_browser_profiles"))[0].wayfern_config .launch_args?.length === 2, { description: "saved launch arguments" }, ); await openEditor(); assert.equal( await app.execute( 'return document.querySelector("#wayfern-launch-args").value;', ), input, ); await app.fillSelector( "#wayfern-launch-args", "--user-data-dir=/tmp/other", ); await app.clickText(en.common.buttons.save, { exact: true }); await app.waitForText( en.backendErrors.wayfernLaunchArgReserved.replace( "{{argument}}", "--user-data-dir", ), ); assert.deepEqual( (await app.invoke("list_browser_profiles"))[0].wayfern_config .launch_args, input.split("\n"), ); await app.clickSelector('[data-slot="profile-info-close"]'); await restoreStubs(app); await app.invoke("plugin:event|emit", { event: "cloud-auth-changed", payload: null, }); await openEditor(); await app.waitFor( () => app.execute( 'return document.querySelector("#wayfern-launch-args").disabled;', ), { description: "launch arguments locked after sign out" }, ); await app.clickText(en.common.buttons.clear, { exact: true }); await app.clickText(en.common.buttons.save, { exact: true }); await app.waitFor( async () => !(await app.invoke("list_browser_profiles"))[0].wayfern_config .launch_args?.length, { description: "cleared launch arguments" }, ); } finally { await restoreStubs(app); } }, { extraEnv: { WAYFERN_TEST_TOKEN: "e2e-local-launch-args" } }, ); }); test("the create dialog warns a free user who creates quickly and holds a capped plan at its hourly limit", async () => { await withApp( "ui-profile-hourly-limit", async (app) => { const createDisabled = () => app.execute( `const dialog = document.querySelector('[role="dialog"]'); const button = [...(dialog?.querySelectorAll('button') ?? [])] .find((node) => node.textContent.trim() === arguments[0]); return button ? button.disabled : null;`, [en.common.buttons.create], ); // Free: past the allowance the dialog warns, and Create still works. await stubCommand(app, "get_profile_creation_allowance", { enforcement: "soft", per_hour: 8, used: 8, retry_after_secs: 1_200, }); await openProfileCreation(app); await app.waitForText(en.createProfile.hourlyLimit.warningTitle); await app.waitForText(en.createProfile.hourlyLimit.warning); await app.fillSelector("#profile-name", "Quick profile"); assert.equal(await createDisabled(), false); await app.clickTextIn('[role="dialog"]', en.common.buttons.cancel, { roles: ["button"], }); await app.waitFor( () => app.execute(`return !document.querySelector('#profile-name');`), { description: "create dialog closed" }, ); // Solo at its cap: Create is disabled and its tooltip says why and when. await stubCommand(app, "get_profile_creation_allowance", { enforcement: "hard", per_hour: 10, used: 10, retry_after_secs: 1_200, }); await openProfileCreation(app); await app.fillSelector("#profile-name", "Capped profile"); await app.waitFor(async () => (await createDisabled()) === true, { description: "Create disabled at the hourly cap", }); assert.equal( await app.execute( `return document.body.innerText.includes(arguments[0]);`, [en.createProfile.hourlyLimit.warningTitle], ), false, "a capped plan is refused, not warned", ); const target = await app.execute( `const rect = document.querySelector('[data-slot="hourly-limit"]').getBoundingClientRect(); return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) };`, ); try { await app.session.command("POST", "/actions", { actions: [ { type: "pointer", id: "hourly-limit-pointer", actions: [ { type: "pointerMove", x: target.x, y: target.y, origin: "viewport", }, ], }, ], }); } finally { await app.session.command("DELETE", "/actions"); } await app.waitForText( en.createProfile.hourlyLimit.reached .replace("{{limit}}", "10") .replace("{{minutes}}", "20"), ); }, { seedDownloadedBrowser: true }, ); }); /** Stands in for the cloud feedback route and keeps every request it gets. */ async function feedbackCaptureServer() { const received = []; let status = 201; const server = http.createServer((request, response) => { let body = ""; request.setEncoding("utf8"); request.on("data", (chunk) => { body += chunk; }); request.on("end", () => { received.push({ method: request.method, url: request.url, headers: request.headers, body: body ? JSON.parse(body) : null, }); response.writeHead(status, { "content-type": "application/json" }); response.end( JSON.stringify( status < 300 ? { id: `fb-${received.length}`, createdAt: new Date().toISOString(), } : { statusCode: status, message: "Too Many Requests" }, ), ); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); return { received, url: `http://127.0.0.1:${server.address().port}/api/feedback`, respondWith(next) { status = next; }, close: () => new Promise((resolve) => server.close(resolve)), }; } const FEEDBACK_DIALOG = '[data-dialog="feedback"]'; const MOD = process.platform === "darwin" ? { meta: true } : { ctrl: true }; function feedbackLogsSwitch(app) { return app.execute( `return document.querySelector('[data-slot="feedback-include-logs"]')?.dataset.state ?? null;`, ); } function feedbackMessage(app) { return app.execute( `return document.querySelector('[data-slot="feedback-message"]')?.value ?? null;`, ); } test("feedback goes from the More menu to the team, with the logs the person previewed", async () => { const capture = await feedbackCaptureServer(); try { await withApp( "ui-feedback", async (app) => { // Refused before anything leaves the machine. assert.match( await app.invokeError("send_feedback", { kind: "bug", message: " ", includeLogs: false, email: null, locale: "en", }), /FEEDBACK_MESSAGE_EMPTY/, ); assert.match( await app.invokeError("send_feedback", { kind: "idea", message: "More themes", includeLogs: false, email: "not-an-address", locale: "en", }), /FEEDBACK_EMAIL_INVALID/, ); assert.equal(capture.received.length, 0); await app.waitFor( async () => (await app.invoke("preview_feedback_logs")).includes( "Donut started", ), { description: "this session's log in the excerpt" }, ); await app.clickSelector(`[aria-label="${en.rail.more.label}"]`); await app.clickSelector('[data-slot="rail-open-feedback"]'); await app.waitFor( () => app.execute( `return Boolean(document.querySelector(arguments[0]));`, [FEEDBACK_DIALOG], ), { description: "the feedback dialog" }, ); assert.equal( await app.execute(`return document.activeElement?.dataset?.slot;`), "feedback-message", "the message box has focus at once", ); assert.equal( await app.execute( `return document.querySelector('[data-slot="feedback-send"]').disabled;`, ), true, "nothing to send yet", ); await app.capture("feedback-open"); // A bug brings the logs; an idea does not, until the person decides. assert.equal(await feedbackLogsSwitch(app), "checked"); await app.clickSelector( '[data-slot="feedback-kind"][data-kind="idea"]', ); assert.equal(await feedbackLogsSwitch(app), "unchecked"); assert.equal( await app.execute( `return document.querySelector('[data-slot="feedback-message"]').placeholder;`, ), en.feedback.kinds.idea.placeholder, ); await app.clickSelector('[data-slot="feedback-kind"][data-kind="bug"]'); assert.equal(await feedbackLogsSwitch(app), "checked"); await app.clickSelector('[data-slot="feedback-preview-logs"]'); await app.waitFor( () => app.execute( `return document.querySelector('[data-slot="feedback-logs-preview"]')?.value.includes("Donut started") ?? false;`, ), { description: "the log preview" }, ); await app.fillSelector( '[data-slot="feedback-message"]', "The proxy check never finishes", ); await app.fillSelector( '[data-slot="feedback-email"]', "me@example.com", ); await app.capture("feedback-compose"); await app.clickSelector('[data-slot="feedback-send"]'); await app.waitFor( () => app.execute( `return Boolean(document.querySelector('[data-slot="feedback-sent"]'));`, ), { description: "the thank-you view" }, ); assert.ok(await app.visibleTextIncludes(en.feedback.success.title)); assert.ok( await app.visibleTextIncludes( en.feedback.success.reply.replace("{{email}}", "me@example.com"), ), ); await app.capture("feedback-sent"); assert.equal(capture.received.length, 1); const [sent] = capture.received; assert.equal(sent.method, "POST"); assert.equal(sent.url, "/api/feedback"); assert.equal( sent.headers.authorization, undefined, "signed out, so no account", ); assert.equal(sent.body.kind, "bug"); assert.equal(sent.body.source, "person"); assert.equal(sent.body.message, "The proxy check never finishes"); assert.equal(sent.body.email, "me@example.com"); assert.equal(sent.body.locale, "en"); assert.equal(typeof sent.body.appVersion, "string"); assert.ok(sent.body.logs.includes("Donut started")); await app.clickSelector('[data-slot="feedback-sent"] button'); await app.waitFor( async () => (await app.execute( `return Boolean(document.querySelector(arguments[0]));`, [FEEDBACK_DIALOG], )) === false, { description: "the dialog closed" }, ); await app.waitFor( async () => (await app.invoke("get_app_settings")).feedback_sent_at > 0, { description: "the sent feedback remembered for the tips" }, ); // The chord opens a fresh dialog: a sent draft does not come back. await app.pressShortcut({ ...MOD, shift: true, key: "f" }); await app.waitFor(async () => (await feedbackMessage(app)) === "", { description: "an empty dialog from the keyboard", }); // A refusal keeps the draft and says why. capture.respondWith(429); await app.clickSelector( '[data-slot="feedback-kind"][data-kind="idea"]', ); await app.fillSelector( '[data-slot="feedback-message"]', "Dark tray icon", ); await app.pressShortcut({ ...MOD, key: "\uE007" }); await app.waitForText(en.backendErrors.feedbackRateLimited); assert.equal(await feedbackMessage(app), "Dark tray icon"); assert.equal(capture.received.length, 2); assert.equal(capture.received[1].body.kind, "idea"); assert.equal( capture.received[1].body.logs, undefined, "an idea was sent without logs", ); // Closing keeps the unsent draft. The feedback tip leads back to it. await app.pressShortcut({ key: "Escape" }); await app.waitFor( async () => (await app.execute( `return Boolean(document.querySelector(arguments[0]));`, [FEEDBACK_DIALOG], )) === false, { description: "the dialog closed with a draft" }, ); await openTipsFromRail(app); await app.clickSelector( '[data-slot="tips-list-item"][data-tip-id="feedback"]', ); assert.ok(await app.visibleTextIncludes(en.tips.items.feedback.title)); await app.capture("feedback-tip"); await app.clickSelector('[data-slot="tip-action"]'); await app.waitFor( async () => (await feedbackMessage(app)) === "Dark tray icon", { description: "the draft back in the dialog from the tip" }, ); }, { extraEnv: { DONUT_E2E_FEEDBACK_URL: capture.url } }, ); } finally { await capture.close(); } });