import type { Locator, Page } from "@playwright/test"; import { runScheduledJob, type Api } from "../helpers/api"; import { raiseTicket } from "../helpers/factories"; import { expect, test, uid, usePersona } from "../helpers/fixtures"; import { TicketList } from "../helpers/list"; import { type PersonaKey, personas } from "../helpers/personas"; import { addComment } from "../helpers/ticket"; usePersona("agent2"); test.describe("home", () => { test.beforeEach(async ({ api }) => { await dropHomeLayout(api); }); test.afterEach(async ({ api }) => { await dropHomeLayout(api); }); test("a dragged and resized widget layout survives a reload and resets", async ({ page, apiAs, }) => { const agent = await apiAs("agent2"); const defaults = chartBoxes((await dashboard(agent)).layout); await page.goto("/opendesk/home"); await page.waitForLoadState("networkidle"); await page.getByRole("button", { name: "Edit" }).click(); const widget = page.locator(".vgl-item", { hasText: "My Tickets" }); await expect(widget).toHaveClass(/vgl-item--resizable/); await drag(page, widget, widget, 0, 300); await drag(page, widget, widget.locator(".vgl-item__resizer"), 200, 100); await page.getByRole("button", { name: "Save" }).click(); await expect(page.getByText("Dashboard saved")).toBeVisible(); const saved = chartBoxes((await dashboard(agent)).layout); expect(saved.agent_tickets).not.toEqual(defaults.agent_tickets); const box = await widget.boundingBox(); await page.reload(); await expect(widget).toBeVisible(); expect(await widget.boundingBox()).toEqual(box); await page.getByRole("button", { name: "Edit" }).click(); await page.getByRole("button", { name: "Reset" }).click(); await page.getByRole("button", { name: "Save" }).click(); await expect .poll(async () => chartBoxes((await dashboard(agent)).layout)) .toEqual(defaults); }); test("dismissing a widget's custom range brings its presets back", async ({ page }) => { await page.goto("/opendesk/home"); const metrics = page.locator(".vgl-item", { hasText: "Avg. Time Metrics" }); await metrics.getByRole("button", { name: "6 Months" }).click(); await page.getByRole("menuitem", { name: "Custom Range" }).click(); await expect(page.getByRole("grid", { name: "Calendar dates" })).toBeVisible(); await page.keyboard.press("Escape"); // Regression: the empty range picker used to stay with no way back. await expect(metrics.getByRole("button", { name: "6 Months" })).toBeVisible(); }); }); test("dashboard presets and custom range drive the stats period", async ({ page }) => { await page.goto("/opendesk/dashboard"); const trigger = page.getByText("Last 30 Days"); await trigger.click(); // the site's date, as the dashboard counts it; the browser's UTC date can differ const timeZone = await page.evaluate(() => (window as any).timezone.system); const request = page.waitForRequest( (request) => request.url().includes("get_dashboard_data") && request.postData()!.includes(daysAgo(6, timeZone)) ); await page.getByRole("menuitem", { name: "Last 7 Days" }).click(); await request; await expect(page.getByText("Last 7 Days")).toBeVisible(); await page.getByText("Last 7 Days").click(); await page.getByRole("menuitem", { name: "Custom Range" }).click(); await expect(page.getByRole("grid", { name: "Calendar dates" })).toBeVisible(); await page.keyboard.press("Escape"); // Regression: dismissing the calendar left a dead end with no presets. await expect(page.getByText("Last 30 Days")).toBeVisible(); await page.getByText("Last 30 Days").click(); await page.getByRole("menuitem", { name: "Custom Range" }).click(); const cells = page.getByRole("grid", { name: "Calendar dates" }).getByRole("gridcell"); await cells.nth(8).click(); await cells.nth(10).click(); await expect(page.getByText(/^\w{3} \d+ to \w{3} \d+$/)).toBeVisible(); }); test.describe("dashboard charts", () => { usePersona("manager"); test("number cards show this period and every chart draws", async ({ page, ticket }) => { expect(ticket.name).toBeTruthy(); await page.goto("/opendesk/dashboard"); const cards = page .locator("div", { has: page.getByText("% SLA Fulfilled", { exact: true }) }) .filter({ has: page.getByText("Avg. Resolution", { exact: true }) }) .last(); const tickets = cards .getByText("Tickets", { exact: true }) .locator("xpath=ancestor::div[contains(@class, 'flex-col')][1]"); await expect(tickets).toContainText(/Tickets\s*\d+/); const chart = page.locator("[_echarts_instance_]"); for (const title of ["Ticket Trend", "Tickets by Team", "Tickets by Priority"]) { const card = page .locator("div", { has: page.getByText(title, { exact: true }) }) .filter({ has: chart }) .last(); await expect(card.locator("[_echarts_instance_] svg")).toBeVisible(); } }); }); test("the command palette finds a ticket and opens it", async ({ page, api, apiAs }) => { const token = `palette${uid()}`; const ticket = await raiseTicket(await apiAs("customer"), `${token} subject`); await indexForSearch(api, token); await new TicketList(page).goto(); // The Desktop Chrome device reports a Windows user agent, so Mod is Control. await page.keyboard.press("Control+k"); const palette = page.getByRole("dialog", { name: "Command Palette" }); await palette.getByRole("combobox").fill(token); await palette.getByRole("option", { name: new RegExp(`${token} subject`) }).click(); await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}`)); }); test("the command palette does not open over the settings dialog", async ({ page }) => { await new TicketList(page).goto(); await page.keyboard.press("Control+,"); const settings = page.getByRole("dialog"); await expect(settings).toBeVisible(); await page.keyboard.press("Control+k"); await expect(page.getByRole("dialog", { name: "Command Palette" })).toBeHidden(); await expect(settings).toBeVisible(); }); test("search finds a ticket by text that is only in a comment", async ({ page, api, ticket, }) => { const token = `comment${uid()}`; await api.insert("Comment", { comment_type: "Comment", reference_doctype: "OD Ticket", reference_name: ticket.name, content: `

Internal note ${token}

`, }); await indexForSearch(api, token); await page.goto("/opendesk/search"); const box = page.getByRole("textbox", { name: /Search tickets/ }); await box.fill(token); await box.press("Enter"); await page.getByText(new RegExp(token)).first().click(); await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}`)); }); test("a notification opens its ticket and mark all as read clears it", async ({ page, api, apiAs, }) => { const customer = await apiAs("customer"); const first = await raiseTicket(customer); const second = await raiseTicket(customer); for (const ticket of [first, second]) { await api.call("frappe.desk.form.assign_to.add", { doctype: "OD Ticket", name: ticket.name, assign_to: JSON.stringify([personas.agent2.email]), }); } const log = (ticket: { name: string }) => api.list("Notification Log", { fields: ["name", "read"], filters: { for_user: personas.agent2.email, document_name: ticket.name }, }); await expect.poll(async () => (await log(second)).length).toBe(1); await page.goto("/opendesk/tickets"); const panel = page.locator(".notifications-panel"); await page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }).click(); await panel.getByText(first.subject).click(); await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${first.name}`)); await expect.poll(async () => (await log(first))[0].read).toBe(1); await page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }).click(); await panel.getByRole("button", { name: "Mark all as read" }).click(); await expect.poll(async () => (await log(second))[0].read).toBe(1); }); test("notification filters narrow the feed by read state and category and reset on close", async ({ page, api, apiAs, }) => { const { read, unread, note } = await seedFilterNotifications(api, apiAs); await page.goto("/opendesk/tickets"); const bell = page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }); const panel = page.locator(".notifications-panel"); // the open menu makes the page aria-hidden, so reach the trigger by CSS const categories = panel.locator("button", { hasText: /categories|Mentions/ }); await bell.click(); await expect(panel.getByText(read.subject)).toBeVisible(); await panel.getByRole("radio", { name: "Unread" }).click(); await expect(panel.getByText(read.subject)).toHaveCount(0); await expect(panel.getByText(unread.subject)).toBeVisible(); // clicking the trigger again closes only the menu, not the panel await categories.click(); await categories.click({ force: true }); await expect(page.getByRole("menu")).toHaveCount(0); await expect(panel).toBeVisible(); await categories.click(); await page.getByRole("menuitem", { name: "Mentions" }).click(); await expect(panel.getByText(note)).toBeVisible(); await expect(panel.getByText(unread.subject)).toHaveCount(0); await panel.getByRole("button", { name: "Close" }).click(); await bell.click(); await expect(categories).toHaveText("All categories"); await expect(panel.getByRole("radio", { name: "All" })).toHaveAttribute("aria-checked", "true"); await expect(panel.getByText(read.subject)).toBeVisible(); }); test("the mobile notifications page filters by read state and category", async ({ page, api, apiAs, }) => { const { read, unread, note } = await seedFilterNotifications(api, apiAs); await page.setViewportSize({ width: 390, height: 760 }); await page.goto("/opendesk/notifications"); await expect(page.getByText(read.subject)).toBeVisible(); await page.getByRole("radio", { name: "Unread" }).click(); await expect(page.getByText(read.subject)).toHaveCount(0); await expect(page.getByText(unread.subject)).toBeVisible(); await page.getByRole("button", { name: "All categories" }).click(); await page.getByRole("menuitem", { name: "Assignments" }).click(); await expect(page.getByText(unread.subject)).toBeVisible(); await expect(page.getByText(note)).toHaveCount(0); }); test("the shortcuts modal opens from the keyboard", async ({ page }) => { await new TicketList(page).goto(); await page.keyboard.press("Control+/"); const modal = page.getByRole("dialog", { name: "Keyboard Shortcuts" }); await expect(modal.getByRole("heading", { name: "Ticket Management" })).toBeVisible(); await expect(modal.getByText("Open reply box")).toBeVisible(); }); /** Give agent2 a read and an unread assignment and an unread mention, each on * its own ticket, so a filter's effect shows up as rows coming and going. */ async function seedFilterNotifications(api: Api, apiAs: (key: PersonaKey) => Promise) { const customer = await apiAs("customer"); const read = await raiseTicket(customer); const unread = await raiseTicket(customer); for (const ticket of [read, unread]) { await api.call("frappe.desk.form.assign_to.add", { doctype: "OD Ticket", name: ticket.name, assign_to: JSON.stringify([personas.agent2.email]), }); } const mentioned = await raiseTicket(customer); const note = `Filter mention ${uid()}`; const email = personas.agent2.email; await addComment( await apiAs("agent"), mentioned.name, `@${email} ${note}` ); const logs = () => api.list("Notification Log", { fields: ["name", "document_name"], filters: { for_user: email, document_name: ["in", [read.name, unread.name, mentioned.name]], }, }); await expect.poll(async () => (await logs()).length).toBe(3); const readLog = (await logs()).find((l) => l.document_name === read.name)!; await (await apiAs("agent2")).call( "frappe.desk.doctype.notification_log.notification_log.mark_as_read", { docname: readLog.name } ); return { read, unread, note }; } async function dashboard(api: Api) { return api.call("opendesk.api.agent_home.agent_home.get_dashboard"); } function chartBoxes(layout: any[]) { return Object.fromEntries( layout.map(({ chart, layout: { x, y, w, h } }) => [chart, { x, y, w, h }]) ); } async function dropHomeLayout(api: Api) { const name = await api.exists("OD Field Layout", { user: personas.agent2.email }); if (name) await api.delete("OD Field Layout", name); } /** Drag `handle` by an offset, then wait for the grid to settle `widget`. */ async function drag(page: Page, widget: Locator, handle: Locator, dx: number, dy: number) { const box = (await handle.boundingBox())!; const x = box.x + Math.min(40, box.width / 2); const y = box.y + Math.min(30, box.height / 2); await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x + dx, y + dy, { steps: 15 }); await page.mouse.up(); let last = ""; await expect .poll(async () => { const style = (await widget.getAttribute("style")) || ""; const settled = style === last; last = style; return settled; }, { intervals: [300] }) .toBe(true); } /** New docs only reach the search index through scheduled jobs; run them now. */ async function indexForSearch(api: Api, token: string) { await runScheduledJob(api, "frappe.search.sqlite_search.build_index_if_not_exists"); await expect .poll( async () => { await runScheduledJob(api, "frappe.search.sqlite_search.index_docs_in_queue"); const found = await api .call("opendesk.api.search.search", { query: token }) .catch(() => ({ results: [] })); return found.results.length; }, { timeout: 30_000, intervals: [1_000] } ) .toBeGreaterThan(0); } // The browser runs in UTC (see playwright.config.ts), so the date is computed in UTC too. function daysAgo(days: number, timeZone: string) { return new Intl.DateTimeFormat("en-CA", { timeZone }).format(Date.now() - days * 86_400_000); }