mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-12 20:58:59 +02:00
1887 lines
73 KiB
JavaScript
1887 lines
73 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdir, rm, 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 { THEMES } from "../../src/lib/themes.ts";
|
|
import { withApp } from "../lib/app.mjs";
|
|
import { extensionZipBase64 } from "../lib/fixtures.mjs";
|
|
|
|
const slot = (name) => `[data-slot="${name}"]`;
|
|
const profileRow = (id) => `tr[data-profile-id="${id}"]`;
|
|
const inspectTrigger = (id) =>
|
|
`${slot("profile-inspect-trigger")}[data-profile-id="${id}"]`;
|
|
const dragHandle = (id) =>
|
|
`${slot("profile-drag-handle")}[data-profile-id="${id}"]`;
|
|
const tableScroll = `${slot("profile-workspace")} > .scroll-fade`;
|
|
const modifier =
|
|
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
|
|
|
async function createProfile(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 waitForSelector(app, selector, present = true) {
|
|
return app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return Boolean(document.querySelector(arguments[0])) === arguments[1];`,
|
|
[selector, present],
|
|
),
|
|
{ description: `${present ? "present" : "absent"} ${selector}` },
|
|
);
|
|
}
|
|
|
|
async function resize(app, width, height) {
|
|
const before = await app.session.command("GET", "/window/rect");
|
|
const viewport = await app.execute(
|
|
"return { width: innerWidth, height: innerHeight };",
|
|
);
|
|
const contentWidth = width - Math.max(0, before.width - viewport.width);
|
|
const contentHeight = height - Math.max(0, before.height - viewport.height);
|
|
await app.session.command("POST", "/window/rect", { width, height });
|
|
// The native resize response precedes WebKit layout and painting. A capture
|
|
// before those complete crops the previous frame to the new window size.
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
"return innerWidth === arguments[0] && innerHeight === arguments[1];",
|
|
[contentWidth, contentHeight],
|
|
),
|
|
{ description: `WebView resized to ${contentWidth} by ${contentHeight}` },
|
|
);
|
|
await app.execute(
|
|
"return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));",
|
|
);
|
|
}
|
|
|
|
async function tabForward(app) {
|
|
// tauri-wd's /actions dispatches an untrusted Tab event without its default.
|
|
// /element/value implements focus traversal, and honors a canceled keydown
|
|
// before moveFocus(), including Radix's modal boundary trap.
|
|
const active = await app.execute("return document.activeElement;");
|
|
await app.session.sendKeys(active, "\uE004");
|
|
}
|
|
|
|
async function activateFocusedByKeyboard(app, key = "\uE006") {
|
|
// tauri-wd 0.1.11 does not implement Enter/Space's default button click in
|
|
// either keyboard endpoint. Exercise the handlers, then supply only the
|
|
// uncanceled missing HTML activation. This is keyboard-path simulation;
|
|
// pointer activation is covered separately through actual WebDriver clicks.
|
|
await app.execute(`
|
|
const state = { target: document.activeElement, events: [], clicks: 0 };
|
|
state.recordKey = (event) => state.events.push(event);
|
|
state.recordClick = (event) => { if (state.target === event.target || state.target.contains(event.target)) state.clicks++; };
|
|
document.addEventListener("keydown", state.recordKey, true);
|
|
document.addEventListener("keyup", state.recordKey, true);
|
|
document.addEventListener("click", state.recordClick, true);
|
|
window.__donutMotionKeyboard = state;
|
|
`);
|
|
try {
|
|
await app.pressShortcut({ key });
|
|
await app.execute(`
|
|
const state = window.__donutMotionKeyboard;
|
|
if (!state.clicks && !state.events.some((event) => event.defaultPrevented) && state.target.isConnected) state.target.click();
|
|
`);
|
|
} finally {
|
|
await app.execute(`
|
|
const state = window.__donutMotionKeyboard;
|
|
if (!state) return;
|
|
document.removeEventListener("keydown", state.recordKey, true);
|
|
document.removeEventListener("keyup", state.recordKey, true);
|
|
document.removeEventListener("click", state.recordClick, true);
|
|
delete window.__donutMotionKeyboard;
|
|
`);
|
|
}
|
|
}
|
|
|
|
async function emit(app, event, payload) {
|
|
await app.invoke("plugin:event|emit", { event, payload });
|
|
}
|
|
|
|
async function assertContained(app, selector) {
|
|
const bounds = await app.execute(
|
|
`
|
|
const element = document.querySelector(arguments[0]);
|
|
if (!element) return null;
|
|
const rect = element.getBoundingClientRect();
|
|
return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom,
|
|
width: innerWidth, height: innerHeight };
|
|
`,
|
|
[selector],
|
|
);
|
|
assert.ok(bounds, selector);
|
|
assert.ok(
|
|
bounds.left >= -1 && bounds.right <= bounds.width + 1,
|
|
JSON.stringify(bounds),
|
|
);
|
|
assert.ok(
|
|
bounds.top >= -1 && bounds.bottom <= bounds.height + 1,
|
|
JSON.stringify(bounds),
|
|
);
|
|
}
|
|
|
|
async function exampleStates(app) {
|
|
return app.execute(`return Object.fromEntries(
|
|
[...document.querySelectorAll('[data-slot="isolation-example-profile"]')].map((profile) =>
|
|
[profile.dataset.profileId, { signedIn: profile.dataset.signedIn, route: profile.dataset.route }])
|
|
);`);
|
|
}
|
|
|
|
async function assertExamples(app, expected) {
|
|
await app.waitFor(
|
|
async () => {
|
|
const actual = await exampleStates(app);
|
|
return (
|
|
Object.keys(actual).length === Object.keys(expected).length &&
|
|
Object.entries(expected).every(
|
|
([id, profile]) =>
|
|
actual[id]?.signedIn === profile.signedIn &&
|
|
actual[id]?.route === profile.route,
|
|
)
|
|
);
|
|
},
|
|
{
|
|
description: `independent example state ${JSON.stringify(expected)}`,
|
|
},
|
|
);
|
|
}
|
|
|
|
const emptyExamples = {
|
|
research: { signedIn: "false", route: "direct" },
|
|
shopping: { signedIn: "false", route: "direct" },
|
|
};
|
|
|
|
async function exerciseIsolation(app) {
|
|
await waitForSelector(app, slot("profile-isolation-demo"));
|
|
const realProfiles = await app.invoke("list_browser_profiles");
|
|
await assertExamples(app, emptyExamples);
|
|
await app.clickSelector(slot("isolation-toggle-cookie"));
|
|
await assertExamples(app, {
|
|
...emptyExamples,
|
|
research: { signedIn: "true", route: "direct" },
|
|
});
|
|
await app.clickSelector(slot("isolation-toggle-route"));
|
|
await assertExamples(app, {
|
|
...emptyExamples,
|
|
research: { signedIn: "true", route: "proxy" },
|
|
});
|
|
await app.clickSelector(
|
|
`${slot("isolation-example-profile")}[data-profile-id="shopping"] ${slot("isolation-select-profile")}`,
|
|
);
|
|
await app.clickSelector(slot("isolation-toggle-cookie"));
|
|
await assertExamples(app, {
|
|
research: { signedIn: "true", route: "proxy" },
|
|
shopping: { signedIn: "true", route: "direct" },
|
|
});
|
|
await app.clickSelector(slot("isolation-toggle-route"));
|
|
await app.clickSelector(slot("isolation-toggle-cookie"));
|
|
await assertExamples(app, {
|
|
research: { signedIn: "true", route: "proxy" },
|
|
shopping: { signedIn: "false", route: "proxy" },
|
|
});
|
|
|
|
// Move focus with the real keyboard, then activate the currently focused
|
|
// route button. The example changes without waiting for a pointer animation.
|
|
await app.execute(
|
|
`document.querySelector('[data-slot="isolation-toggle-cookie"]').focus();`,
|
|
);
|
|
await tabForward(app);
|
|
assert.equal(
|
|
await app.execute(`return document.activeElement?.dataset.slot;`),
|
|
"isolation-toggle-route",
|
|
);
|
|
await activateFocusedByKeyboard(app);
|
|
await assertExamples(app, {
|
|
research: { signedIn: "true", route: "proxy" },
|
|
shopping: { signedIn: "false", route: "direct" },
|
|
});
|
|
await app.clickSelector(slot("isolation-reset"));
|
|
await assertExamples(app, emptyExamples);
|
|
assert.deepEqual(
|
|
await app.invoke("list_browser_profiles"),
|
|
realProfiles,
|
|
"the demonstration never mutates real profiles",
|
|
);
|
|
}
|
|
|
|
test("first-run profile cutaway is interactive, isolated, and readable at both window sizes", async () => {
|
|
await withApp(
|
|
"motion-first-run",
|
|
async (app) => {
|
|
await app.waitForText(en.welcome.title);
|
|
await resize(app, 1080, 900);
|
|
await exerciseIsolation(app);
|
|
await app.capture("onboarding-isolation-wide");
|
|
await resize(app, 640, 480);
|
|
await assertContained(app, '[role="dialog"]');
|
|
await app.clickSelector(`${slot("welcome-features")} summary`);
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelector('[data-slot="welcome-features"]')?.open === true;`,
|
|
),
|
|
{
|
|
description:
|
|
"welcome feature disclosure to open from a real pointer click",
|
|
},
|
|
);
|
|
assert.equal(
|
|
await app.visibleTextIncludes(en.welcome.features.items.cookies),
|
|
true,
|
|
);
|
|
await app.clickSelector(`${slot("welcome-features")} summary`);
|
|
await app.capture("onboarding-isolation-narrow");
|
|
await app.clickText(en.welcome.next, { roles: ["button"] });
|
|
await app.waitForText(en.welcome.license.title);
|
|
assert.equal(
|
|
await app.invoke("get_onboarding_completed"),
|
|
false,
|
|
"the example does not complete actual onboarding",
|
|
);
|
|
},
|
|
{ onboardingCompleted: false },
|
|
);
|
|
});
|
|
|
|
async function openReplay(app) {
|
|
await app.clickSelector(`[aria-label="${en.rail.more.label}"]`);
|
|
await waitForSelector(app, '[role="menu"]');
|
|
await app.clickText(en.rail.more.about, {
|
|
exact: false,
|
|
roles: ["menuitem"],
|
|
});
|
|
await app.clickSelector(slot("isolation-demo-replay"));
|
|
await waitForSelector(app, slot("profile-isolation-demo"));
|
|
}
|
|
|
|
async function assertPopupReadable(app, selector) {
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`
|
|
const content = document.querySelector(arguments[0]);
|
|
if (!content || content.getBoundingClientRect().height <= 0) return false;
|
|
for (let node = content; node instanceof Element; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
if (Number(style.opacity) < 0.99 || style.visibility === "hidden" || style.display === "none") return false;
|
|
}
|
|
return true;
|
|
`,
|
|
[selector],
|
|
),
|
|
{ timeoutMs: 2000, description: `fully readable popup ${selector}` },
|
|
);
|
|
}
|
|
|
|
test("paused CSS animations cannot retain dismissed selects or dropdowns", async () => {
|
|
await withApp(
|
|
"motion-popup-dismissal",
|
|
async (app) => {
|
|
await resize(app, 1100, 760);
|
|
await createProfile(app, "Motion popup Alpha");
|
|
await createProfile(app, "Motion popup Beta");
|
|
await app.clickSelector(`[aria-label="${en.rail.settings}"]`);
|
|
await app.clickSelector("#theme-select");
|
|
await assertPopupReadable(app, slot("select-content"));
|
|
|
|
// Freeze CSS only after the first menu is fully open. A closed Radix
|
|
// Presence must not wait for animationend, which cannot arrive here.
|
|
await app.execute(`
|
|
const style = document.createElement("style");
|
|
style.id = "donut-motion-paused-css";
|
|
style.textContent = "*, *::before, *::after { animation-play-state: paused !important; }";
|
|
document.head.append(style);
|
|
window.__donutPausedSelect = document.querySelector('[data-slot="select-content"]');
|
|
`);
|
|
try {
|
|
assert.equal(
|
|
await app.execute(
|
|
`return getComputedStyle(window.__donutPausedSelect).animationPlayState;`,
|
|
),
|
|
"paused",
|
|
);
|
|
await app.clickText(en.common.labels.custom, { roles: ["option"] });
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return !window.__donutPausedSelect.isConnected && !document.querySelector('[data-slot="select-content"]');`,
|
|
),
|
|
{
|
|
timeoutMs: 2000,
|
|
description:
|
|
"selected menu to unmount without a CSS animationend event",
|
|
},
|
|
);
|
|
|
|
// The next popup also opens under the paused clock: both its content
|
|
// and its pointer targets must be available at the initial frame.
|
|
await app.clickSelector("#theme-preset-select");
|
|
await assertPopupReadable(app, slot("select-content"));
|
|
await app.capture("paused-css-preset-select");
|
|
const preset = THEMES.find((theme) => theme.id === "dracula");
|
|
assert.ok(preset);
|
|
await app.clickText(preset.name, { roles: ["option"] });
|
|
await waitForSelector(app, slot("select-content"), false);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector("#theme-preset-select")?.textContent.trim();`,
|
|
),
|
|
preset.name,
|
|
);
|
|
await app.clickText(en.common.buttons.saveSettings, {
|
|
roles: ["button"],
|
|
});
|
|
await app.waitFor(
|
|
() => app.visibleTextIncludes(en.common.buttons.saved),
|
|
{ description: "saved settings feedback" },
|
|
);
|
|
await waitForSelector(app, "#theme-select", true);
|
|
await app.clickSelector(`[aria-label="${en.rail.profiles}"]`);
|
|
|
|
const openSort = () =>
|
|
app.clickTextIn("thead", en.common.labels.name, {
|
|
roles: ["button"],
|
|
});
|
|
await openSort();
|
|
await assertPopupReadable(app, slot("dropdown-menu-content"));
|
|
await app.capture("paused-css-profile-sort");
|
|
await app.clickText(en.profiles.sort.nameDesc, {
|
|
roles: ["menuitem"],
|
|
});
|
|
await waitForSelector(app, slot("dropdown-menu-content"), false);
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelector('tr[data-profile-id]')?.textContent.includes("Motion popup Beta");`,
|
|
),
|
|
{ description: "pointer-selected descending profile order" },
|
|
);
|
|
await openSort();
|
|
await assertPopupReadable(app, slot("dropdown-menu-content"));
|
|
await app.clickText(en.profiles.sort.nameAsc, {
|
|
roles: ["menuitem"],
|
|
});
|
|
await waitForSelector(app, slot("dropdown-menu-content"), false);
|
|
|
|
await app.clickSelector(`[aria-label="${en.rail.more.label}"]`);
|
|
await assertPopupReadable(app, '[role="menu"]');
|
|
await app.clickText(en.rail.more.about, {
|
|
exact: false,
|
|
roles: ["menuitem"],
|
|
});
|
|
await waitForSelector(app, '[role="menu"]', false);
|
|
await app.clickSelector(slot("isolation-demo-replay"));
|
|
await waitForSelector(app, slot("profile-isolation-demo"));
|
|
await app.capture("paused-css-about-replay");
|
|
} finally {
|
|
await app.execute(`
|
|
document.getElementById("donut-motion-paused-css")?.remove();
|
|
delete window.__donutPausedSelect;
|
|
`);
|
|
}
|
|
},
|
|
{ seedDownloadedBrowser: true },
|
|
);
|
|
});
|
|
|
|
async function clickVisible(app, selector) {
|
|
// tauri-wd's element click always scrollIntoView(center), including a raw
|
|
// session.click. Pointer actions preserve the position of an in-view row.
|
|
const point = await pointIn(app, selector);
|
|
try {
|
|
await pointerActions(app, [
|
|
{ type: "pointerMove", x: point.x, y: point.y, origin: "viewport" },
|
|
{ type: "pointerDown", button: 0 },
|
|
{ type: "pointerUp", button: 0 },
|
|
]);
|
|
} finally {
|
|
await app.session.command("DELETE", "/actions");
|
|
}
|
|
}
|
|
|
|
async function visibleProfileIds(app, count = 3) {
|
|
return app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`
|
|
const scroller = document.querySelector(arguments[0]);
|
|
if (!scroller) return null;
|
|
const bounds = scroller.getBoundingClientRect();
|
|
const rows = [...scroller.querySelectorAll('tr[data-profile-id]')].filter((row) => {
|
|
const rect = row.getBoundingClientRect();
|
|
return rect.top > bounds.top + 45 && rect.bottom < bounds.bottom - 110;
|
|
});
|
|
return rows.length >= arguments[1] ? rows.slice(0, arguments[1]).map((row) => row.dataset.profileId) : null;
|
|
`,
|
|
[tableScroll, count],
|
|
),
|
|
{ description: `${count} fully visible profile rows` },
|
|
);
|
|
}
|
|
|
|
async function toggleProfile(app, id) {
|
|
const selector = `${profileRow(id)} [role="checkbox"], ${profileRow(id)} [aria-label="${en.common.aria.selectProfile}"]`;
|
|
await clickVisible(app, selector);
|
|
}
|
|
|
|
async function pointIn(app, selector) {
|
|
const point = await app.execute(
|
|
`
|
|
const node = document.querySelector(arguments[0]);
|
|
if (!node) return null;
|
|
const rect = node.getBoundingClientRect();
|
|
const x = Math.round(rect.left + rect.width / 2);
|
|
const y = Math.round(rect.top + rect.height / 2);
|
|
const hit = document.elementFromPoint(x, y);
|
|
return hit && (hit === node || node.contains(hit)) ? { x, y, left: rect.left, top: rect.top } : null;
|
|
`,
|
|
[selector],
|
|
);
|
|
assert.ok(point, `pointer-interactable ${selector}`);
|
|
return point;
|
|
}
|
|
|
|
async function pointerActions(app, actions) {
|
|
await app.session.command("POST", "/actions", {
|
|
actions: [
|
|
{
|
|
type: "pointer",
|
|
id: "motion-profile-pointer",
|
|
parameters: { pointerType: "mouse" },
|
|
actions,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
async function beginDrag(app, id) {
|
|
const start = await pointIn(app, dragHandle(id));
|
|
await pointerActions(app, [
|
|
{ type: "pointerMove", x: start.x, y: start.y, origin: "viewport" },
|
|
{ type: "pointerDown", button: 0 },
|
|
{ type: "pause", duration: 50 },
|
|
{
|
|
type: "pointerMove",
|
|
x: start.x + 16,
|
|
y: start.y + 10,
|
|
duration: 100,
|
|
origin: "viewport",
|
|
},
|
|
]);
|
|
await waitForSelector(
|
|
app,
|
|
`${slot("profile-drag-preview")}[data-phase="dragging"]`,
|
|
);
|
|
const preview = await app.execute(`
|
|
const rect = document.querySelector('[data-slot="profile-drag-preview"]').getBoundingClientRect();
|
|
return { left: rect.left, top: rect.top };
|
|
`);
|
|
assert.ok(
|
|
Math.abs(preview.left - start.left - 16) <= 3,
|
|
"drag preserves the horizontal grab offset",
|
|
);
|
|
assert.ok(
|
|
Math.abs(preview.top - start.top - 10) <= 3,
|
|
"drag preserves the vertical grab offset",
|
|
);
|
|
}
|
|
|
|
async function groupAssignments(app) {
|
|
const profiles = await app.invoke("list_browser_profiles");
|
|
return Object.fromEntries(
|
|
profiles.map((profile) => [profile.id, profile.group_id ?? null]),
|
|
);
|
|
}
|
|
|
|
async function installSynchronizerFixture(app) {
|
|
// This test supplies a paid user's UI state without changing authentication,
|
|
// entitlement checks, or synchronizer state in the backend. The only
|
|
// operation being simulated is the readiness promise of start_sync_session.
|
|
// Tauri defines invoke/ipc/postMessage as non-writable. Like the native folder
|
|
// picker fixture in ui.test.mjs, this wraps only their IPC fetch transport.
|
|
await app.execute(`
|
|
const fixture = { originalFetch: window.fetch, requests: [], pending: [] };
|
|
window.__donutMotionSynchronizer = fixture;
|
|
const response = (value, ok = true) => new Response(JSON.stringify(value), {
|
|
status: 200, headers: { "content-type": "application/json", "Tauri-Response": ok ? "ok" : "error" }
|
|
});
|
|
window.fetch = function (input, init) {
|
|
let command = "";
|
|
try {
|
|
const url = new URL(typeof input === "string" ? input : input.url);
|
|
if ((url.protocol === "ipc:" && url.hostname === "localhost") ||
|
|
(["http:", "https:"].includes(url.protocol) && url.hostname === "ipc.localhost")) {
|
|
command = decodeURIComponent(url.pathname.split("/").pop() || "");
|
|
}
|
|
} catch {}
|
|
if (command === "cloud_get_user") return Promise.resolve(response({
|
|
logged_in_at: "2026-09-01T00:00:00Z",
|
|
user: {
|
|
id: "motion-ui-fixture", email: "motion@example.test", plan: "pro",
|
|
planPeriod: "monthly", subscriptionStatus: "active", profileLimit: 50,
|
|
cloudProfilesUsed: 0, proxyBandwidthLimitMb: 0, proxyBandwidthUsedMb: 0,
|
|
proxyBandwidthExtraMb: 0, isPrimaryDevice: true
|
|
}
|
|
}));
|
|
if (command === "start_sync_session") {
|
|
fixture.requests.push(JSON.parse(init.body));
|
|
return new Promise((resolve) => fixture.pending.push({
|
|
resolve: (value) => resolve(response(value)),
|
|
// Reject at the Tauri response layer. Rejecting fetch itself would
|
|
// trigger its native-transport fallback and launch real browsers.
|
|
reject: (error) => resolve(response(error, false))
|
|
}));
|
|
}
|
|
return fixture.originalFetch.apply(window, arguments);
|
|
};
|
|
`);
|
|
assert.equal(
|
|
(await app.invoke("cloud_get_user")).user?.id,
|
|
"motion-ui-fixture",
|
|
"scoped IPC fixture is active before opening paid UI",
|
|
);
|
|
await emit(app, "cloud-auth-changed", null);
|
|
}
|
|
|
|
async function restoreSynchronizerFixture(app) {
|
|
await app.execute(`
|
|
const fixture = window.__donutMotionSynchronizer;
|
|
if (!fixture) return;
|
|
window.fetch = fixture.originalFetch;
|
|
for (const pending of fixture.pending) pending.reject("Motion fixture disposed");
|
|
delete window.__donutMotionSynchronizer;
|
|
`);
|
|
await emit(app, "cloud-auth-expired", null);
|
|
}
|
|
|
|
test("synchronizer rehearsal targets selected profiles and waits for actual command readiness", async () => {
|
|
await withApp(
|
|
"motion-synchronizer",
|
|
async (app) => {
|
|
await resize(app, 1080, 900);
|
|
const leader = await createProfile(app, "Motion rehearsal leader");
|
|
const first = await createProfile(app, "Motion rehearsal follower one");
|
|
const second = await createProfile(
|
|
app,
|
|
"Motion rehearsal follower with a longer profile name",
|
|
);
|
|
await installSynchronizerFixture(app);
|
|
const session = {
|
|
id: "motion-ui-rehearsal-session",
|
|
leader_profile_id: leader.id,
|
|
leader_profile_name: leader.name,
|
|
followers: [
|
|
{
|
|
profile_id: first.id,
|
|
profile_name: first.name,
|
|
failed_at_url: null,
|
|
},
|
|
],
|
|
};
|
|
try {
|
|
await app.clickSelector(inspectTrigger(leader.id));
|
|
await app.clickSelector(
|
|
`${slot("profile-info-section")}[data-section="automation"]`,
|
|
);
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelector('[data-slot="profile-start-synchronizer"]')?.disabled === false;`,
|
|
),
|
|
{
|
|
description:
|
|
"paid fixture can open the real synchronizer selection interface",
|
|
},
|
|
);
|
|
await app.clickSelector(slot("profile-start-synchronizer"));
|
|
await waitForSelector(app, slot("synchronizer-follower-dialog"));
|
|
const option = (id) =>
|
|
`${slot("synchronizer-follower-option")}[data-profile-id="${id}"]`;
|
|
const checkbox = (id) => `${option(id)} [role="checkbox"]`;
|
|
const checked = (id, value) =>
|
|
app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelector(arguments[0])?.getAttribute("aria-checked") === arguments[1];`,
|
|
[checkbox(id), String(value)],
|
|
),
|
|
{
|
|
description: `${id} checkbox becomes ${value}`,
|
|
},
|
|
);
|
|
await app.clickSelector(`${option(first.id)} > span`);
|
|
await checked(first.id, true);
|
|
await app.clickSelector(checkbox(first.id));
|
|
await checked(first.id, false);
|
|
await app.execute(`document.querySelector(arguments[0]).focus();`, [
|
|
checkbox(first.id),
|
|
]);
|
|
await activateFocusedByKeyboard(app, "\uE00D");
|
|
await checked(first.id, true);
|
|
await app.clickSelector(`${option(second.id)} > span`);
|
|
await checked(second.id, true);
|
|
await app.clickSelector(slot("synchronizer-preview-send"));
|
|
const received = () =>
|
|
app.execute(
|
|
`return [...document.querySelectorAll('[data-slot="synchronizer-preview-follower"][data-received="true"]')].map((node) => node.dataset.profileId).sort();`,
|
|
);
|
|
await app.waitFor(
|
|
async () =>
|
|
JSON.stringify(await received()) ===
|
|
JSON.stringify([first.id, second.id].sort()),
|
|
{
|
|
description:
|
|
"example click reaches exactly the two selected real profile names",
|
|
},
|
|
);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return window.__donutMotionSynchronizer.requests.length;`,
|
|
),
|
|
0,
|
|
"rehearsal never starts browsers",
|
|
);
|
|
await app.capture("synchronizer-rehearsal-wide");
|
|
await app.clickSelector(checkbox(second.id));
|
|
await checked(second.id, false);
|
|
assert.deepEqual(
|
|
await received(),
|
|
[],
|
|
"changing selection resets the previous rehearsal result",
|
|
);
|
|
await app.execute(
|
|
`document.querySelector('[data-slot="synchronizer-preview-send"]').focus();`,
|
|
);
|
|
await activateFocusedByKeyboard(app);
|
|
await app.waitFor(
|
|
async () =>
|
|
JSON.stringify(await received()) === JSON.stringify([first.id]),
|
|
{
|
|
description:
|
|
"keyboard rehearsal acknowledges only the selected follower",
|
|
},
|
|
);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelectorAll('[data-slot="synchronizer-rehearsal"] path[stroke-width="2.5"]').length;`,
|
|
),
|
|
0,
|
|
"keyboard preview is static",
|
|
);
|
|
await resize(app, 640, 700);
|
|
await assertContained(app, '[role="dialog"]');
|
|
await app.capture("synchronizer-rehearsal-narrow");
|
|
|
|
await app.clickSelector(slot("synchronizer-start"));
|
|
await waitForSelector(app, slot("synchronizer-starting"));
|
|
assert.deepEqual(
|
|
await app.execute(
|
|
`return window.__donutMotionSynchronizer.requests;`,
|
|
),
|
|
[
|
|
{
|
|
leaderProfileId: leader.id,
|
|
followerProfileIds: [first.id],
|
|
},
|
|
],
|
|
);
|
|
await emit(app, "sync-session-changed", session);
|
|
await waitForSelector(app, slot("synchronizer-starting"));
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector('[data-slot="synchronizer-start"]')?.disabled;`,
|
|
),
|
|
true,
|
|
);
|
|
await assert.rejects(
|
|
app.session.click(
|
|
await app.session.findCss(slot("synchronizer-start")),
|
|
),
|
|
/element does not receive pointer events/,
|
|
);
|
|
await app.pressShortcut({ key: "Escape" });
|
|
assert.equal(
|
|
await app.execute(
|
|
`return window.__donutMotionSynchronizer.requests.length;`,
|
|
),
|
|
1,
|
|
"pending startup cannot be duplicated",
|
|
);
|
|
await waitForSelector(app, slot("synchronizer-follower-dialog"));
|
|
await app.capture("synchronizer-awaiting-readiness");
|
|
await app.execute(
|
|
`window.__donutMotionSynchronizer.pending.shift().reject(JSON.stringify({ code: "PROFILE_RUNNING" }));`,
|
|
);
|
|
await waitForSelector(app, slot("synchronizer-start-error"));
|
|
await checked(first.id, true);
|
|
await checked(second.id, false);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector('[data-slot="synchronizer-start"]')?.disabled;`,
|
|
),
|
|
false,
|
|
"failed startup can be retried",
|
|
);
|
|
await app.capture("synchronizer-retry");
|
|
await emit(app, "sync-session-ended", session.id);
|
|
await app.clickSelector(slot("synchronizer-start"));
|
|
await waitForSelector(app, slot("synchronizer-starting"));
|
|
assert.equal(
|
|
await app.execute(
|
|
`return window.__donutMotionSynchronizer.requests.length;`,
|
|
),
|
|
2,
|
|
);
|
|
await app.execute(
|
|
`window.__donutMotionSynchronizer.pending.shift().resolve(arguments[0]);`,
|
|
[session],
|
|
);
|
|
await waitForSelector(app, slot("synchronizer-follower-dialog"), false);
|
|
assert.deepEqual(
|
|
await app.invoke("get_sync_sessions"),
|
|
[],
|
|
"the readiness fixture never creates a backend session",
|
|
);
|
|
const profiles = await app.invoke("list_browser_profiles");
|
|
assert.ok(
|
|
profiles.every((profile) => profile.process_id == null),
|
|
"rehearsal and simulated readiness launch no browser processes",
|
|
);
|
|
} finally {
|
|
await emit(app, "sync-session-ended", session.id);
|
|
await restoreSynchronizerFixture(app);
|
|
}
|
|
},
|
|
{ seedDownloadedBrowser: true },
|
|
);
|
|
});
|
|
|
|
async function freezeAnimations(app) {
|
|
// Motion captures requestAnimationFrame at module initialization, so replacing
|
|
// the global afterward does not stall its engine. Its JS batcher reads
|
|
// performance.now on every batch; native animations need a separate pause.
|
|
await app.execute(`
|
|
const state = {
|
|
now: performance.now(),
|
|
nowDescriptor: Object.getOwnPropertyDescriptor(performance, "now"),
|
|
animateDescriptor: Object.getOwnPropertyDescriptor(Element.prototype, "animate"),
|
|
animations: []
|
|
};
|
|
window.__donutFrozenMotion = state;
|
|
Object.defineProperty(performance, "now", { configurable: true, value: () => state.now });
|
|
if (state.animateDescriptor?.value) {
|
|
Object.defineProperty(Element.prototype, "animate", {
|
|
...state.animateDescriptor,
|
|
value: function (...args) {
|
|
const animation = state.animateDescriptor.value.apply(this, args);
|
|
const play = animation.play;
|
|
const pause = animation.pause;
|
|
state.animations.push({ animation, play, playDescriptor: Object.getOwnPropertyDescriptor(animation, "play") });
|
|
// NativeAnimation may explicitly play after construction. Keep that
|
|
// path paused too, so its first keyframe remains the rendered frame.
|
|
Object.defineProperty(animation, "play", {
|
|
configurable: true,
|
|
value: function () { play.call(this); pause.call(this); this.currentTime = 0; }
|
|
});
|
|
pause.call(animation);
|
|
animation.currentTime = 0;
|
|
return animation;
|
|
}
|
|
});
|
|
}
|
|
`);
|
|
}
|
|
|
|
async function resumeAnimations(app) {
|
|
await app.execute(`
|
|
const state = window.__donutFrozenMotion;
|
|
if (!state) return;
|
|
if (state.nowDescriptor) Object.defineProperty(performance, "now", state.nowDescriptor);
|
|
else delete performance.now;
|
|
if (state.animateDescriptor) Object.defineProperty(Element.prototype, "animate", state.animateDescriptor);
|
|
for (const { animation, play, playDescriptor } of state.animations) {
|
|
if (playDescriptor) Object.defineProperty(animation, "play", playDescriptor);
|
|
else delete animation.play;
|
|
if (animation.playState === "paused") play.call(animation);
|
|
}
|
|
delete window.__donutFrozenMotion;
|
|
`);
|
|
}
|
|
|
|
test("profile replay, inspector, group gestures, and remote handoff preserve context", async () => {
|
|
await withApp(
|
|
"motion-workspace",
|
|
async (app) => {
|
|
await resize(app, 1480, 900);
|
|
await openReplay(app);
|
|
await exerciseIsolation(app);
|
|
await app.capture("isolation-replay-wide");
|
|
await resize(app, 640, 480);
|
|
await assertContained(app, '[role="dialog"]');
|
|
await app.capture("isolation-replay-narrow");
|
|
await app.clickText(en.common.buttons.back, { roles: ["button"] });
|
|
await waitForSelector(app, slot("isolation-demo-replay"));
|
|
assert.equal(
|
|
await app.execute(`return document.activeElement?.dataset.slot;`),
|
|
"isolation-demo-replay",
|
|
);
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await waitForSelector(app, '[role="dialog"]', false);
|
|
await resize(app, 1480, 900);
|
|
|
|
const group = await app.invoke("create_profile_group", {
|
|
name: "Motion research group",
|
|
});
|
|
const proxy = await app.invoke("create_stored_proxy", {
|
|
name: "Motion inspector proxy",
|
|
proxySettings: {
|
|
proxy_type: "http",
|
|
host: "127.0.0.1",
|
|
port: 9,
|
|
username: null,
|
|
password: null,
|
|
},
|
|
});
|
|
for (let index = 0; index < 36; index += 1) {
|
|
await createProfile(
|
|
app,
|
|
`Motion profile ${String(index + 1).padStart(2, "0")}`,
|
|
);
|
|
}
|
|
await app.waitFor(
|
|
async () => (await app.invoke("list_browser_profiles")).length === 36,
|
|
{ description: "all motion fixture profiles" },
|
|
);
|
|
await waitForSelector(app, slot("profile-inspect-trigger"));
|
|
await app.execute(
|
|
`document.querySelector(arguments[0]).scrollTop = 250;`,
|
|
[tableScroll],
|
|
);
|
|
const [first, second] = await visibleProfileIds(app, 2);
|
|
await toggleProfile(app, first);
|
|
const scrollBefore = await app.execute(
|
|
`return document.querySelector(arguments[0]).scrollTop;`,
|
|
[tableScroll],
|
|
);
|
|
assert.ok(scrollBefore > 0);
|
|
try {
|
|
await freezeAnimations(app);
|
|
await clickVisible(app, inspectTrigger(first));
|
|
await waitForSelector(
|
|
app,
|
|
`${slot("profile-inspector")}[data-profile-id="${first}"]`,
|
|
);
|
|
assert.equal(
|
|
await app.execute(`
|
|
const content = document.querySelector('[data-slot="profile-info-content"]');
|
|
const control = document.querySelector('[data-slot="profile-info-section"][data-section="network"]');
|
|
return [content, control].every((element) => {
|
|
if (!element || element.getBoundingClientRect().height <= 0) return false;
|
|
for (let node = element; node instanceof Element; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
if (Number(style.opacity) === 0 || style.visibility === "hidden" || style.display === "none") return false;
|
|
}
|
|
return true;
|
|
});
|
|
`),
|
|
true,
|
|
"inspector text and controls remain visible with the animation clock frozen at its initial frame",
|
|
);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return performance.now() === window.__donutFrozenMotion.now && window.__donutFrozenMotion.animations.every(({ animation }) => animation.playState !== "running" && (animation.currentTime === null || animation.currentTime === 0));`,
|
|
),
|
|
true,
|
|
"both JavaScript and newly created native animations remain frozen",
|
|
);
|
|
await app.capture("profile-inspector-animation-frozen");
|
|
} finally {
|
|
await resumeAnimations(app);
|
|
}
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector('[data-slot="dialog-overlay"]') === null;`,
|
|
),
|
|
true,
|
|
"wide inspector is nonmodal",
|
|
);
|
|
await app.clickSelector(
|
|
`${slot("profile-info-section")}[data-section="network"]`,
|
|
);
|
|
await clickVisible(app, inspectTrigger(second));
|
|
await waitForSelector(
|
|
app,
|
|
`${slot("profile-info-content")}[data-profile-id="${second}"][data-section="network"]`,
|
|
);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector(arguments[0]).scrollTop;`,
|
|
[tableScroll],
|
|
),
|
|
scrollBefore,
|
|
"profile switching preserves table scroll",
|
|
);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector(arguments[0])?.getAttribute("data-state");`,
|
|
[profileRow(first)],
|
|
),
|
|
"selected",
|
|
"profile switching preserves selection",
|
|
);
|
|
await app.capture("profile-inspector-wide");
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await waitForSelector(app, slot("profile-inspector"), false);
|
|
assert.equal(
|
|
await app.execute(`return document.activeElement?.dataset.profileId;`),
|
|
second,
|
|
"Escape restores the last inspection trigger",
|
|
);
|
|
|
|
await activateFocusedByKeyboard(app);
|
|
await waitForSelector(app, slot("profile-inspector"));
|
|
await app.clickSelector(
|
|
`${slot("profile-info-section")}[data-section="network"]`,
|
|
);
|
|
await app.clickSelector(
|
|
`${slot("profile-info-content")}[data-section="network"] [role="combobox"]`,
|
|
);
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(`
|
|
const popup = document.querySelector('[data-slot="select-content"]');
|
|
return popup?.contains(document.activeElement) && !popup.closest('[aria-hidden="true"]');
|
|
`),
|
|
{
|
|
description: "focused, accessibility-visible inspector proxy picker",
|
|
},
|
|
);
|
|
// Radix Select intentionally closes on window.resize. Its ownership
|
|
// cleanup must restore the inspector's accessibility exposure.
|
|
await resize(app, 720, 600);
|
|
await waitForSelector(app, slot("profile-inspector"), false);
|
|
await waitForSelector(app, slot("select-content"), false);
|
|
const inspectorAccessible = () =>
|
|
app.execute(`
|
|
const content = document.querySelector('[data-slot="profile-info-content"]');
|
|
return content && !content.closest('[aria-hidden="true"]');
|
|
`);
|
|
await app.waitFor(inspectorAccessible, {
|
|
description: "inspector is accessible after Select closes on resize",
|
|
});
|
|
await app.clickSelector(
|
|
`${slot("profile-info-content")}[data-section="network"] [role="combobox"]`,
|
|
);
|
|
await app.clickText(proxy.name, { roles: ["option"] });
|
|
await waitForSelector(app, slot("select-content"), false);
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(profile) => profile.id === second,
|
|
)?.proxy_id === proxy.id,
|
|
{ description: "real pointer selection persists the proxy assignment" },
|
|
);
|
|
// The proxy is an inert local fixture. Selecting it neither starts a
|
|
// browser nor claims the endpoint is reachable.
|
|
await resize(app, 1480, 900);
|
|
await waitForSelector(app, slot("profile-inspector"));
|
|
await app.clickSelector(
|
|
`${slot("profile-info-section")}[data-section="overview"]`,
|
|
);
|
|
const colorTrigger = `[aria-label="${en.profileInfo.fields.windowColor}"]`;
|
|
await app.clickSelector(colorTrigger);
|
|
await assertPopupReadable(app, slot("popover-content"));
|
|
await app.clickSelector(`${slot("popover-content")} input`);
|
|
const colorBefore = await app.execute(`
|
|
const popup = document.querySelector('[data-slot="popover-content"]');
|
|
window.__donutMotionOwnedPopup = { popup, focused: document.activeElement };
|
|
return popup.querySelector('input').value;
|
|
`);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector(arguments[0]).getAttribute('aria-controls') === window.__donutMotionOwnedPopup.popup.id;`,
|
|
[colorTrigger],
|
|
),
|
|
true,
|
|
"the color trigger identifies its actual owned portal",
|
|
);
|
|
try {
|
|
await resize(app, 720, 600);
|
|
await waitForSelector(app, slot("profile-inspector"), false);
|
|
await assertPopupReadable(app, slot("popover-content"));
|
|
assert.deepEqual(
|
|
await app.execute(`
|
|
const popup = document.querySelector('[data-slot="popover-content"]');
|
|
return {
|
|
samePopup: popup === window.__donutMotionOwnedPopup.popup,
|
|
sameFocus: document.activeElement === window.__donutMotionOwnedPopup.focused,
|
|
focusInside: popup?.contains(document.activeElement),
|
|
accessible: Boolean(popup && !popup.closest('[aria-hidden="true"]'))
|
|
};
|
|
`),
|
|
{
|
|
samePopup: true,
|
|
sameFocus: true,
|
|
focusInside: true,
|
|
accessible: true,
|
|
},
|
|
"wide-to-narrow resize preserves the owned portal, focus, and accessibility exposure",
|
|
);
|
|
await app.capture("profile-inspector-owned-popup-narrow");
|
|
const colorPoint = await pointIn(
|
|
app,
|
|
`${slot("popover-content")} .h-32`,
|
|
);
|
|
try {
|
|
await pointerActions(app, [
|
|
{
|
|
type: "pointerMove",
|
|
x: colorPoint.x,
|
|
y: colorPoint.y,
|
|
origin: "viewport",
|
|
},
|
|
{ type: "pointerDown", button: 0 },
|
|
{
|
|
type: "pointerMove",
|
|
x: colorPoint.x + 20,
|
|
y: colorPoint.y + 10,
|
|
duration: 120,
|
|
origin: "viewport",
|
|
},
|
|
{ type: "pointerUp", button: 0 },
|
|
]);
|
|
} finally {
|
|
await app.session.command("DELETE", "/actions");
|
|
}
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelector('[data-slot="popover-content"] input').value !== arguments[0];`,
|
|
[colorBefore],
|
|
),
|
|
{
|
|
description:
|
|
"resized color popup responds to real pointer selection",
|
|
},
|
|
);
|
|
// The picker renders its HSL input separately from its effect-driven
|
|
// onColorChange. The controlled swatch is the color committed on close.
|
|
const chosenColor = Color(
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`
|
|
const color = getComputedStyle(document.querySelector(arguments[0])).backgroundColor;
|
|
const before = document.createElement('span').style;
|
|
before.backgroundColor = arguments[1];
|
|
return color !== before.backgroundColor ? color : null;
|
|
`,
|
|
[colorTrigger, colorBefore],
|
|
),
|
|
{
|
|
description:
|
|
"selected color reaches the controlled profile swatch",
|
|
},
|
|
),
|
|
).hex();
|
|
await app.clickSelector(colorTrigger);
|
|
await waitForSelector(app, slot("popover-content"), false);
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_browser_profiles"))
|
|
.find((profile) => profile.id === second)
|
|
?.window_color?.toLowerCase() === chosenColor.toLowerCase(),
|
|
{
|
|
description:
|
|
"closing the color popup persists its real selected color",
|
|
},
|
|
);
|
|
await app.waitFor(inspectorAccessible, {
|
|
description:
|
|
"inspector remains accessible after its owned popup closes",
|
|
});
|
|
} finally {
|
|
await app.execute(`delete window.__donutMotionOwnedPopup;`);
|
|
}
|
|
await resize(app, 1480, 900);
|
|
await waitForSelector(app, slot("profile-inspector"));
|
|
await app.clickSelector(
|
|
`${slot("profile-info-section")}[data-section="automation"]`,
|
|
);
|
|
const draft = "https://example.com/inspector-draft";
|
|
await app.fillSelector(slot("profile-launch-hook-input"), draft);
|
|
await app.execute(
|
|
`window.__donutMotionDraftInput = document.querySelector('[data-slot="profile-launch-hook-input"]');`,
|
|
);
|
|
await resize(app, 720, 600);
|
|
await waitForSelector(app, slot("profile-inspector"), false);
|
|
await waitForSelector(
|
|
app,
|
|
`${slot("profile-info-content")}[data-profile-id="${second}"][data-section="automation"]`,
|
|
);
|
|
assert.deepEqual(
|
|
await app.execute(`
|
|
const input = document.querySelector('[data-slot="profile-launch-hook-input"]');
|
|
return { value: input?.value, sameNode: input === window.__donutMotionDraftInput };
|
|
`),
|
|
{ value: draft, sameNode: true },
|
|
"wide-to-narrow transition preserves the actual unsaved editor",
|
|
);
|
|
await assertContained(app, '[role="dialog"]');
|
|
await app.capture("profile-inspector-narrow");
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.activeElement === document.querySelector('[data-slot="profile-launch-hook-input"]');`,
|
|
),
|
|
true,
|
|
"resizing keeps focus in the unsaved editor",
|
|
);
|
|
for (let index = 0; index < 14; index += 1) {
|
|
await tabForward(app);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector('[role="dialog"]')?.contains(document.activeElement);`,
|
|
),
|
|
true,
|
|
"compact inspector traps keyboard focus inside the modal",
|
|
);
|
|
}
|
|
await resize(app, 1480, 900);
|
|
await waitForSelector(app, slot("profile-inspector"));
|
|
assert.deepEqual(
|
|
await app.execute(`
|
|
const input = document.querySelector('[data-slot="profile-launch-hook-input"]');
|
|
return { value: input?.value, sameNode: input === window.__donutMotionDraftInput };
|
|
`),
|
|
{ value: draft, sameNode: true },
|
|
"narrow-to-wide transition also preserves the unsaved editor",
|
|
);
|
|
await app.execute(`delete window.__donutMotionDraftInput;`);
|
|
await resize(app, 720, 600);
|
|
await waitForSelector(app, slot("profile-inspector"), false);
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await waitForSelector(app, slot("profile-info-content"), false);
|
|
await app.pressShortcut({ key: "k", ...modifier });
|
|
await waitForSelector(app, "[cmdk-input]");
|
|
await app.session.sendKeys(
|
|
await app.session.findCss("[cmdk-input]"),
|
|
en.rail.settings,
|
|
);
|
|
assert.equal(await app.visibleTextIncludes(en.rail.settings), true);
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await waitForSelector(app, "[cmdk-input]", false);
|
|
|
|
await resize(app, 1480, 900);
|
|
await app.execute(`document.querySelector(arguments[0]).scrollTop = 0;`, [
|
|
tableScroll,
|
|
]);
|
|
// Clear any earlier selection through the actual table checkbox.
|
|
const selected = await app.execute(
|
|
`return [...document.querySelectorAll('tr[data-state="selected"]')].map((row) => row.dataset.profileId);`,
|
|
);
|
|
if (selected.includes(first)) await toggleProfile(app, first);
|
|
else {
|
|
// The selected profile may be virtualized outside the top of the table.
|
|
await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`);
|
|
await app.clickSelector(`[aria-label="${en.common.aria.selectAll}"]`);
|
|
}
|
|
const [moveFirst, moveSecond, cancelId] = await visibleProfileIds(app);
|
|
await toggleProfile(app, moveFirst);
|
|
await toggleProfile(app, moveSecond);
|
|
try {
|
|
await beginDrag(app, moveFirst);
|
|
const target = await pointIn(
|
|
app,
|
|
`[data-profile-group-drop="${group.id}"]`,
|
|
);
|
|
await pointerActions(app, [
|
|
{
|
|
type: "pointerMove",
|
|
x: target.x,
|
|
y: target.y,
|
|
duration: 180,
|
|
origin: "viewport",
|
|
},
|
|
]);
|
|
await waitForSelector(
|
|
app,
|
|
`[data-profile-group-drop="${group.id}"][data-drop-state="target"]`,
|
|
);
|
|
await app.capture("profile-group-drag");
|
|
await pointerActions(app, [{ type: "pointerUp", button: 0 }]);
|
|
await app.waitFor(
|
|
async () => {
|
|
const assignments = await groupAssignments(app);
|
|
return (
|
|
assignments[moveFirst] === group.id &&
|
|
assignments[moveSecond] === group.id
|
|
);
|
|
},
|
|
{
|
|
description:
|
|
"both selected profiles persisted into the drop target group",
|
|
},
|
|
);
|
|
} finally {
|
|
await app.session.command("DELETE", "/actions");
|
|
}
|
|
await waitForSelector(app, slot("profile-drag-preview"), false);
|
|
|
|
const beforeCancel = await groupAssignments(app);
|
|
try {
|
|
await beginDrag(app, cancelId);
|
|
await app.pressShortcut({ key: "Escape" });
|
|
} finally {
|
|
await app.session.command("DELETE", "/actions");
|
|
}
|
|
await waitForSelector(app, slot("profile-drag-preview"), false);
|
|
assert.deepEqual(
|
|
await groupAssignments(app),
|
|
beforeCancel,
|
|
"Escape never writes a group assignment",
|
|
);
|
|
await app.execute(`document.querySelector(arguments[0]).focus();`, [
|
|
dragHandle(cancelId),
|
|
]);
|
|
await activateFocusedByKeyboard(app);
|
|
await app.waitForText(en.groupAssignment.title);
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await waitForSelector(app, '[role="dialog"]', false);
|
|
|
|
await clickVisible(app, inspectTrigger(cancelId));
|
|
await app.clickSelector(
|
|
`${slot("profile-info-section")}[data-section="overview"]`,
|
|
);
|
|
const handoff = `${slot("profile-handoff-detail")}[data-profile-id="${cancelId}"]`;
|
|
// These are frontend event-contract checks. No remote browser or real
|
|
// sync transfer is started, and no networking success is inferred.
|
|
await emit(app, "remote-handoff-changed", { [cancelId]: "running" });
|
|
await waitForSelector(app, `${handoff}[data-handoff-state="running"]`);
|
|
await emit(app, "remote-handoff-changed", {});
|
|
await waitForSelector(app, handoff, false);
|
|
await emit(app, "remote-handoff-changed", { [cancelId]: "pending_sync" });
|
|
await waitForSelector(
|
|
app,
|
|
`${handoff}[data-handoff-state="pending_sync"]`,
|
|
);
|
|
await emit(app, "profile-sync-status", {
|
|
profile_id: cancelId,
|
|
status: "error",
|
|
error: "Motion fixture sync failure",
|
|
});
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelector(arguments[0])?.textContent.includes(arguments[1]);`,
|
|
[handoff, en.profileMotion.handoffError],
|
|
),
|
|
{
|
|
description:
|
|
"failed return keeps its pending state and explains the error",
|
|
},
|
|
);
|
|
await waitForSelector(
|
|
app,
|
|
`${handoff}[data-handoff-state="pending_sync"]`,
|
|
);
|
|
await app.capture("profile-handoff-retry");
|
|
await emit(app, "profile-sync-status", {
|
|
profile_id: cancelId,
|
|
status: "synced",
|
|
});
|
|
await waitForSelector(
|
|
app,
|
|
`${handoff}[data-handoff-state="pending_sync"]`,
|
|
);
|
|
await emit(app, "remote-handoff-changed", {});
|
|
await waitForSelector(app, `${handoff}[data-handoff-state="returned"]`);
|
|
await app.capture("profile-handoff-returned");
|
|
await app.clickTextIn(handoff, en.common.buttons.close, {
|
|
roles: ["button"],
|
|
});
|
|
await waitForSelector(app, handoff, false);
|
|
},
|
|
{ seedDownloadedBrowser: true },
|
|
);
|
|
});
|
|
|
|
test("settings save a RAM-only edit, preserve location, and expose review and discard", async () => {
|
|
await withApp("motion-settings-feedback", async (app) => {
|
|
await resize(app, 1000, 720);
|
|
await app.clickSelector(`[aria-label="${en.rail.settings}"]`);
|
|
await waitForSelector(app, slot("settings-search"));
|
|
await app.fillSelector(slot("settings-search"), "decrypted");
|
|
assert.deepEqual(
|
|
await app.execute(
|
|
`return [...document.querySelectorAll('[data-settings-section]')].filter(node => !node.hidden).map(node => node.dataset.settingsSection);`,
|
|
),
|
|
["advanced"],
|
|
);
|
|
const initial = await app.invoke("get_app_settings");
|
|
const selector = "#keep-decrypted-profiles-in-ram";
|
|
await app.clickSelector(selector);
|
|
await app.clickSelector(`${slot("settings-feedback")} summary`);
|
|
assert.equal(
|
|
await app.visibleTextIncludes(en.settings.keepDecryptedProfilesInRam),
|
|
true,
|
|
);
|
|
await app.capture("review-ram-change");
|
|
await app.clickSelector(slot("settings-discard"));
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector(arguments[0]).getAttribute("data-state") === "checked";`,
|
|
[selector],
|
|
),
|
|
Boolean(initial.keep_decrypted_profiles_in_ram),
|
|
);
|
|
await app.clickSelector(selector);
|
|
await app.clickText(en.common.buttons.saveSettings, { roles: ["button"] });
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("get_app_settings"))
|
|
.keep_decrypted_profiles_in_ram !==
|
|
Boolean(initial.keep_decrypted_profiles_in_ram),
|
|
{ description: "RAM preference persisted" },
|
|
);
|
|
await waitForSelector(app, slot("settings-search"));
|
|
assert.equal(
|
|
await app.execute(`return document.querySelector(arguments[0]).value;`, [
|
|
slot("settings-search"),
|
|
]),
|
|
"decrypted",
|
|
);
|
|
await app.waitFor(() => app.visibleTextIncludes(en.common.buttons.saved), {
|
|
description: "inline save confirmation",
|
|
});
|
|
await resize(app, 780, 580);
|
|
await app.capture("settings-small-saved");
|
|
assert.equal(
|
|
await app.execute(
|
|
`const el = [...document.querySelectorAll("button")].find(button => button.textContent.trim() === arguments[0]); const r = el.getBoundingClientRect(); return r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0;`,
|
|
[en.common.buttons.saveSettings],
|
|
),
|
|
true,
|
|
);
|
|
|
|
await app.pressShortcut({ ...modifier, key: "/" });
|
|
await waitForSelector(app, slot("shortcuts-search"));
|
|
await app.fillSelector(slot("shortcuts-search"), en.shortcuts.goProfiles);
|
|
assert.equal(await app.visibleTextIncludes(en.shortcuts.goProfiles), true);
|
|
assert.equal(await app.visibleTextIncludes(en.shortcuts.goGroups), false);
|
|
await app.capture("shortcuts-search");
|
|
});
|
|
});
|
|
|
|
test("profile notes save through the inspector and real launch failures retain a receipt", async () => {
|
|
await withApp("motion-profile-feedback", async (app) => {
|
|
await resize(app, 1200, 800);
|
|
const profile = await createProfile(app, "Feedback profile");
|
|
await waitForSelector(app, inspectTrigger(profile.id));
|
|
await app.clickSelector(inspectTrigger(profile.id));
|
|
await app.clickSelector(slot("profile-edit-note"));
|
|
await app.fillSelector(
|
|
`textarea[aria-label="${en.profileInfo.fields.note}"]`,
|
|
"Remember the launch context",
|
|
);
|
|
await app.clickTextIn(slot("popover-content"), en.common.buttons.save, {
|
|
roles: ["button"],
|
|
});
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === profile.id,
|
|
)?.note === "Remember the launch context",
|
|
{ description: "note saved" },
|
|
);
|
|
// A cross-platform launch is rejected before starting any process.
|
|
const otherOs = process.platform === "darwin" ? "linux" : "macos";
|
|
await app.invokeError("launch_browser_profile", {
|
|
profile: { ...profile, host_os: otherOs },
|
|
url: null,
|
|
});
|
|
await waitForSelector(
|
|
app,
|
|
`${slot("profile-launch-activity")}[data-stage="failed"]`,
|
|
);
|
|
await app.clickSelector(`${slot("profile-launch-activity")} summary`);
|
|
assert.equal(
|
|
await app.visibleTextIncludes(en.appFeedback.launch.preparing),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await app.visibleTextIncludes(en.appFeedback.launch.failed),
|
|
true,
|
|
);
|
|
await app.capture("profile-launch-receipt");
|
|
});
|
|
});
|
|
|
|
test("schedule displays every timezone and slot, and skipped run details work by keyboard", async () => {
|
|
await withApp("motion-schedule-feedback", async (app) => {
|
|
await resize(app, 1200, 800);
|
|
const profile = await createProfile(app, "Two daily slots");
|
|
const other = await createProfile(app, "Separate timezone");
|
|
await installSynchronizerFixture(app);
|
|
const schedule = (item, timezone, slots) => ({
|
|
profile_id: item.id,
|
|
profile_name: item.name,
|
|
owner_user_id: "motion-ui-fixture",
|
|
enabled: true,
|
|
timezone,
|
|
slots,
|
|
run_at_minute: slots[0].run_at_minute,
|
|
days_mask: slots[0].days_mask,
|
|
max_minutes: 30,
|
|
platform: "linux",
|
|
preset: "light",
|
|
sites: [],
|
|
template_id: null,
|
|
next_run_at: "2026-09-09T02:00:00Z",
|
|
last_run_at: null,
|
|
});
|
|
const schedules = [
|
|
schedule(profile, "UTC", [
|
|
{ days_mask: 127, run_at_minute: 120 },
|
|
{ days_mask: 127, run_at_minute: 720 },
|
|
]),
|
|
schedule(other, "Asia/Yerevan", [{ days_mask: 127, run_at_minute: 120 }]),
|
|
];
|
|
await app.execute(
|
|
`
|
|
const schedules = arguments[0], id = arguments[1];
|
|
const previous = window.fetch;
|
|
window.__donutScheduleCalls = [];
|
|
const response = value => Promise.resolve(new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json", "Tauri-Response": "ok" } }));
|
|
window.fetch = function(input, init) {
|
|
let command = "";
|
|
try { const url = new URL(typeof input === "string" ? input : input.url); if (url.hostname === "localhost" && url.protocol === "ipc:" || url.hostname === "ipc.localhost") command = decodeURIComponent(url.pathname.split("/").pop() || ""); } catch {}
|
|
if (command === "get_cookie_bot_schedules") return response({ schedules });
|
|
if (command === "get_remote_hours_quota") return response({ granted_hours: 200, remaining_hours: 199.8, used_hours: 0.2, seats: 1, per_seat_hours: 200, members: [] });
|
|
if (command === "get_cookie_bot_runs") return response({ runs: [{ id: "skipped-fixture", profile_id: id, profile_name: "Two daily slots", status: "skipped", scheduled_for: "2026-09-08T02:00:00Z", max_minutes: 30, chunks_total: 1, chunk_index: 0, sites_total: 0, sites_visited: 0, sites_failed: 0, consent_dismissed: 0, billed_seconds: 0, outcome_code: "profile_locked" }], next_before: null });
|
|
return previous.apply(window, arguments);
|
|
};
|
|
`,
|
|
[schedules, profile.id],
|
|
);
|
|
await app.pressShortcut({ ...modifier, key: "b" });
|
|
await app.clickText(en.cookieBot.tabs.schedule, { roles: ["tab"] });
|
|
await waitForSelector(app, slot("schedule-lane"));
|
|
assert.deepEqual(
|
|
await app.execute(
|
|
`return [...document.querySelectorAll('[data-slot="schedule-lane"]')].map(node => Number(node.dataset.minute)).sort((a, b) => a - b);`,
|
|
),
|
|
[120, 120, 720],
|
|
);
|
|
assert.equal(await app.visibleTextIncludes("Asia/Yerevan"), true);
|
|
assert.equal(await app.visibleTextIncludes("199.8"), true);
|
|
await app.capture("every-schedule-slot");
|
|
await app.clickText(en.cookieBot.tabs.activity, { roles: ["tab"] });
|
|
await app.clickSelector('[role="combobox"]');
|
|
await app.clickText(en.cookieBot.runStatus.skipped, { roles: ["option"] });
|
|
await waitForSelector(app, slot("run-details-toggle"));
|
|
await app.execute(`document.querySelector(arguments[0]).focus();`, [
|
|
slot("run-details-toggle"),
|
|
]);
|
|
await activateFocusedByKeyboard(app);
|
|
assert.equal(
|
|
await app.execute(
|
|
`return document.querySelector(arguments[0]).getAttribute("aria-expanded");`,
|
|
[slot("run-details-toggle")],
|
|
),
|
|
"true",
|
|
);
|
|
await app.capture("skipped-run-details");
|
|
await restoreSynchronizerFixture(app);
|
|
});
|
|
});
|
|
|
|
test("connection diagnostics and extension impact expose real assigned profiles", async () => {
|
|
await withApp("motion-connection-impact", async (app) => {
|
|
await resize(app, 1120, 800);
|
|
const profile = await createProfile(app, "Assigned feedback profile");
|
|
const proxy = await app.invoke("create_stored_proxy", {
|
|
name: "Unavailable local route",
|
|
proxySettings: {
|
|
proxy_type: "http",
|
|
host: "127.0.0.1",
|
|
port: 9,
|
|
username: null,
|
|
password: null,
|
|
},
|
|
});
|
|
await app.invoke("update_profile_proxy", {
|
|
profileId: profile.id,
|
|
proxyId: proxy.id,
|
|
});
|
|
await app.pressShortcut({ ...modifier, key: "n" });
|
|
await waitForSelector(app, slot("profile-usage"));
|
|
await app.clickSelector(slot("profile-usage"));
|
|
await app.waitForText(profile.name);
|
|
await app.capture("proxy-assignment-list");
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await app.clickSelector(`[aria-label="${en.proxyCheck.tooltipDefault}"]`);
|
|
await waitForSelector(app, slot("proxy-route-details"));
|
|
await app.waitFor(
|
|
() => app.visibleTextIncludes(en.proxyCheck.tooltipFailedTitle),
|
|
{ description: "actual failed proxy check" },
|
|
);
|
|
assert.equal(
|
|
await app.visibleTextIncludes(en.appFeedback.notVerified),
|
|
true,
|
|
);
|
|
await app.capture("proxy-route-failure");
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await app.invoke("update_stored_proxy", {
|
|
proxyId: proxy.id,
|
|
name: proxy.name,
|
|
proxySettings: { ...proxy.proxy_settings, port: 19 },
|
|
});
|
|
assert.equal(
|
|
await app.invoke("get_cached_proxy_check", { proxyId: proxy.id }),
|
|
null,
|
|
);
|
|
|
|
const extension = await app.invoke("add_extension", {
|
|
name: "Fixture",
|
|
fileName: "fixture.zip",
|
|
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
|
});
|
|
const group = await app.invoke("create_extension_group", {
|
|
name: "Feedback extensions",
|
|
});
|
|
await app.invoke("add_extension_to_group", {
|
|
groupId: group.id,
|
|
extensionId: extension.id,
|
|
});
|
|
await app.invoke("assign_extension_group_to_profile", {
|
|
profileId: profile.id,
|
|
extensionGroupId: group.id,
|
|
});
|
|
await app.invoke("update_extension", {
|
|
extensionId: extension.id,
|
|
name: "My chosen name",
|
|
fileName: null,
|
|
fileData: null,
|
|
});
|
|
await app.pressShortcut({ ...modifier, key: "e" });
|
|
await app.clickText("My chosen name", { roles: ["button"], exact: false });
|
|
await waitForSelector(app, slot("assignment-impact"));
|
|
const disclosures = await app.execute(
|
|
`return [...document.querySelectorAll('[data-slot="assignment-impact"] summary')].map(node => node.textContent.trim());`,
|
|
);
|
|
assert.ok(disclosures.some((text) => text.includes("1")));
|
|
await app.clickSelector(
|
|
`${slot("assignment-impact")} li:last-child summary`,
|
|
);
|
|
await app.waitForText(profile.name);
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(`
|
|
const impact = document.querySelector('[data-slot="assignment-impact"]');
|
|
const marker = impact.querySelector('[data-slot="operation-marker"]')?.getBoundingClientRect();
|
|
const label = impact.querySelector('li[aria-current="step"]')?.getBoundingClientRect();
|
|
if (!marker || !label || Math.abs(marker.left + marker.width / 2 - label.left - label.width / 2) > 1) return false;
|
|
let node = impact;
|
|
while (node && node !== document.body) {
|
|
if (node.scrollLeft || node.scrollWidth > node.clientWidth + 1) return false;
|
|
node = node.parentElement;
|
|
}
|
|
return true;
|
|
`),
|
|
{ description: "extension flow aligned without horizontal overflow" },
|
|
);
|
|
await app.capture("extension-assignment-impact");
|
|
await app.clickText(en.appFeedback.useManifestName, { roles: ["button"] });
|
|
await app.clickTextIn('[role="dialog"]', en.common.buttons.save, {
|
|
roles: ["button"],
|
|
});
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_extensions"))[0].name === extension.name,
|
|
{ description: "explicit manifest-name choice persists" },
|
|
);
|
|
|
|
await app.pressShortcut({ ...modifier, key: "i" });
|
|
await waitForSelector(app, slot("integration-diagnostics"));
|
|
await app.clickTextIn(
|
|
slot("integration-diagnostics"),
|
|
en.appFeedback.testConnection,
|
|
{ roles: ["button"] },
|
|
);
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelector('[data-slot="integration-diagnostics"] [role="status"]').textContent.includes(arguments[0]);`,
|
|
[en.proxyCheck.tooltipChecked.split("{{")[0]],
|
|
),
|
|
{ description: "real local API probe receipt" },
|
|
);
|
|
await app.capture("integration-connection-receipt");
|
|
});
|
|
});
|
|
|
|
test("partial import receipts survive retry without duplicating successful profiles", async () => {
|
|
await withApp(
|
|
"motion-import-receipts",
|
|
async (app) => {
|
|
const root = path.join(app.root, "import-sources");
|
|
const sources = [
|
|
path.join(root, "Default"),
|
|
path.join(root, "Profile 1"),
|
|
];
|
|
async function writeSource(index) {
|
|
await mkdir(sources[index], { recursive: true });
|
|
await writeFile(
|
|
path.join(sources[index], "Preferences"),
|
|
JSON.stringify({ profile: { name: `Receipt source ${index + 1}` } }),
|
|
);
|
|
await writeFile(
|
|
path.join(sources[index], "Bookmarks"),
|
|
JSON.stringify({
|
|
roots: {
|
|
bookmark_bar: {
|
|
type: "folder",
|
|
children: [
|
|
{ type: "url", name: "Example", url: "https://example.com/" },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
await writeSource(0);
|
|
await writeSource(1);
|
|
// Only fingerprint generation is fixed for this offline UI suite. Scanning,
|
|
// file copying, reports, progress events and retries run through real IPC.
|
|
await app.execute(`
|
|
window.__donutImportFetch = window.fetch;
|
|
window.__donutImportBatches = [];
|
|
window.fetch = function(input, init) {
|
|
let command = "";
|
|
try { const url = new URL(typeof input === "string" ? input : input.url); if (url.hostname === "localhost" && url.protocol === "ipc:" || url.hostname === "ipc.localhost") command = decodeURIComponent(url.pathname.split("/").pop() || ""); } catch {}
|
|
if (command === "import_browser_profiles") {
|
|
const body = JSON.parse(init.body);
|
|
window.__donutImportBatches.push(body.items.map(item => item.source_path));
|
|
body.wayfernConfig = { fingerprint: "{}" };
|
|
return window.__donutImportFetch.call(this, input, { ...init, body: JSON.stringify(body) });
|
|
}
|
|
return window.__donutImportFetch.apply(this, arguments);
|
|
};
|
|
`);
|
|
try {
|
|
await app.pressShortcut({ ...modifier, key: "o" });
|
|
await app.clickText(en.importProfile.manualImport, { roles: ["tab"] });
|
|
await app.fillSelector("#manual-profile-path", root);
|
|
await app.clickText(en.importProfile.scanButton, { roles: ["button"] });
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelectorAll('[role="checkbox"][data-state="checked"]').length === 3;`,
|
|
),
|
|
{ description: "both scanned sources selected" },
|
|
);
|
|
await app.clickText(en.importProfile.nextButton, { roles: ["button"] });
|
|
await rm(sources[1], { recursive: true, force: true });
|
|
await app.clickText(
|
|
en.importProfile.importButtonCount.replace("{{count}}", "2"),
|
|
{ roles: ["button"], exact: false },
|
|
);
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelectorAll('[data-slot="import-receipt"][data-status="imported"]').length === 1 && document.querySelectorAll('[data-slot="import-receipt"][data-status="failed"]').length === 1;`,
|
|
),
|
|
{ description: "real partial-import receipts" },
|
|
);
|
|
const first = await app.invoke("list_browser_profiles");
|
|
assert.equal(first.length, 1);
|
|
await app.capture("partial-import-receipts");
|
|
await writeSource(1);
|
|
await app.clickSelector(slot("import-retry"));
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return document.querySelectorAll('[data-slot="import-receipt"][data-status="imported"]').length === 2;`,
|
|
),
|
|
{ description: "both import receipts after retry" },
|
|
);
|
|
const profiles = await app.invoke("list_browser_profiles");
|
|
assert.equal(profiles.length, 2);
|
|
assert.ok(profiles.some((profile) => profile.id === first[0].id));
|
|
await app.waitFor(
|
|
() =>
|
|
app.execute(
|
|
`return [...document.querySelectorAll('[data-sonner-toast]')].some(node => node.getAttribute('data-type') === 'success' && node.textContent.includes(arguments[0]));`,
|
|
[
|
|
en.importProfile.resultsSummary
|
|
.replace("{{imported}}", "2")
|
|
.replace("{{skipped}}", "0")
|
|
.replace("{{failed}}", "0"),
|
|
],
|
|
),
|
|
{ description: "retry toast agrees with the cumulative receipts" },
|
|
);
|
|
const batches = await app.execute(
|
|
`return window.__donutImportBatches;`,
|
|
);
|
|
assert.equal(batches[0].length, 2);
|
|
assert.deepEqual(batches[1], [sources[1]]);
|
|
await resize(app, 760, 620);
|
|
await assertContained(app, slot("import-receipts"));
|
|
await app.capture("import-retry-receipts-small");
|
|
} finally {
|
|
await app.execute(
|
|
`window.fetch = window.__donutImportFetch; delete window.__donutImportFetch; delete window.__donutImportBatches;`,
|
|
);
|
|
}
|
|
},
|
|
{ seedDownloadedBrowser: true },
|
|
);
|
|
});
|
|
|
|
test("About hides a snack drawer that responds to bites and resets on close", async () => {
|
|
await withApp("motion-donut-snack", async (app) => {
|
|
await resize(app, 760, 620);
|
|
const openAbout = async () => {
|
|
await app.clickSelector(`[aria-label="${en.rail.more.label}"]`);
|
|
await app.clickText(en.rail.more.about, {
|
|
roles: ["menuitem"],
|
|
exact: false,
|
|
});
|
|
await waitForSelector(app, slot("about-logo"));
|
|
};
|
|
await openAbout();
|
|
await app.clickSelector(slot("about-logo"));
|
|
await waitForSelector(app, slot("donut-snack"), false);
|
|
await app.execute(
|
|
`document.querySelector('[data-slot="about-logo"]').focus();`,
|
|
);
|
|
await app.pressShortcut({ key: "\uE006", shift: true });
|
|
await waitForSelector(app, slot("donut-snack"));
|
|
const biteCount = () =>
|
|
app.execute(
|
|
`return Number(document.querySelector('[data-slot="donut-snack"]').dataset.bites);`,
|
|
);
|
|
assert.equal(await biteCount(), 0);
|
|
await app.capture("secret-snack-drawer");
|
|
for (const count of [1, 2, 3]) {
|
|
await app.clickSelector(slot("donut-snack-bite"));
|
|
assert.equal(await biteCount(), count);
|
|
await assertContained(app, slot("donut-snack"));
|
|
if (count === 2) await app.capture("secret-snack-two-bites");
|
|
}
|
|
await app.waitForText(en.about.snack.finished);
|
|
await app.capture("secret-snack-crumbs");
|
|
await app.clickSelector(slot("donut-snack-bite"));
|
|
assert.equal(await biteCount(), 0);
|
|
await activateFocusedByKeyboard(app);
|
|
assert.equal(await biteCount(), 1);
|
|
await freezeAnimations(app);
|
|
try {
|
|
await app.clickSelector(slot("donut-snack-bite"));
|
|
assert.equal(await biteCount(), 2);
|
|
await assertPopupReadable(app, slot("donut-snack"));
|
|
} finally {
|
|
await resumeAnimations(app);
|
|
}
|
|
assert.deepEqual(
|
|
await app.invoke("list_browser_profiles"),
|
|
[],
|
|
"the snack never touches profiles",
|
|
);
|
|
await app.pressShortcut({ key: "Escape" });
|
|
await waitForSelector(app, slot("donut-snack"), false);
|
|
await openAbout();
|
|
await waitForSelector(app, slot("donut-snack"), false);
|
|
});
|
|
});
|
|
|
|
test("the Wayfern terms gate lifts when the backend announces acceptance", async () => {
|
|
// No Wayfern binary in this suite, so the marker is written the way the
|
|
// binary writes it and the backend's announcement is replayed. The dialog
|
|
// must close on that event alone: a REST or automation acceptance never
|
|
// presses the dialog's own button.
|
|
await withApp(
|
|
"motion-terms-gate",
|
|
async (app) => {
|
|
const termsDialogVisible = () =>
|
|
app.execute(
|
|
`return [...document.querySelectorAll('[role="dialog"]')].some(node => node.textContent.includes(arguments[0]));`,
|
|
[en.wayfernTerms.title],
|
|
);
|
|
await app.waitFor(termsDialogVisible, {
|
|
description: "the Wayfern terms dialog before acceptance",
|
|
});
|
|
await app.capture("terms-gate-closed");
|
|
await mkdir(path.dirname(app.wayfernTermsFile), { recursive: true });
|
|
await writeFile(
|
|
app.wayfernTermsFile,
|
|
`${Math.floor(Date.now() / 1000)}\n`,
|
|
);
|
|
assert.equal(await app.invoke("check_wayfern_terms_accepted"), true);
|
|
await emit(app, "wayfern-terms-accepted", null);
|
|
await app.waitFor(async () => !(await termsDialogVisible()), {
|
|
description: "the Wayfern terms dialog to close on the event",
|
|
});
|
|
},
|
|
{ wayfernTermsAccepted: false, seedDownloadedBrowser: true },
|
|
);
|
|
});
|
|
|
|
test("the cheat code pays out sprinkles and never touches a profile", async () => {
|
|
await withApp("motion-cheat-code", async (app) => {
|
|
await resize(app, 1000, 700);
|
|
const code = ["", "", "", "", "", "", "", "", "b", "a"];
|
|
const toastVisible = () =>
|
|
app.execute(
|
|
`return [...document.querySelectorAll('[data-sonner-toast]')].some(node => node.textContent.includes(arguments[0]));`,
|
|
[en.easterEgg.konami.title],
|
|
);
|
|
// Arrow keys type nothing; the letters land in a focused field as text.
|
|
const typedLetters = code.filter((key) => /^[a-z]$/.test(key)).join("");
|
|
// Typed into a field, the code is text, not a command.
|
|
await app.pressShortcut({ ...modifier, key: "/" });
|
|
await waitForSelector(app, slot("shortcuts-search"));
|
|
await app.clickSelector(slot("shortcuts-search"));
|
|
for (const key of code) await app.pressShortcut({ key });
|
|
assert.equal(await toastVisible(), false);
|
|
assert.equal(
|
|
await app.execute(`return document.querySelector(arguments[0]).value;`, [
|
|
slot("shortcuts-search"),
|
|
]),
|
|
typedLetters,
|
|
);
|
|
// Escape empties the filter first; only an empty filter lets it through.
|
|
await app.pressShortcut({ key: "Escape" });
|
|
assert.equal(
|
|
await app.execute(`return document.querySelector(arguments[0]).value;`, [
|
|
slot("shortcuts-search"),
|
|
]),
|
|
"",
|
|
);
|
|
await app.execute("document.activeElement?.blur();");
|
|
for (const key of code) await app.pressShortcut({ key });
|
|
await app.waitFor(toastVisible, { description: "the cheat-code toast" });
|
|
await app.capture("cheat-code");
|
|
assert.deepEqual(
|
|
await app.invoke("list_browser_profiles"),
|
|
[],
|
|
"the cheat code creates nothing",
|
|
);
|
|
});
|
|
});
|