Files
donutbrowser/e2e/tests/ui.test.mjs
T
2026-09-09 06:16:14 -07:00

2805 lines
97 KiB
JavaScript

import assert from "node:assert/strict";
import { mkdir, realpath, writeFile } from "node:fs/promises";
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 { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
import { withApp } from "../lib/app.mjs";
import {
CRX_EXTENSION_NAME,
CRX_EXTENSION_VERSION,
extensionZipBase64,
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 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.clickSelector(triggerSelector);
await app.clickText(option, { roles: ["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" },
);
}
test("the integrations page ships the Local API and MCP tabs 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.tabMcp);
// Exactly the two tabs v0.30.0 shipped, in its order. Remote control is
// an Enterprise feature that a desktop without the entitlement is never
// told about, and this session is signed out, so a third 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,
en.integrations.tabMcp,
]);
// 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,
en.integrations.remote.notEntitled,
]) {
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 between the two tabs there are, and 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 app.waitFor(
async () => (await activeTab()) === en.integrations.tabMcp,
{ description: "Mod+I to move to the MCP tab" },
);
await app.pressShortcut({ key: "i", ...modifier });
await app.waitFor(
async () => (await activeTab()) === en.integrations.tabApi,
{ description: "Mod+I to move back to the Local API tab" },
);
assert.deepEqual(await tabs(), [
en.integrations.tabApi,
en.integrations.tabMcp,
]);
await app.clickText(en.integrations.tabMcp, { roles: ["tab"] });
await app.waitForText(en.integrations.mcpEnableLabel);
// Section labels are set in CSS uppercase, which innerText applies, so
// they are read from the label nodes rather than from the body text.
const labelShown = (label) =>
app.execute(
`return [...document.querySelectorAll("label")].some((node) => node.textContent.trim() === arguments[0]);`,
[label],
);
// Local MCP is removed: the tab shows a deprecation banner, the local
// client installer is gone, and enabling it starts no server.
assert.ok(
(await app.bodyText()).includes(
en.integrations.mcp.deprecatedBannerTitle,
),
"the MCP tab must show the local-MCP removal banner",
);
assert.equal(
await labelShown(en.integrations.mcp.clientsLabel),
false,
"the local client installer is gone with the local server",
);
assert.equal(await app.invoke("get_mcp_server_status"), false);
// Attempting to enable local MCP raises the removal dialog and starts
// nothing. Only the MCP tab's content is mounted, so this is its switch.
await app.clickSelector('[role="switch"]');
const removalDialogVisible = () =>
app.execute(
`return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`,
[en.mcpLocalDeprecated.title],
);
await app.waitFor(removalDialogVisible, {
description: "the local MCP removal dialog to open",
});
assert.equal(
await app.invoke("get_mcp_server_status"),
false,
"enabling local MCP must not start a server",
);
assert.equal(
await labelShown(en.integrations.mcp.clientsLabel),
false,
"no local client installer appears after a refused enable",
);
// The refusal must leave the switch off: a regression that flips it on
// visually while starting nothing would otherwise pass.
assert.equal(
await app.execute(
`return document.querySelector('[role="switch"]').getAttribute("aria-checked");`,
),
"false",
"a refused enable must leave the local MCP switch off",
);
// The command also emits a toast with the same title. Observe the dialog
// itself so a toast cannot stand in for modal opening or dismissal.
await app.pressShortcut({ key: "Escape" });
await app.waitFor(async () => !(await removalDialogVisible()), {
description: "the local MCP removal dialog to close",
});
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("VLESS proxy form keeps the share URI 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");
// "VLESS (REALITY)" since the type list was regrouped by whether the
// first hop is encrypted; chooseSelectOption matches the label exactly.
await chooseSelectOption(app, "#proxy-type", "VLESS (REALITY)");
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 URI 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=ws")}&path=%2Fray`,
);
await app.waitFor(
() =>
app.execute(
`return /only|TCP|transport/i.test(
document.querySelector("#proxy-vless-uri-help")?.textContent || ""
);`,
),
{ description: "transport-specific unsupported message" },
);
await app.fillSelector("#proxy-vless-uri", uri);
await app.waitFor(
() =>
app.execute(
`return document.querySelector("#proxy-vless-uri")?.getAttribute("aria-invalid") === "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 opens from the rail and the palette, keeps every tab live, and never shows a form it cannot honour", async () => {
await withApp("ui-agent", async (app) => {
await app.clickSelector(`[aria-label="${en.rail.agent}"]`);
await app.waitForText(en.agent.unavailable.signInTitle);
// Exactly the three panels the page ships, in its order. The strip renders
// whether or not the account can use the agent, because
// AGENT_NOT_CONFIGURED is only learned by asking and the chrome has to be
// on screen when the answer lands.
const tabs = async () =>
app.execute(
`return [...document.querySelectorAll('[role="tab"]')].map((node) => node.textContent.trim());`,
);
assert.deepEqual(await tabs(), [
en.agent.tabs.run,
en.agent.tabs.history,
en.agent.tabs.recipes,
]);
// The explanation is PAINTED, not merely in the DOM. Content that only
// becomes visible once an animation has run renders as an empty panel every
// time the animation does not, which is the failure this asserts against.
const notice = async () =>
app.execute(`
const el = document.querySelector('[data-slot="agent-unavailable"]');
if (!el) return null;
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
width: rect.width,
height: rect.height,
opacity: Number(style.opacity),
visibility: style.visibility,
text: el.innerText.trim(),
};
`);
const signedOut = await notice();
assert.ok(signedOut, "the run tab must explain itself when signed out");
assert.ok(signedOut.width > 0 && signedOut.height > 0);
assert.equal(signedOut.opacity, 1);
assert.equal(signedOut.visibility, "visible");
assert.match(signedOut.text, new RegExp(en.agent.unavailable.signInHint));
// Never a broken form: a signed-out desktop is told what is missing, it is
// not handed a goal field and a submit button that cannot work.
assert.equal(
await app.execute(
`return document.querySelector('[data-slot="agent-run-form"]') === null;`,
),
true,
);
// Every tab answers a real pointer click, and each carries its own
// explanation rather than a blank panel.
for (const label of [
en.agent.tabs.history,
en.agent.tabs.recipes,
en.agent.tabs.run,
]) {
await app.clickText(label);
await app.waitFor(
() =>
app.execute(
`return [...document.querySelectorAll('[role="tab"]')].some(
(node) => node.textContent.trim() === arguments[0] &&
node.getAttribute("data-state") === "active"
);`,
[label],
),
{ description: `${label} agent tab` },
);
const panel = await notice();
assert.ok(panel, `${label} must explain itself when signed out`);
assert.ok(panel.height > 0, `${label} rendered an empty panel`);
}
await dismissSurface(app);
// The same page from the palette. A rail item nobody can reach by keyboard
// is half a navigation.
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 app.waitForText(en.agent.unavailable.signInTitle);
assert.deepEqual(await tabs(), [
en.agent.tabs.run,
en.agent.tabs.history,
en.agent.tabs.recipes,
]);
await dismissSurface(app);
});
});
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.clickSelector(
`[aria-label="${en.profiles.actionBar.distributeProxies}"]`,
);
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 DATA_ROOT = en.settings.dataRoot;
test("the data directory setting shows where state lives, refuses a bad move, and reports the one it makes", async () => {
await withApp("ui-data-root", async (app) => {
const defaultRoot = path.join(app.dataRoot, "data");
await app.clickSelector(`[aria-label="${en.rail.settings}"]`);
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector('[data-slot="data-root-setting"]'));`,
),
{ description: "the data directory control" },
);
const shown = await app.execute(
`return {
path: document.querySelector('[data-slot="data-root-active-path"]')?.innerText ?? null,
size: document.querySelector('[data-slot="data-root-size"]')?.innerText ?? null,
moveDisabled: document.querySelector('[data-slot="data-root-move"]')?.disabled ?? null,
missing: Boolean(document.querySelector('[data-slot="data-root-missing"]')),
restart: Boolean(document.querySelector('[data-slot="data-root-restart-required"]')),
};`,
);
assert.equal(shown.path, defaultRoot, "the real directory is on screen");
assert.match(shown.size, /\d/, "the size has to be a real figure");
assert.equal(
shown.moveDisabled,
true,
"there is nowhere to move to until a folder is chosen",
);
assert.equal(shown.missing, false);
assert.equal(shown.restart, false);
assert.equal(await app.visibleTextIncludes(DATA_ROOT.title), true);
// A folder inside the current directory: the copy would never finish and
// the delete afterwards would take the copy with it.
await stubFolderPicker(app, path.join(defaultRoot, "profiles"));
await app.clickSelector('[data-slot="data-root-choose"]');
await app.waitFor(
async () =>
(await app.execute(
`return document.querySelector('[data-slot="data-root-move"]')?.disabled;`,
)) === false,
{ description: "the move button waking up once a folder is chosen" },
);
const chosen = await app.execute(
`return document.querySelector('[data-slot="data-root-destination"]')?.innerText ?? null;`,
);
assert.ok(
chosen?.startsWith(path.join(defaultRoot, "profiles")),
`the exact destination has to be shown before committing, got: ${chosen}`,
);
await app.clickSelector('[data-slot="data-root-move"]');
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector('[data-slot="data-root-error"]'));`,
),
{ description: "the refusal" },
);
assert.equal(
(await restoreFolderPicker(app)).length,
1,
"the first choice went through the picker",
);
assert.equal(
await app.execute(
`return document.querySelector('[data-slot="data-root-error"]').innerText.trim();`,
),
en.backendErrors.dataRootDestinationInsideSource,
"a refusal is a translated sentence, never a raw code",
);
await app.capture("data-root-refused");
// Nothing was created for a move that was never going to run.
assert.equal(
await app
.invoke("get_data_root_info")
.then((info) => info.configured_path),
null,
);
// Now a real move, into this session's own temporary root.
const destination = path.join(app.root, "ui-moved-data");
await stubFolderPicker(app, destination);
await app.clickSelector('[data-slot="data-root-choose"]');
await app.waitFor(
async () =>
(
await app.execute(
`return document.querySelector('[data-slot="data-root-destination"]')?.innerText ?? "";`,
)
).startsWith(destination),
{ description: "the new destination on screen" },
);
await app.clickSelector('[data-slot="data-root-move"]');
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector('[data-slot="data-root-restart-required"]'));`,
),
{ description: "the restart notice", timeoutMs: 60_000 },
);
assert.equal(
await app.execute(
`return Boolean(document.querySelector('[data-slot="data-root-error"]'));`,
),
false,
"a move that worked must not also report an error",
);
await app.capture("data-root-moved");
// The fixture clears its own log each time it is installed, so this reads
// only the second choice.
const picks = await restoreFolderPicker(app);
assert.equal(picks.length, 1);
assert.equal(
picks[0]?.options?.directory,
true,
"the control asks for a folder, not a file",
);
const info = await app.invoke("get_data_root_info");
assert.ok(
info.configured_path?.startsWith(destination),
`the choice has to be recorded, got: ${info.configured_path}`,
);
assert.equal(info.restart_required, true);
});
});
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("a tip opens by itself once the app settles, then waits a day", async () => {
await withApp(
"ui-tips-auto",
async (app) => {
await app.waitFor(
() =>
app.execute(
`return document.querySelector(arguments[0])?.dataset.mode === "single";`,
[TIPS_DIALOG],
),
{ description: "the automatic tip", timeoutMs: 30_000 },
);
assert.ok(
await app.visibleTextIncludes(en.tips.items.dnsBlocklist.title),
);
assert.equal(
await app.execute(
`return document.querySelectorAll('[data-slot="tips-list-item"]').length;`,
),
0,
"the single card carries no catalog",
);
await app.capture("tips-auto");
await app.waitFor(
async () => {
const state = await app.invoke("get_tips_state");
return (
state.seen.includes("dnsBlocklist") && state.auto_due === false
);
},
{ description: "the automatic tip recorded" },
);
await dismissSurface(app);
await app.waitFor(
() =>
app.execute(`return !document.querySelector(arguments[0]);`, [
TIPS_DIALOG,
]),
{ description: "the dialog closed" },
);
// A restart within the day shows nothing: one tip a day.
await app.restart();
await app.waitForText("No profiles yet");
await new Promise((resolve) => setTimeout(resolve, 4_500));
assert.equal(
await app.execute(
`return Boolean(document.querySelector(arguments[0]));`,
[TIPS_DIALOG],
),
false,
);
},
{ settings: { tips_auto_show: true } },
);
});
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"],
"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);
}
});
});