1import type { Locator, Page } from "@playwright/test";
2import { runScheduledJob, type Api } from "../helpers/api";
3import { raiseTicket } from "../helpers/factories";
4import { expect, test, uid, usePersona } from "../helpers/fixtures";
5import { TicketList } from "../helpers/list";
6import { type PersonaKey, personas } from "../helpers/personas";
7import { addComment } from "../helpers/ticket";
8
9usePersona("agent2");
10
11test.describe("home", () => {
12 test.beforeEach(async ({ api }) => {
13 await dropHomeLayout(api);
14 });
15
16 test.afterEach(async ({ api }) => {
17 await dropHomeLayout(api);
18 });
19
20 test("a dragged and resized widget layout survives a reload and resets", async ({
21 page,
22 apiAs,
23 }) => {
24 const agent = await apiAs("agent2");
25 const defaults = chartBoxes((await dashboard(agent)).layout);
26 await page.goto("/opendesk/home");
27 await page.waitForLoadState("networkidle");
28 await page.getByRole("button", { name: "Edit" }).click();
29 const widget = page.locator(".vgl-item", { hasText: "My Tickets" });
30 await expect(widget).toHaveClass(/vgl-item--resizable/);
31 await drag(page, widget, widget, 0, 300);
32 await drag(page, widget, widget.locator(".vgl-item__resizer"), 200, 100);
33 await page.getByRole("button", { name: "Save" }).click();
34 await expect(page.getByText("Dashboard saved")).toBeVisible();
35 const saved = chartBoxes((await dashboard(agent)).layout);
36 expect(saved.agent_tickets).not.toEqual(defaults.agent_tickets);
37
38 const box = await widget.boundingBox();
39 await page.reload();
40 await expect(widget).toBeVisible();
41 expect(await widget.boundingBox()).toEqual(box);
42
43 await page.getByRole("button", { name: "Edit" }).click();
44 await page.getByRole("button", { name: "Reset" }).click();
45 await page.getByRole("button", { name: "Save" }).click();
46 await expect
47 .poll(async () => chartBoxes((await dashboard(agent)).layout))
48 .toEqual(defaults);
49 });
50
51 test("dismissing a widget's custom range brings its presets back", async ({ page }) => {
52 await page.goto("/opendesk/home");
53 const metrics = page.locator(".vgl-item", { hasText: "Avg. Time Metrics" });
54 await metrics.getByRole("button", { name: "6 Months" }).click();
55 await page.getByRole("menuitem", { name: "Custom Range" }).click();
56 await expect(page.getByRole("grid", { name: "Calendar dates" })).toBeVisible();
57 await page.keyboard.press("Escape");
58 // Regression: the empty range picker used to stay with no way back.
59 await expect(metrics.getByRole("button", { name: "6 Months" })).toBeVisible();
60 });
61});
62
63test("dashboard presets and custom range drive the stats period", async ({ page }) => {
64 await page.goto("/opendesk/dashboard");
65 const trigger = page.getByText("Last 30 Days");
66 await trigger.click();
67 // the site's date, as the dashboard counts it; the browser's UTC date can differ
68 const timeZone = await page.evaluate(() => (window as any).timezone.system);
69 const request = page.waitForRequest(
70 (request) =>
71 request.url().includes("get_dashboard_data") &&
72 request.postData()!.includes(daysAgo(6, timeZone))
73 );
74 await page.getByRole("menuitem", { name: "Last 7 Days" }).click();
75 await request;
76 await expect(page.getByText("Last 7 Days")).toBeVisible();
77
78 await page.getByText("Last 7 Days").click();
79 await page.getByRole("menuitem", { name: "Custom Range" }).click();
80 await expect(page.getByRole("grid", { name: "Calendar dates" })).toBeVisible();
81 await page.keyboard.press("Escape");
82 // Regression: dismissing the calendar left a dead end with no presets.
83 await expect(page.getByText("Last 30 Days")).toBeVisible();
84
85 await page.getByText("Last 30 Days").click();
86 await page.getByRole("menuitem", { name: "Custom Range" }).click();
87 const cells = page.getByRole("grid", { name: "Calendar dates" }).getByRole("gridcell");
88 await cells.nth(8).click();
89 await cells.nth(10).click();
90 await expect(page.getByText(/^\w{3} \d+ to \w{3} \d+$/)).toBeVisible();
91});
92
93test.describe("dashboard charts", () => {
94 usePersona("manager");
95
96 test("number cards show this period and every chart draws", async ({ page, ticket }) => {
97 expect(ticket.name).toBeTruthy();
98 await page.goto("/opendesk/dashboard");
99 const cards = page
100 .locator("div", { has: page.getByText("% SLA Fulfilled", { exact: true }) })
101 .filter({ has: page.getByText("Avg. Resolution", { exact: true }) })
102 .last();
103 const tickets = cards
104 .getByText("Tickets", { exact: true })
105 .locator("xpath=ancestor::div[contains(@class, 'flex-col')][1]");
106 await expect(tickets).toContainText(/Tickets\s*\d+/);
107 const chart = page.locator("[_echarts_instance_]");
108 for (const title of ["Ticket Trend", "Tickets by Team", "Tickets by Priority"]) {
109 const card = page
110 .locator("div", { has: page.getByText(title, { exact: true }) })
111 .filter({ has: chart })
112 .last();
113 await expect(card.locator("[_echarts_instance_] svg")).toBeVisible();
114 }
115 });
116});
117
118test("the command palette finds a ticket and opens it", async ({ page, api, apiAs }) => {
119 const token = `palette${uid()}`;
120 const ticket = await raiseTicket(await apiAs("customer"), `${token} subject`);
121 await indexForSearch(api, token);
122
123 await new TicketList(page).goto();
124 // The Desktop Chrome device reports a Windows user agent, so Mod is Control.
125 await page.keyboard.press("Control+k");
126 const palette = page.getByRole("dialog", { name: "Command Palette" });
127 await palette.getByRole("combobox").fill(token);
128 await palette.getByRole("option", { name: new RegExp(`${token} subject`) }).click();
129 await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}`));
130});
131
132test("the command palette does not open over the settings dialog", async ({ page }) => {
133 await new TicketList(page).goto();
134 await page.keyboard.press("Control+,");
135 const settings = page.getByRole("dialog");
136 await expect(settings).toBeVisible();
137
138 await page.keyboard.press("Control+k");
139 await expect(page.getByRole("dialog", { name: "Command Palette" })).toBeHidden();
140 await expect(settings).toBeVisible();
141});
142
143test("search finds a ticket by text that is only in a comment", async ({
144 page,
145 api,
146 ticket,
147}) => {
148 const token = `comment${uid()}`;
149 await api.insert("Comment", {
150 comment_type: "Comment",
151 reference_doctype: "OD Ticket",
152 reference_name: ticket.name,
153 content: `<p>Internal note ${token}</p>`,
154 });
155 await indexForSearch(api, token);
156
157 await page.goto("/opendesk/search");
158 const box = page.getByRole("textbox", { name: /Search tickets/ });
159 await box.fill(token);
160 await box.press("Enter");
161 await page.getByText(new RegExp(token)).first().click();
162 await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}`));
163});
164
165test("a notification opens its ticket and mark all as read clears it", async ({
166 page,
167 api,
168 apiAs,
169}) => {
170 const customer = await apiAs("customer");
171 const first = await raiseTicket(customer);
172 const second = await raiseTicket(customer);
173 for (const ticket of [first, second]) {
174 await api.call("frappe.desk.form.assign_to.add", {
175 doctype: "OD Ticket",
176 name: ticket.name,
177 assign_to: JSON.stringify([personas.agent2.email]),
178 });
179 }
180 const log = (ticket: { name: string }) =>
181 api.list("Notification Log", {
182 fields: ["name", "read"],
183 filters: { for_user: personas.agent2.email, document_name: ticket.name },
184 });
185 await expect.poll(async () => (await log(second)).length).toBe(1);
186
187 await page.goto("/opendesk/tickets");
188 const panel = page.locator(".notifications-panel");
189 await page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }).click();
190 await panel.getByText(first.subject).click();
191 await expect(page).toHaveURL(new RegExp(`/opendesk/tickets/${first.name}`));
192 await expect.poll(async () => (await log(first))[0].read).toBe(1);
193
194 await page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" }).click();
195 await panel.getByRole("button", { name: "Mark all as read" }).click();
196 await expect.poll(async () => (await log(second))[0].read).toBe(1);
197});
198
199test("notification filters narrow the feed by read state and category and reset on close", async ({
200 page,
201 api,
202 apiAs,
203}) => {
204 const { read, unread, note } = await seedFilterNotifications(api, apiAs);
205
206 await page.goto("/opendesk/tickets");
207 const bell = page.getByRole("navigation", { name: "Main" }).getByRole("button", { name: "Notifications" });
208 const panel = page.locator(".notifications-panel");
209 // the open menu makes the page aria-hidden, so reach the trigger by CSS
210 const categories = panel.locator("button", { hasText: /categories|Mentions/ });
211 await bell.click();
212 await expect(panel.getByText(read.subject)).toBeVisible();
213
214 await panel.getByRole("radio", { name: "Unread" }).click();
215 await expect(panel.getByText(read.subject)).toHaveCount(0);
216 await expect(panel.getByText(unread.subject)).toBeVisible();
217
218 // clicking the trigger again closes only the menu, not the panel
219 await categories.click();
220 await categories.click({ force: true });
221 await expect(page.getByRole("menu")).toHaveCount(0);
222 await expect(panel).toBeVisible();
223
224 await categories.click();
225 await page.getByRole("menuitem", { name: "Mentions" }).click();
226 await expect(panel.getByText(note)).toBeVisible();
227 await expect(panel.getByText(unread.subject)).toHaveCount(0);
228
229 await panel.getByRole("button", { name: "Close" }).click();
230 await bell.click();
231 await expect(categories).toHaveText("All categories");
232 await expect(panel.getByRole("radio", { name: "All" })).toHaveAttribute("aria-checked", "true");
233 await expect(panel.getByText(read.subject)).toBeVisible();
234});
235
236test("the mobile notifications page filters by read state and category", async ({
237 page,
238 api,
239 apiAs,
240}) => {
241 const { read, unread, note } = await seedFilterNotifications(api, apiAs);
242
243 await page.setViewportSize({ width: 390, height: 760 });
244 await page.goto("/opendesk/notifications");
245 await expect(page.getByText(read.subject)).toBeVisible();
246
247 await page.getByRole("radio", { name: "Unread" }).click();
248 await expect(page.getByText(read.subject)).toHaveCount(0);
249 await expect(page.getByText(unread.subject)).toBeVisible();
250
251 await page.getByRole("button", { name: "All categories" }).click();
252 await page.getByRole("menuitem", { name: "Assignments" }).click();
253 await expect(page.getByText(unread.subject)).toBeVisible();
254 await expect(page.getByText(note)).toHaveCount(0);
255});
256
257test("the shortcuts modal opens from the keyboard", async ({ page }) => {
258 await new TicketList(page).goto();
259 await page.keyboard.press("Control+/");
260 const modal = page.getByRole("dialog", { name: "Keyboard Shortcuts" });
261 await expect(modal.getByRole("heading", { name: "Ticket Management" })).toBeVisible();
262 await expect(modal.getByText("Open reply box")).toBeVisible();
263});
264
265/** Give agent2 a read and an unread assignment and an unread mention, each on
266 * its own ticket, so a filter's effect shows up as rows coming and going. */
267async function seedFilterNotifications(api: Api, apiAs: (key: PersonaKey) => Promise<Api>) {
268 const customer = await apiAs("customer");
269 const read = await raiseTicket(customer);
270 const unread = await raiseTicket(customer);
271 for (const ticket of [read, unread]) {
272 await api.call("frappe.desk.form.assign_to.add", {
273 doctype: "OD Ticket",
274 name: ticket.name,
275 assign_to: JSON.stringify([personas.agent2.email]),
276 });
277 }
278 const mentioned = await raiseTicket(customer);
279 const note = `Filter mention ${uid()}`;
280 const email = personas.agent2.email;
281 await addComment(
282 await apiAs("agent"),
283 mentioned.name,
284 `<span class="mention" data-type="mention" data-id="${email}" data-label="${email}">@${email}</span> ${note}`
285 );
286 const logs = () =>
287 api.list("Notification Log", {
288 fields: ["name", "document_name"],
289 filters: {
290 for_user: email,
291 document_name: ["in", [read.name, unread.name, mentioned.name]],
292 },
293 });
294 await expect.poll(async () => (await logs()).length).toBe(3);
295 const readLog = (await logs()).find((l) => l.document_name === read.name)!;
296 await (await apiAs("agent2")).call(
297 "frappe.desk.doctype.notification_log.notification_log.mark_as_read",
298 { docname: readLog.name }
299 );
300 return { read, unread, note };
301}
302
303async function dashboard(api: Api) {
304 return api.call("opendesk.api.agent_home.agent_home.get_dashboard");
305}
306
307function chartBoxes(layout: any[]) {
308 return Object.fromEntries(
309 layout.map(({ chart, layout: { x, y, w, h } }) => [chart, { x, y, w, h }])
310 );
311}
312
313async function dropHomeLayout(api: Api) {
314 const name = await api.exists("OD Field Layout", { user: personas.agent2.email });
315 if (name) await api.delete("OD Field Layout", name);
316}
317
318/** Drag `handle` by an offset, then wait for the grid to settle `widget`. */
319async function drag(page: Page, widget: Locator, handle: Locator, dx: number, dy: number) {
320 const box = (await handle.boundingBox())!;
321 const x = box.x + Math.min(40, box.width / 2);
322 const y = box.y + Math.min(30, box.height / 2);
323 await page.mouse.move(x, y);
324 await page.mouse.down();
325 await page.mouse.move(x + dx, y + dy, { steps: 15 });
326 await page.mouse.up();
327 let last = "";
328 await expect
329 .poll(async () => {
330 const style = (await widget.getAttribute("style")) || "";
331 const settled = style === last;
332 last = style;
333 return settled;
334 }, { intervals: [300] })
335 .toBe(true);
336}
337
338/** New docs only reach the search index through scheduled jobs; run them now. */
339async function indexForSearch(api: Api, token: string) {
340 await runScheduledJob(api, "frappe.search.sqlite_search.build_index_if_not_exists");
341 await expect
342 .poll(
343 async () => {
344 await runScheduledJob(api, "frappe.search.sqlite_search.index_docs_in_queue");
345 const found = await api
346 .call("opendesk.api.search.search", { query: token })
347 .catch(() => ({ results: [] }));
348 return found.results.length;
349 },
350 { timeout: 30_000, intervals: [1_000] }
351 )
352 .toBeGreaterThan(0);
353}
354
355// The browser runs in UTC (see playwright.config.ts), so the date is computed in UTC too.
356function daysAgo(days: number, timeZone: string) {
357 return new Intl.DateTimeFormat("en-CA", { timeZone }).format(Date.now() - days * 86_400_000);
358}
359