Files
donutbrowser/e2e/tests/ui.test.mjs
T
2026-10-10 01:11:04 +04:00

4535 lines
160 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"]);
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,
);
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.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-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("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("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("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/<command>`. 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("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("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();
}
});