1import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
2import { personas } from "../../../helpers/personas";
3import {
4 addComment,
5 comments,
6 composer,
7 openTicket,
8 postComment,
9 setOpendeskSetting,
10} from "../../../helpers/ticket";
11
12// 1x1 transparent PNG
13const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
14
15usePersona("agent");
16
17test("agent raises a ticket from the new ticket form", async ({ page, api }) => {
18 const subject = `Agent raised ${uid()}`;
19 await page.goto("/opendesk/tickets/new");
20 await page.getByPlaceholder("A short description").fill(subject);
21 await composer(page).click();
22 await composer(page).pressSequentially("Printer is on fire");
23 await page.getByRole("button", { name: "Create ticket" }).click();
24
25 await expect(page).toHaveURL(/\/opendesk\/tickets\/\d+$/);
26 await expect(page.getByRole("banner").getByRole("button", { name: subject })).toBeVisible();
27 const ticket = await api.get("OD Ticket", page.url().split("/").pop()!);
28 expect(ticket.subject).toBe(subject);
29 expect(ticket.description).toContain("Printer is on fire");
30});
31
32test("email reply with cc and an attachment is sent and stored", async ({ page, api, ticket }) => {
33 const fileName = `note-${uid()}.txt`;
34 await openTicket(page, ticket.name);
35
36 await page.getByRole("button", { name: "Reply", exact: true }).click();
37 // type before picking the Cc: the picker refocuses its input 200ms after a
38 // pick, which would swallow the last keystrokes meant for the editor
39 await composer(page).click();
40 await composer(page).pressSequentially("We are on it");
41 await page.getByRole("button", { name: "Cc", exact: true }).click();
42 await page.getByRole("combobox", { expanded: true }).fill("cc-person@example.com");
43 await page.getByRole("option", { name: /cc-person@example.com/ }).click();
44 await page.locator("input[type=file]").setInputFiles({
45 name: fileName,
46 mimeType: "text/plain",
47 // unique bytes, or Frappe hands back an older file with the same hash
48 buffer: Buffer.from(`attachment ${fileName}`),
49 });
50 await expect(page.getByText(fileName)).toBeVisible();
51 await page.getByRole("button", { name: /^Send/ }).click();
52
53 await expect
54 .poll(() =>
55 api.list("Communication", {
56 fields: ["recipients", "cc", "content"],
57 filters: { reference_name: ticket.name, sent_or_received: "Sent" },
58 })
59 )
60 .toEqual([
61 expect.objectContaining({
62 recipients: personas.customer.email,
63 cc: "cc-person@example.com",
64 content: expect.stringContaining("We are on it"),
65 }),
66 ]);
67 const [file] = await api.list("File", {
68 fields: ["is_private"],
69 filters: { attached_to_name: ticket.name, file_name: fileName },
70 });
71 expect(file.is_private).toBe(1);
72
73 await page.getByRole("tab", { name: "Emails" }).click();
74 const emails = page.getByRole("tabpanel", { name: "Emails" });
75 await expect(emails.getByText("cc-person@example.com")).toBeVisible();
76 await expect(emails.getByRole("button", { name: fileName })).toBeVisible();
77});
78
79test("reply editor inserts a table from the slash menu and uploads a pasted image", async ({
80 page,
81 api,
82 ticket,
83}) => {
84 await openTicket(page, ticket.name);
85 await page.getByRole("button", { name: "Reply", exact: true }).click();
86 const editor = composer(page);
87
88 await editor.click();
89 await editor.pressSequentially("/table");
90 await page.keyboard.press("Enter");
91 await expect(page.getByText("3 × 3")).toBeVisible();
92 await page.keyboard.press("Enter");
93 await expect(editor.locator("td, th")).toHaveCount(9);
94 await editor.locator("td, th").nth(4).click();
95 await page.keyboard.type("cell value");
96
97 await editor.evaluate(
98 (element, { base64, name }) => {
99 const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
100 const data = new DataTransfer();
101 data.items.add(new File([bytes], name, { type: "image/png" }));
102 element.dispatchEvent(
103 new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true })
104 );
105 },
106 { base64: PNG, name: "pasted.png" }
107 );
108 await expect(editor.locator(`img[src^="/private/files/"]`)).toHaveCount(1);
109 await page.getByRole("button", { name: /^Send/ }).click();
110
111 await expect
112 .poll(async () => {
113 const [sent] = await api.list("Communication", {
114 fields: ["content"],
115 filters: { reference_name: ticket.name, sent_or_received: "Sent" },
116 });
117 return sent?.content || "";
118 })
119 .toMatch(/<table[\s\S]*cell value[\s\S]*<img[^>]+src="\/private\/files\/[\s\S]*<\/table>/);
120});
121
122test("comments post once, edit, delete and survive leaving the ticket", async ({ page, api, ticket }) => {
123 const rowWith = (text: string) => page.locator(".activity", { hasText: text });
124 const first = `First note ${uid()}`;
125 await openTicket(page, ticket.name);
126
127 await postComment(page, first);
128 await expect(rowWith(first)).toHaveCount(1);
129 await expect(rowWith(first).locator("div.uppercase").first()).toHaveText("A");
130
131 // leaving and coming back must not leave a pending copy behind
132 await page.getByRole("banner").getByRole("link", { name: "Tickets" }).click();
133 await expect(page).toHaveURL(/\/opendesk\/tickets$/);
134 await page.goBack();
135 const second = `Second note ${uid()}`;
136 await postComment(page, second);
137 await expect(rowWith(second)).toHaveCount(1);
138 await expect(rowWith(first)).toHaveCount(1);
139 await expect(rowWith(second).locator("div.uppercase").first()).toHaveText("A");
140 await expect.poll(async () => (await comments(api, ticket.name)).length).toBe(2);
141
142 await rowWith(first).getByRole("button").first().click();
143 await page.getByRole("menuitem", { name: "Edit" }).click();
144 const edited = `Edited note ${uid()}`;
145 await rowWith(first).locator(".ProseMirror").fill(edited);
146 await rowWith(edited).getByRole("button", { name: "Save" }).click();
147 await expect(rowWith(edited)).toHaveCount(1);
148 await expect(rowWith(edited).locator("div.uppercase").first()).toHaveText("A");
149 await expect
150 .poll(async () => (await comments(api, ticket.name)).map((c) => c.content).join())
151 .toContain(edited);
152
153 await rowWith(edited).getByRole("button").first().click();
154 await page.getByRole("menuitem", { name: "Delete" }).click();
155 await page.getByRole("menuitem", { name: "Confirm Delete" }).click();
156 await expect(page.getByText("Comment deleted")).toBeVisible();
157 await expect(rowWith(edited)).toHaveCount(0);
158 await expect.poll(async () => (await comments(api, ticket.name)).length).toBe(1);
159});
160
161test("an @mention notifies the agent and the notification opens the comment", async ({
162 page,
163 api,
164 pageAs,
165 ticket,
166}) => {
167 const note = `Need eyes ${uid()}`;
168 await openTicket(page, ticket.name);
169 await page.getByRole("button", { name: "Comment", exact: true }).click();
170 await composer(page).click();
171 await composer(page).pressSequentially("@Bel");
172 await page.getByRole("button", { name: "Bela E2E" }).click();
173 await composer(page).pressSequentially(` ${note}`);
174 await page.getByRole("button", { name: /^Comment \(/ }).click();
175
176 await expect
177 .poll(() =>
178 api.list("Notification Log", {
179 filters: { for_user: personas.agent2.email, document_name: ticket.name, type: "Mention" },
180 })
181 )
182 .toHaveLength(1);
183 const [comment] = await comments(api, ticket.name);
184
185 const agent2 = await pageAs("agent2");
186 await agent2.goto("/opendesk/home");
187 await agent2.getByRole("button", { name: "Notifications" }).click();
188 await agent2.getByText(note).click();
189
190 await expect(agent2).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}#activity$`));
191 await expect(agent2.locator(`[id="comment:${comment.name}"]`)).toBeInViewport();
192});
193
194test.describe("reactions", () => {
195 let restore: () => Promise<unknown>;
196 test.beforeAll(async ({ api }) => {
197 restore = await setOpendeskSetting(api, "enable_comment_reactions", 1);
198 });
199 test.afterAll(async () => restore?.());
200
201 test("reactions roll up into one notification for the author", async ({ api, apiAs, pageAs, ticket }) => {
202 const agentApi = await apiAs("agent");
203 const name = await addComment(agentApi, ticket.name, `React to me ${uid()}`);
204 const reactionLogs = () =>
205 api.list("Notification Log", {
206 fields: ["subject"],
207 filters: { for_user: personas.agent.email, type: "Reaction", source_name: name },
208 });
209
210 const agent2 = await pageAs("agent2");
211 await openTicket(agent2, ticket.name);
212 const row = agent2.locator(`[id="comment:${name}"]`);
213 await row.getByRole("button").last().click();
214 await agent2.getByRole("button", { name: "👍" }).click();
215 await expect(row.getByRole("button", { name: "👍 1" })).toBeVisible();
216
217 const manager = await apiAs("manager");
218 await manager.call("opendesk.api.comment.toggle_reaction", { comment: name, emoji: "❤️" });
219 await expect.poll(reactionLogs).toEqual([{ subject: "2 people reacted to your comment" }]);
220
221 await row.getByRole("button", { name: "👍 1" }).click();
222 await expect.poll(reactionLogs).toEqual([{ subject: "1 person reacted to your comment" }]);
223 });
224});
225
226test("pinned comments cycle in a bar that never shifts the feed", async ({ page, api, ticket }) => {
227 // a feed long enough to scroll; a short one is top-aligned, so the bar pushes it down
228 for (let i = 0; i < 10; i++) await addComment(api, ticket.name, `Filler ${i}`);
229 const older = `Older pin ${uid()}`;
230 const newer = `Newer pin ${uid()}`;
231 await addComment(api, ticket.name, older);
232 await addComment(api, ticket.name, newer);
233 await openTicket(page, ticket.name, "#comment");
234
235 const panel = page.locator("[role=tabpanel][data-state=active]");
236 const rowWith = (text: string) => panel.locator(".activity", { hasText: text });
237 const bar = panel.getByRole("button", { name: /Go to pinned comment/ });
238 const topOf = (text: string) => rowWith(text).evaluate((row) => row.getBoundingClientRect().top);
239 const toggle = async (text: string, action: "Pin" | "Unpin") => {
240 await rowWith(text).getByRole("button").first().click();
241 await page.getByRole("menuitem", { name: action, exact: true }).click();
242 };
243 await expect(rowWith(newer)).toBeVisible();
244 const restingTop = await topOf(newer);
245
246 await toggle(older, "Pin");
247 await expect(bar).toContainText(older);
248 await toggle(newer, "Pin");
249 await expect(bar).toContainText(newer);
250 await expect(panel.getByText("Pinned", { exact: true })).toHaveCount(2);
251 expect(await topOf(newer)).toBe(restingTop);
252
253 // newest first; each click jumps to the shown pin, then steps to the older one
254 await bar.click();
255 await expect(bar).toContainText(older);
256 await bar.click();
257 await expect(rowWith(older)).toBeInViewport();
258 await expect(bar).toContainText(newer);
259
260 await rowWith(newer).scrollIntoViewIfNeeded();
261 const pinnedTop = await topOf(newer);
262 await toggle(older, "Unpin");
263 await expect(bar).toContainText(newer);
264 await toggle(newer, "Unpin");
265 await expect(bar).toHaveCount(0);
266 expect(await topOf(newer)).toBe(pinnedTop);
267});
268
269test("a sixth pin is blocked until one of the five is unpinned", async ({ page, api, ticket }) => {
270 const notes = Array.from({ length: 6 }, (_, i) => `Note ${i + 1} ${uid()}`);
271 const names: string[] = [];
272 for (const note of notes) names.push(await addComment(api, ticket.name, note));
273 for (const name of names.slice(0, 5)) await api.update("Comment", name, { is_pinned: 1 });
274 await openTicket(page, ticket.name, "#comment");
275
276 const panel = page.locator("[role=tabpanel][data-state=active]");
277 const rowWith = (text: string) => panel.locator(".activity", { hasText: text });
278 const markers = panel.getByText("Pinned", { exact: true });
279 const pinItem = page.getByRole("menuitem", { name: "Pin", exact: true });
280 const openMenu = (text: string) => rowWith(text).getByRole("button").first().click();
281 await expect(markers).toHaveCount(5);
282 await expect(panel.getByRole("button", { name: "Go to pinned comment 5" })).toBeVisible();
283
284 await openMenu(notes[5]);
285 await expect(pinItem).toHaveAttribute("aria-disabled", "true");
286 // the item ignores the pointer; only its label takes the hover for the reason
287 await pinItem.getByText("Pin", { exact: true }).hover();
288 await expect(page.getByText("5 pinned already. Unpin one to pin this.").first()).toBeVisible();
289 // the first Escape closes the tooltip, the second the menu
290 await page.keyboard.press("Escape");
291 await page.keyboard.press("Escape");
292 await expect(page.getByRole("menu")).toHaveCount(0);
293
294 await openMenu(notes[0]);
295 await page.getByRole("menuitem", { name: "Unpin", exact: true }).click();
296 await expect(markers).toHaveCount(4);
297 await openMenu(notes[5]);
298 await expect(pinItem).not.toHaveAttribute("aria-disabled", "true");
299 await pinItem.click();
300 await expect(rowWith(notes[5]).getByText("Pinned", { exact: true })).toBeVisible();
301 await expect(markers).toHaveCount(5);
302});
303