import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
import { personas } from "../../../helpers/personas";
import {
addComment,
comments,
composer,
openTicket,
postComment,
setOpendeskSetting,
} from "../../../helpers/ticket";
// 1x1 transparent PNG
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
usePersona("agent");
test("agent raises a ticket from the new ticket form", async ({ page, api }) => {
const subject = `Agent raised ${uid()}`;
await page.goto("/opendesk/tickets/new");
await page.getByPlaceholder("A short description").fill(subject);
await composer(page).click();
await composer(page).pressSequentially("Printer is on fire");
await page.getByRole("button", { name: "Create ticket" }).click();
await expect(page).toHaveURL(/\/opendesk\/tickets\/\d+$/);
await expect(page.getByRole("banner").getByRole("button", { name: subject })).toBeVisible();
const ticket = await api.get("OD Ticket", page.url().split("/").pop()!);
expect(ticket.subject).toBe(subject);
expect(ticket.description).toContain("Printer is on fire");
});
test("email reply with cc and an attachment is sent and stored", async ({ page, api, ticket }) => {
const fileName = `note-${uid()}.txt`;
await openTicket(page, ticket.name);
await page.getByRole("button", { name: "Reply", exact: true }).click();
// type before picking the Cc: the picker refocuses its input 200ms after a
// pick, which would swallow the last keystrokes meant for the editor
await composer(page).click();
await composer(page).pressSequentially("We are on it");
await page.getByRole("button", { name: "Cc", exact: true }).click();
await page.getByRole("combobox", { expanded: true }).fill("cc-person@example.com");
await page.getByRole("option", { name: /cc-person@example.com/ }).click();
await page.locator("input[type=file]").setInputFiles({
name: fileName,
mimeType: "text/plain",
// unique bytes, or Frappe hands back an older file with the same hash
buffer: Buffer.from(`attachment ${fileName}`),
});
await expect(page.getByText(fileName)).toBeVisible();
await page.getByRole("button", { name: /^Send/ }).click();
await expect
.poll(() =>
api.list("Communication", {
fields: ["recipients", "cc", "content"],
filters: { reference_name: ticket.name, sent_or_received: "Sent" },
})
)
.toEqual([
expect.objectContaining({
recipients: personas.customer.email,
cc: "cc-person@example.com",
content: expect.stringContaining("We are on it"),
}),
]);
const [file] = await api.list("File", {
fields: ["is_private"],
filters: { attached_to_name: ticket.name, file_name: fileName },
});
expect(file.is_private).toBe(1);
await page.getByRole("tab", { name: "Emails" }).click();
const emails = page.getByRole("tabpanel", { name: "Emails" });
await expect(emails.getByText("cc-person@example.com")).toBeVisible();
await expect(emails.getByRole("button", { name: fileName })).toBeVisible();
});
test("reply editor inserts a table from the slash menu and uploads a pasted image", async ({
page,
api,
ticket,
}) => {
await openTicket(page, ticket.name);
await page.getByRole("button", { name: "Reply", exact: true }).click();
const editor = composer(page);
await editor.click();
await editor.pressSequentially("/table");
await page.keyboard.press("Enter");
await expect(page.getByText("3 × 3")).toBeVisible();
await page.keyboard.press("Enter");
await expect(editor.locator("td, th")).toHaveCount(9);
await editor.locator("td, th").nth(4).click();
await page.keyboard.type("cell value");
await editor.evaluate(
(element, { base64, name }) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const data = new DataTransfer();
data.items.add(new File([bytes], name, { type: "image/png" }));
element.dispatchEvent(
new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true })
);
},
{ base64: PNG, name: "pasted.png" }
);
await expect(editor.locator(`img[src^="/private/files/"]`)).toHaveCount(1);
await page.getByRole("button", { name: /^Send/ }).click();
await expect
.poll(async () => {
const [sent] = await api.list("Communication", {
fields: ["content"],
filters: { reference_name: ticket.name, sent_or_received: "Sent" },
});
return sent?.content || "";
})
.toMatch(/
]+src="\/private\/files\/[\s\S]*<\/table>/);
});
test("comments post once, edit, delete and survive leaving the ticket", async ({ page, api, ticket }) => {
const rowWith = (text: string) => page.locator(".activity", { hasText: text });
const first = `First note ${uid()}`;
await openTicket(page, ticket.name);
await postComment(page, first);
await expect(rowWith(first)).toHaveCount(1);
await expect(rowWith(first).locator("div.uppercase").first()).toHaveText("A");
// leaving and coming back must not leave a pending copy behind
await page.getByRole("banner").getByRole("link", { name: "Tickets" }).click();
await expect(page).toHaveURL(/\/opendesk\/tickets$/);
await page.goBack();
const second = `Second note ${uid()}`;
await postComment(page, second);
await expect(rowWith(second)).toHaveCount(1);
await expect(rowWith(first)).toHaveCount(1);
await expect(rowWith(second).locator("div.uppercase").first()).toHaveText("A");
await expect.poll(async () => (await comments(api, ticket.name)).length).toBe(2);
await rowWith(first).getByRole("button").first().click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const edited = `Edited note ${uid()}`;
await rowWith(first).locator(".ProseMirror").fill(edited);
await rowWith(edited).getByRole("button", { name: "Save" }).click();
await expect(rowWith(edited)).toHaveCount(1);
await expect(rowWith(edited).locator("div.uppercase").first()).toHaveText("A");
await expect
.poll(async () => (await comments(api, ticket.name)).map((c) => c.content).join())
.toContain(edited);
await rowWith(edited).getByRole("button").first().click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await page.getByRole("menuitem", { name: "Confirm Delete" }).click();
await expect(page.getByText("Comment deleted")).toBeVisible();
await expect(rowWith(edited)).toHaveCount(0);
await expect.poll(async () => (await comments(api, ticket.name)).length).toBe(1);
});
test("an @mention notifies the agent and the notification opens the comment", async ({
page,
api,
pageAs,
ticket,
}) => {
const note = `Need eyes ${uid()}`;
await openTicket(page, ticket.name);
await page.getByRole("button", { name: "Comment", exact: true }).click();
await composer(page).click();
await composer(page).pressSequentially("@Bel");
await page.getByRole("button", { name: "Bela E2E" }).click();
await composer(page).pressSequentially(` ${note}`);
await page.getByRole("button", { name: /^Comment \(/ }).click();
await expect
.poll(() =>
api.list("Notification Log", {
filters: { for_user: personas.agent2.email, document_name: ticket.name, type: "Mention" },
})
)
.toHaveLength(1);
const [comment] = await comments(api, ticket.name);
const agent2 = await pageAs("agent2");
await agent2.goto("/opendesk/home");
await agent2.getByRole("button", { name: "Notifications" }).click();
await agent2.getByText(note).click();
await expect(agent2).toHaveURL(new RegExp(`/opendesk/tickets/${ticket.name}#activity$`));
await expect(agent2.locator(`[id="comment:${comment.name}"]`)).toBeInViewport();
});
test.describe("reactions", () => {
let restore: () => Promise;
test.beforeAll(async ({ api }) => {
restore = await setOpendeskSetting(api, "enable_comment_reactions", 1);
});
test.afterAll(async () => restore?.());
test("reactions roll up into one notification for the author", async ({ api, apiAs, pageAs, ticket }) => {
const agentApi = await apiAs("agent");
const name = await addComment(agentApi, ticket.name, `React to me ${uid()}`);
const reactionLogs = () =>
api.list("Notification Log", {
fields: ["subject"],
filters: { for_user: personas.agent.email, type: "Reaction", source_name: name },
});
const agent2 = await pageAs("agent2");
await openTicket(agent2, ticket.name);
const row = agent2.locator(`[id="comment:${name}"]`);
await row.getByRole("button").last().click();
await agent2.getByRole("button", { name: "👍" }).click();
await expect(row.getByRole("button", { name: "👍 1" })).toBeVisible();
const manager = await apiAs("manager");
await manager.call("opendesk.api.comment.toggle_reaction", { comment: name, emoji: "❤️" });
await expect.poll(reactionLogs).toEqual([{ subject: "2 people reacted to your comment" }]);
await row.getByRole("button", { name: "👍 1" }).click();
await expect.poll(reactionLogs).toEqual([{ subject: "1 person reacted to your comment" }]);
});
});
test("pinned comments cycle in a bar that never shifts the feed", async ({ page, api, ticket }) => {
// a feed long enough to scroll; a short one is top-aligned, so the bar pushes it down
for (let i = 0; i < 10; i++) await addComment(api, ticket.name, `Filler ${i}`);
const older = `Older pin ${uid()}`;
const newer = `Newer pin ${uid()}`;
await addComment(api, ticket.name, older);
await addComment(api, ticket.name, newer);
await openTicket(page, ticket.name, "#comment");
const panel = page.locator("[role=tabpanel][data-state=active]");
const rowWith = (text: string) => panel.locator(".activity", { hasText: text });
const bar = panel.getByRole("button", { name: /Go to pinned comment/ });
const topOf = (text: string) => rowWith(text).evaluate((row) => row.getBoundingClientRect().top);
const toggle = async (text: string, action: "Pin" | "Unpin") => {
await rowWith(text).getByRole("button").first().click();
await page.getByRole("menuitem", { name: action, exact: true }).click();
};
await expect(rowWith(newer)).toBeVisible();
const restingTop = await topOf(newer);
await toggle(older, "Pin");
await expect(bar).toContainText(older);
await toggle(newer, "Pin");
await expect(bar).toContainText(newer);
await expect(panel.getByText("Pinned", { exact: true })).toHaveCount(2);
expect(await topOf(newer)).toBe(restingTop);
// newest first; each click jumps to the shown pin, then steps to the older one
await bar.click();
await expect(bar).toContainText(older);
await bar.click();
await expect(rowWith(older)).toBeInViewport();
await expect(bar).toContainText(newer);
await rowWith(newer).scrollIntoViewIfNeeded();
const pinnedTop = await topOf(newer);
await toggle(older, "Unpin");
await expect(bar).toContainText(newer);
await toggle(newer, "Unpin");
await expect(bar).toHaveCount(0);
expect(await topOf(newer)).toBe(pinnedTop);
});
test("a sixth pin is blocked until one of the five is unpinned", async ({ page, api, ticket }) => {
const notes = Array.from({ length: 6 }, (_, i) => `Note ${i + 1} ${uid()}`);
const names: string[] = [];
for (const note of notes) names.push(await addComment(api, ticket.name, note));
for (const name of names.slice(0, 5)) await api.update("Comment", name, { is_pinned: 1 });
await openTicket(page, ticket.name, "#comment");
const panel = page.locator("[role=tabpanel][data-state=active]");
const rowWith = (text: string) => panel.locator(".activity", { hasText: text });
const markers = panel.getByText("Pinned", { exact: true });
const pinItem = page.getByRole("menuitem", { name: "Pin", exact: true });
const openMenu = (text: string) => rowWith(text).getByRole("button").first().click();
await expect(markers).toHaveCount(5);
await expect(panel.getByRole("button", { name: "Go to pinned comment 5" })).toBeVisible();
await openMenu(notes[5]);
await expect(pinItem).toHaveAttribute("aria-disabled", "true");
// the item ignores the pointer; only its label takes the hover for the reason
await pinItem.getByText("Pin", { exact: true }).hover();
await expect(page.getByText("5 pinned already. Unpin one to pin this.").first()).toBeVisible();
// the first Escape closes the tooltip, the second the menu
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await openMenu(notes[0]);
await page.getByRole("menuitem", { name: "Unpin", exact: true }).click();
await expect(markers).toHaveCount(4);
await openMenu(notes[5]);
await expect(pinItem).not.toHaveAttribute("aria-disabled", "true");
await pinItem.click();
await expect(rowWith(notes[5]).getByText("Pinned", { exact: true })).toBeVisible();
await expect(markers).toHaveCount(5);
});