Lumi/tests/ui/lumi-ux.spec.js
2026-07-22 10:20:32 +02:00

507 lines
27 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const AxeBuilder = require("@axe-core/playwright").default;
async function loginAsAdmin(page) {
await page.goto("/auth/localhost");
await page.getByLabel(/username/i).fill("admin");
await page.getByLabel(/password/i).fill("admin");
await Promise.all([
page.waitForURL(/\/$|\/admin/),
page.getByRole("button", { name: /login locally/i }).click()
]);
}
async function expectNoSeriousAxeViolations(page) {
const result = await new AxeBuilder({ page }).analyze();
const blocking = result.violations.filter((item) => ["serious", "critical"].includes(item.impact));
expect(blocking, blocking.map((item) => `${item.id}: ${item.help}`).join("\n")).toEqual([]);
}
async function clickSidebarLink(page, href) {
const link = page.locator(`.nav-link[href="${href}"]`);
if (!(await link.isVisible())) {
await link.locator("xpath=ancestor::details/summary").click();
}
await Promise.all([
page.waitForNavigation({ waitUntil: "load" }),
link.click()
]);
}
test("public shell renders without page errors or serious accessibility violations", async ({ page }, testInfo) => {
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await expect(page.locator("main.content")).toBeVisible();
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expectNoSeriousAxeViolations(page);
await page.screenshot({ path: testInfo.outputPath("home-baseline.png"), fullPage: true });
expect(errors).toEqual([]);
});
test("mobile navigation is a focus-contained drawer and restores its opener", async ({ page }) => {
test.skip((page.viewportSize()?.width || 999) > 900, "Mobile drawer contract applies below 900px.");
await page.goto("/");
const opener = page.locator(".mobile-topbar [data-sidebar-toggle]");
await opener.click();
await expect(opener).toHaveAttribute("aria-expanded", "true");
await expect(page.locator("#lumi-sidebar")).toHaveAttribute("aria-modal", "true");
await page.keyboard.press("Escape");
await expect(opener).toHaveAttribute("aria-expanded", "false");
await expect(opener).toBeFocused();
});
test("live events use one connection and release it across repeated navigation", async ({ page }) => {
test.skip((page.viewportSize()?.width || 0) <= 900, "Desktop navigation stress coverage exercises the same event lifecycle.");
await page.addInitScript(() => {
const NativeEventSource = window.EventSource;
window.__lumiEventSourceUrls = [];
window.EventSource = new Proxy(NativeEventSource, {
construct(Target, args) {
window.__lumiEventSourceUrls.push(new URL(args[0], window.location.href).pathname);
return Reflect.construct(Target, args);
}
});
});
await loginAsAdmin(page);
await clickSidebarLink(page, "/admin/updates");
await expect.poll(() => page.evaluate(() => window.__lumiEventSourceUrls)).toEqual(["/api/events"]);
const routes = ["/commands", "/feedback", "/admin", "/admin/navigation", "/admin/settings", "/"];
for (let cycle = 0; cycle < 3; cycle += 1) {
for (const route of routes) {
await clickSidebarLink(page, route);
await expect.poll(() => page.evaluate(() => window.__lumiEventSourceUrls)).toEqual(["/api/events"]);
}
}
});
test("updates reconcile checks and cleanup settings without document reloads", async ({ page }) => {
await loginAsAdmin(page);
let snapshotSaveAttempts = 0;
await page.route("**/admin/updates/core/check", async (route) => {
if (route.request().method() !== "POST") return route.continue();
const core = {
current_version: "0.2.24",
safe_target_version: "0.2.25",
latest_available_version: "0.2.25",
source_branch: "main",
update_available: true,
blocked: false,
version_description: "A targeted browser-test update is available."
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
ok: true,
message: "Core version 0.2.25 is available.",
status: { core },
data: { status: { core } },
patch: {},
reloadRequired: false
})
});
});
await page.route("**/admin/updates/snapshots/settings", async (route) => {
if (route.request().method() !== "POST" || snapshotSaveAttempts++ > 0) return route.continue();
await route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({
ok: false,
message: "Correct the snapshot cleanup limits and try again.",
fieldErrors: { max_age_days: "Enter a whole number from 1 to 3650." },
data: {},
patch: {},
reloadRequired: false
})
});
});
await page.goto("/admin/updates");
await expect(page.locator("[data-update-management]")).toBeVisible();
const deployment = page.locator("[data-deployment-panel]");
await expect(deployment.getByRole("heading", { name: "Running code branch" })).toBeVisible();
await expect(deployment.locator("select[name='branch']")).toContainText("Stable · main");
await expect(deployment.getByRole("button", { name: "Deploy selected branch" })).toBeVisible();
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
const core = page.locator('[data-update-key="core"]');
await core.locator("summary").first().click();
const [checkResponse] = await Promise.all([
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/updates/core/check"),
core.getByRole("button", { name: "Check for updates" }).click()
]);
expect(checkResponse.headers()["content-type"]).toContain("application/json");
await expect(core.locator("[data-update-summary]")).toContainText("0.2.25");
await expect(core.locator("[data-update-badge]")).toHaveText("Update available");
await expect(core.locator("[data-update-inline-result]")).toContainText("0.2.25");
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
const settings = page.locator("[data-update-settings-form]");
const age = settings.locator("input[name='max_age_days']");
const count = settings.locator("input[name='max_per_target']");
const originalAge = await age.inputValue();
const originalCount = await count.inputValue();
const [invalidResponse] = await Promise.all([
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/updates/snapshots/settings"),
settings.getByRole("button", { name: "Save cleanup policy" }).click()
]);
expect(invalidResponse.status()).toBe(422);
await expect(age).toHaveAttribute("aria-invalid", "true");
await expect(settings.locator("[data-update-inline-result]")).toContainText("Correct the snapshot cleanup limits");
await age.fill(originalAge);
await count.fill(originalCount);
const [saveResponse] = await Promise.all([
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/updates/snapshots/settings" && response.status() === 200),
settings.getByRole("button", { name: "Save cleanup policy" }).click()
]);
expect(saveResponse.headers()["content-type"]).toContain("application/json");
await expect(settings.locator("[data-update-inline-result]")).toContainText("Snapshot cleanup now keeps");
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
const reflow = await page.evaluate(() => ({ viewport: document.documentElement.clientWidth, documentWidth: document.documentElement.scrollWidth }));
expect(reflow.documentWidth).toBeLessThanOrEqual(reflow.viewport + 1);
await expectNoSeriousAxeViolations(page);
});
test("custom commands and policies save targeted state without document reloads", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/commands");
const triggerHeader = page.locator("th[data-sort='trigger']");
await triggerHeader.getByRole("button", { name: "Trigger" }).focus();
await page.keyboard.press("Enter");
await expect(triggerHeader).toHaveAttribute("aria-sort", "ascending");
await expectNoSeriousAxeViolations(page);
await page.goto("/admin/commands");
const createForm = page.locator("[data-command-create-form]");
const mode = createForm.locator("select[name='mode']");
await mode.selectOption("random");
await expect(createForm.locator(".js-field-random")).toBeVisible();
await expect(createForm.locator(".js-field-response").locator("xpath=ancestor::div[contains(@class,'field')]")).toBeHidden();
await mode.selectOption("plain");
const trigger = `uxcommand${Date.now()}`;
await createForm.locator("input[name='trigger']").fill(trigger);
await createForm.locator("input[name='response']").fill("A targeted command response");
const commandNavigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
const [createResponse] = await Promise.all([
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/commands" && response.request().method() === "POST"),
createForm.getByRole("button", { name: "Create command" }).click({ timeout: 5000 })
]);
expect(createResponse.headers()["content-type"]).toContain("application/json");
await expect(page.locator("[data-command-list-section]")).toContainText(trigger);
await expect(page.locator("[data-command-management-status]")).toContainText("Command created");
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(commandNavigationCount);
await expect(createForm.locator("input[name='trigger']")).toHaveValue("");
expect(await createForm.evaluate((form) => Array.from(form.elements)
.filter((field) => typeof field.checkValidity === "function" && !field.checkValidity())
.map((field) => ({ name: field.name, message: field.validationMessage })))).toEqual([]);
const [invalidResponse] = await Promise.all([
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/commands" && response.request().method() === "POST"),
createForm.evaluate((form) => form.requestSubmit(form.querySelector("button[type='submit']")))
]);
expect(invalidResponse.status()).toBe(422);
await expect(createForm.locator("input[name='trigger']")).toHaveAttribute("aria-invalid", "true");
await expect(createForm.locator("[data-lumi-form-status]")).toContainText("Trigger is required");
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(commandNavigationCount);
const commandReflow = await page.evaluate(() => ({ viewport: document.documentElement.clientWidth, documentWidth: document.documentElement.scrollWidth }));
expect(commandReflow.documentWidth).toBeLessThanOrEqual(commandReflow.viewport + 1);
await page.goto("/admin/command-policies");
const commandItem = page.locator('[data-policy-search-scope="commands"] [data-policy-item]', { hasText: `!${trigger}` });
await expect(commandItem).toHaveCount(1);
await commandItem.locator("summary").click();
await commandItem.locator("select[name$='window_mode']").selectOption("custom");
await commandItem.locator("input[name$='window_uses']").fill("2");
const policyNavigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
const [policyResponse] = await Promise.all([
page.waitForResponse((response) => new URL(response.url()).pathname === "/admin/command-policies/commands"),
page.locator(".command-policy-savebar").first().getByRole("button", { name: "Save all command settings" }).click()
]);
expect(policyResponse.headers()["content-type"]).toContain("application/json");
await expect(page.locator("[data-command-save-state]")).toContainText("saved");
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(policyNavigationCount);
const reflow = await page.evaluate(() => ({ viewport: document.documentElement.clientWidth, documentWidth: document.documentElement.scrollWidth }));
expect(reflow.documentWidth).toBeLessThanOrEqual(reflow.viewport + 1);
await expectNoSeriousAxeViolations(page);
});
test("enhanced settings save preserves the document, focus, and viewport", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/admin/settings");
const title = page.locator("input[name='site_title']");
await title.focus();
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
const scrollBefore = await page.evaluate(() => window.scrollY);
await title.fill(`Lumi UI Test ${Date.now()}`);
await page.getByRole("button", { name: "Save changes" }).click();
await expect(page.getByText(/Section saved|Settings saved/).first()).toBeVisible();
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
expect(Math.abs((await page.evaluate(() => window.scrollY)) - scrollBefore)).toBeLessThan(3);
await expect(title).toBeFocused();
await expectNoSeriousAxeViolations(page);
});
test("overlay editor keeps settings scrollable and synchronizes interactive previews", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/admin/overlays");
await page.getByRole("button", { name: "Create overlay" }).click();
const createName = page.locator("[data-overlay-create-modal] input[name='name']");
const overlayName = `UX overlay ${Date.now()}`;
await expect(createName).toBeFocused();
await createName.fill(overlayName);
await Promise.all([
page.waitForURL(/\/admin\/overlays/),
page.locator("[data-overlay-create-modal]").getByRole("button", { name: /create|add/i }).click()
]);
await page.locator("tbody tr", { hasText: overlayName }).getByRole("link", { name: "Open editor" }).click();
const viewportWidth = page.viewportSize()?.width || 0;
const preview = page.locator("[data-overlay-editor]");
const previewLauncher = page.locator("[data-preview-drawer-toggle]");
if (viewportWidth >= 1180) {
await expect(preview).toBeVisible();
} else {
await expect(preview).toBeHidden();
await page.mouse.move((page.viewportSize()?.width || 320) / 2, Math.min(420, (page.viewportSize()?.height || 720) / 2));
await page.mouse.wheel(0, 480);
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
await page.evaluate(() => window.scrollTo(0, 0));
const launcherGeometry = await previewLauncher.evaluate((button) => {
const rect = button.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
viewportHeight: window.innerHeight
};
});
expect(launcherGeometry.top).toBeGreaterThanOrEqual(0);
expect(launcherGeometry.bottom).toBeLessThanOrEqual(launcherGeometry.viewportHeight);
await previewLauncher.click();
await expect(preview).toBeVisible();
await expect(previewLauncher).toHaveAttribute("aria-expanded", "true");
await expect(preview).toHaveCSS("overflow-y", "auto");
}
const previewGeometry = await preview.evaluate((panel) => {
const panelRect = panel.getBoundingClientRect();
const frameRect = panel.querySelector("[data-preview-frame]").getBoundingClientRect();
return {
panelTop: panelRect.top,
panelBottom: panelRect.bottom,
viewportHeight: window.innerHeight,
frameRatio: frameRect.width / frameRect.height,
stageHeight: panel.querySelector("[data-preview-stage]").getBoundingClientRect().height,
frameHeight: frameRect.height,
panelHeight: panelRect.height
};
});
expect(previewGeometry.panelTop).toBeGreaterThanOrEqual(-1);
expect(previewGeometry.panelBottom).toBeLessThanOrEqual(previewGeometry.viewportHeight + 1);
expect(previewGeometry.frameRatio).toBeCloseTo(16 / 9, 2);
expect(Math.abs(previewGeometry.stageHeight - previewGeometry.frameHeight)).toBeLessThanOrEqual(2);
if (viewportWidth >= 1180) expect(previewGeometry.panelHeight).toBeLessThan(previewGeometry.viewportHeight * 0.9);
if (viewportWidth < 1180) {
await preview.getByRole("button", { name: "Close scene preview" }).click();
await expect(preview).toBeHidden();
await expect(previewLauncher).toBeFocused();
}
const settingsColumn = page.locator(".overlay-settings-column");
const finalSettingsCard = page.locator(".overlay-settings-column > .card").last();
const finalDisclosure = finalSettingsCard.locator("details").first();
if (viewportWidth < 1180) {
await finalDisclosure.locator("summary").evaluate((item) => item.scrollIntoView({ block: "center", behavior: "instant" }));
}
await finalDisclosure.locator("summary").click();
const scrollContract = await settingsColumn.evaluate((item) => ({
overflowY: getComputedStyle(item).overflowY,
maxHeight: getComputedStyle(item).maxHeight,
legacyHeight: item.style.getPropertyValue("--overlay-settings-max-height"),
clientHeight: item.clientHeight,
scrollHeight: item.scrollHeight
}));
expect(scrollContract.legacyHeight).toBe("");
if (viewportWidth >= 1180) {
expect(scrollContract.overflowY).toBe("auto");
expect(scrollContract.scrollHeight).toBeGreaterThan(scrollContract.clientHeight);
await settingsColumn.evaluate((item) => { item.scrollTop = item.scrollHeight; });
await expect.poll(() => settingsColumn.evaluate((item) => item.scrollTop)).toBeGreaterThan(0);
} else {
expect(["visible", "clip"]).toContain(scrollContract.overflowY);
}
const finalSettingsAction = finalSettingsCard.locator("button").last();
await finalSettingsAction.scrollIntoViewIfNeeded();
await expect(finalSettingsAction).toBeVisible();
const finalActionGeometry = await finalSettingsAction.evaluate((button) => {
const rect = button.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom, viewportHeight: window.innerHeight };
});
expect(finalActionGeometry.top).toBeGreaterThanOrEqual(-1);
expect(finalActionGeometry.bottom).toBeLessThanOrEqual(finalActionGeometry.viewportHeight + 1);
if (viewportWidth >= 1180) {
const stickyGeometry = await preview.evaluate((panel) => {
const rect = panel.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom, viewportHeight: window.innerHeight };
});
expect(stickyGeometry.top).toBeGreaterThanOrEqual(-1);
expect(stickyGeometry.bottom).toBeLessThanOrEqual(stickyGeometry.viewportHeight + 1);
const addSource = page.locator(".overlay-add-source").first();
await addSource.scrollIntoViewIfNeeded();
await addSource.locator("summary").click();
await addSource.locator("input[name='name']").fill("Pop-out adjustment source");
await Promise.all([
page.waitForNavigation({ waitUntil: "load" }),
addSource.getByRole("button", { name: "Add source" }).click()
]);
await expect(page.locator("[data-overlay-editor]")).toBeVisible();
const [popup] = await Promise.all([
page.waitForEvent("popup"),
preview.getByRole("button", { name: "Pop out" }).click()
]);
await popup.waitForLoadState("domcontentloaded");
await popup.setViewportSize({ width: 900, height: 500 });
const popoutFrame = popup.locator("[data-preview-window-frame]");
await expect(popoutFrame).toBeVisible();
await expect.poll(() => popoutFrame.evaluate((frame) => {
const rect = frame.getBoundingClientRect();
return rect.right <= window.innerWidth + 1 && rect.bottom <= window.innerHeight + 1;
})).toBe(true);
const popoutGeometry = await popoutFrame.evaluate((frame) => {
const rect = frame.getBoundingClientRect();
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
ratio: rect.width / rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
};
});
expect(popoutGeometry.top).toBeGreaterThanOrEqual(-1);
expect(popoutGeometry.left).toBeGreaterThanOrEqual(-1);
expect(popoutGeometry.right).toBeLessThanOrEqual(popoutGeometry.viewportWidth + 1);
expect(popoutGeometry.bottom).toBeLessThanOrEqual(popoutGeometry.viewportHeight + 1);
expect(popoutGeometry.ratio).toBeCloseTo(16 / 9, 2);
await expect(popup.locator("[data-popout-source]")).toHaveValue(/.+/);
await expect(popup.locator(".lumi-overlay-module.is-selected")).toHaveCount(1);
const mainX = page.locator("[data-module-editor][data-module-id] input[name='x']").first();
await expect(mainX).toHaveValue("0");
const transformResponse = popup.waitForResponse((response) => /\/api\/admin\/overlays\/[^/]+\/modules\/[^/]+\/transform$/.test(new URL(response.url()).pathname));
await popup.locator("[data-preview-window-canvas]").focus();
await popup.keyboard.press("ArrowRight");
expect((await transformResponse).ok()).toBe(true);
await expect(popup.locator("[data-preview-window-status]")).toContainText("Saved");
await expect(mainX).toHaveValue("0.1");
await popup.close();
}
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
await page.getByText("Overlay name and canvas size", { exact: true }).click();
const name = page.locator("form[data-lumi-enhanced-form] input[name='name']").first();
await name.fill(`${await name.inputValue()} edited`);
await name.locator("xpath=ancestor::form").getByRole("button", { name: /save overlay settings/i }).click();
await expect(name.locator("xpath=ancestor::form").locator("[data-lumi-form-status]")).toContainText("Overlay saved");
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
await expectNoSeriousAxeViolations(page);
});
test("resource storage limits save, reconcile immediately, and persist", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/admin/resources");
const settings = page.locator(".resource-settings-panel");
await settings.locator("summary").click();
const form = settings.locator("[data-resource-settings-form]");
await form.locator("[name='limit_gib']").fill("3.5");
await form.locator("[name='reserve_gib']").fill("0.3");
await form.locator("[name='max_file_mib']").fill("24");
await form.locator("[name='max_files']").fill("6");
const [response] = await Promise.all([
page.waitForResponse((candidate) => new URL(candidate.url()).pathname === "/admin/resources/settings"),
form.getByRole("button", { name: "Save storage settings" }).click()
]);
expect(response.ok()).toBe(true);
const payload = await response.json();
expect(payload.settings).toEqual({ limit_gib: 3.5, reserve_gib: 0.3, max_file_mib: 24, max_files: 6 });
await expect(form.locator("[data-resource-settings-status]")).toContainText("saved");
await expect(page.locator("[data-storage-limit]")).toContainText("3.50 GB");
await expect(page.locator("[data-storage-max-file]")).toContainText("24.0 MB");
const fileInput = page.locator("[data-resource-file-input]");
await expect(fileInput).toHaveAttribute("data-max-files", "6");
await expect(fileInput).toHaveAttribute("data-max-file-bytes", String(24 * 1024 * 1024));
await page.reload();
await page.locator(".resource-settings-panel summary").click();
await expect(page.locator("[data-resource-settings-form] [name='limit_gib']")).toHaveValue("3.5");
await expect(page.locator("[data-resource-settings-form] [name='reserve_gib']")).toHaveValue("0.3");
await expect(page.locator("[data-resource-settings-form] [name='max_file_mib']")).toHaveValue("24");
await expect(page.locator("[data-resource-settings-form] [name='max_files']")).toHaveValue("6");
await expectNoSeriousAxeViolations(page);
});
test("navigation builder supports keyboard movement and saves without navigation", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/admin/navigation");
await expect(page.locator("[data-navigation-builder]" )).toBeVisible();
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
const firstItem = page.locator("[data-nav-section-card] .nav-dd-item").first();
const navId = await firstItem.getAttribute("data-nav-id");
const movedItem = page.locator(`.nav-dd-item[data-nav-id="${navId}"]`);
const originalGroup = await movedItem.locator("xpath=parent::*").getAttribute("data-group-label");
await movedItem.getByRole("button", { name: /to the next group/i }).click();
const movedGroup = await movedItem.locator("xpath=parent::*").getAttribute("data-group-label");
expect(movedGroup).not.toBe(originalGroup);
await page.getByRole("button", { name: "Add section" }).click();
const newLabel = page.locator("[data-nav-section-card] .nav-section-label").last();
await expect(newLabel).toBeFocused();
await newLabel.fill("Automation");
await page.getByRole("button", { name: "Save navigation" }).click();
await expect(page.locator("[data-nav-builder-status]")).toContainText("Navigation saved");
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
await expect(page.locator("[data-nav-dirty-badge]")).toBeHidden();
const reflow = await page.evaluate(() => ({
viewport: document.documentElement.clientWidth,
documentWidth: document.documentElement.scrollWidth
}));
expect(reflow.documentWidth).toBeLessThanOrEqual(reflow.viewport + 1);
await expectNoSeriousAxeViolations(page);
});
test("theme draft preview survives a targeted custom-theme save", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/admin/theming");
const sourceTheme = page.locator(".theme-card").first();
await sourceTheme.locator("summary", { hasText: "More actions" }).click();
const copyName = `UX Theme ${Date.now()}`;
await sourceTheme.locator("form[action='/admin/theming/duplicate'] input[name='name']").fill(copyName);
await Promise.all([
page.waitForURL(/\/admin\/theming\?edit=.*#theme-editor/),
sourceTheme.getByRole("button", { name: "Duplicate" }).click()
]);
const form = page.locator("[data-theme-form]");
await expect(form).toBeVisible();
const accent = form.locator("input[name='light_accent']");
const nextAccent = (await accent.inputValue()).toLowerCase() === "#176b76" ? "#176b75" : "#176b76";
await accent.fill(nextAccent);
const navigationCount = await page.evaluate(() => performance.getEntriesByType("navigation").length);
const [saveResponse] = await Promise.all([
page.waitForResponse((response) => /\/admin\/theming\/custom\/[^/]+\/save$/.test(new URL(response.url()).pathname)),
form.getByRole("button", { name: "Save theme" }).click()
]);
expect(saveResponse.headers()["content-type"]).toContain("application/json");
expect((await saveResponse.json()).message).toContain(`${copyName} saved`);
await expect(form.locator("[data-lumi-form-status]")).toContainText(`${copyName} saved`);
expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toBe(navigationCount);
await expect(form.getByRole("button", { name: "Saved", exact: true })).toBeVisible();
await expectNoSeriousAxeViolations(page);
});