1import { expect, test, uid, usePersona } from "../../helpers/fixtures";
2import { personas } from "../../helpers/personas";
3import {
4 createThrowawayAgent,
5 openSettings,
6} from "../../helpers/settings";
7import type { Locator, Page } from "@playwright/test";
8import type { Api } from "../../helpers/api";
9
10usePersona("admin");
11
12test("every settings tab opens without errors", async ({ page }) => {
13 const errors: string[] = [];
14 page.on("pageerror", (error) => errors.push(error.message));
15 page.on("response", (response) => {
16 if (response.url().includes("/api/") && response.status() >= 500) {
17 errors.push(`${response.status()} ${response.url()}`);
18 }
19 });
20 await page.goto("/opendesk/tickets");
21 const dialog = await openSettings(page, "Preferences");
22 const tabs = dialog.getByRole("navigation").getByRole("button");
23 expect(await tabs.count()).toBeGreaterThan(10);
24
25 for (const tab of await tabs.all()) {
26 await tab.click();
27 await expect(dialog.getByRole("heading", { level: 1 }).nth(1)).toBeVisible();
28 }
29 expect(errors).toEqual([]);
30});
31
32test("inviting an agent shows it pending, and accepting creates the agent", async ({ page, api }) => {
33 const [cancelled, accepted] = [`e2e-cancel-${uid()}@example.com`, `e2e-invite-${uid()}@example.com`];
34 await page.goto("/opendesk/tickets");
35 const dialog = await openSettings(page, "Invite Agents");
36
37 await dialog.getByRole("textbox", { name: /Invite by email/ }).fill(`${cancelled}, ${accepted}`);
38 // The textarea's v-model is debounced by 100ms.
39 await page.waitForTimeout(300);
40 await dialog.getByRole("button", { name: "Send Invites" }).click();
41 const pending = dialog.getByRole("listitem");
42 await expect(pending.filter({ hasText: cancelled })).toBeVisible();
43 await expect(pending.filter({ hasText: accepted })).toBeVisible();
44
45 await pending.filter({ hasText: cancelled }).getByRole("button").click();
46 await expect(pending.filter({ hasText: cancelled })).toHaveCount(0);
47 expect(await invitationStatus(api, cancelled)).toBe("Cancelled");
48
49 const key = await invitationKey(api, accepted);
50 const response = await page.request.get(
51 `/api/method/frappe.core.api.user_invitation.accept_invitation?key=${key}`,
52 { maxRedirects: 0 }
53 );
54 expect(response.status()).toBeLessThan(400);
55 expect(await invitationStatus(api, accepted)).toBe("Accepted");
56 expect(await api.exists("OD Agent", { user: accepted })).toBeTruthy();
57});
58
59test("inviting an agent ticks the invite onboarding step", async ({ page, api }) => {
60 const steps = [{ name: "invite_agents", completed: false }];
61 await api.call("frappe.onboarding.update_user_onboarding_status", {
62 steps: JSON.stringify(steps),
63 appName: "opendesk",
64 });
65 await page.addInitScript(() => localStorage.clear());
66 await page.goto("/opendesk/tickets");
67 const dialog = await openSettings(page, "Invite Agents");
68 await dialog.getByRole("textbox", { name: /Invite by email/ }).fill(`e2e-step-${uid()}@example.com`);
69 await page.waitForTimeout(300);
70 await dialog.getByRole("button", { name: "Send Invites" }).click();
71 await expect
72 .poll(async () => {
73 const status = await api.call("frappe.onboarding.get_onboarding_status");
74 return status.opendesk_onboarding_status?.find((step) => step.name === "invite_agents")?.completed;
75 })
76 .toBe(true);
77});
78
79test("a disabled agent disappears from the assignee list", async ({ page, api }) => {
80 const email = `e2e-disable-${uid()}@example.com`;
81 const agent = await createThrowawayAgent(api, email);
82 const ticket = await api.insert("OD Ticket", { subject: `Disable check ${uid()}`, description: "x" });
83
84 await page.goto(`/opendesk/tickets/${ticket.name}`);
85 await expect(await assigneeOption(page, agent.agent_name)).toBeVisible();
86
87 const dialog = await openSettings(page, "Agents");
88 await dialog.getByRole("textbox", { name: "Search" }).fill(email);
89 const row = dialog.locator("div.group").filter({ hasText: email });
90 await row.getByRole("button").last().click();
91 await page.getByRole("menuitem", { name: "Disable Agent" }).click();
92 await expect.poll(async () => (await api.get("OD Agent", email)).is_active).toBe(0);
93
94 await page.goto(`/opendesk/tickets/${ticket.name}`);
95 await expect(await assigneeOption(page, agent.agent_name)).toHaveCount(0);
96});
97
98test("teams can be created, staffed, renamed, disabled and deleted", async ({ page, api }) => {
99 const name = `E2E Team ${uid()}`;
100 const renamed = `${name} Renamed`;
101 await page.goto("/opendesk/tickets");
102 const dialog = await openSettings(page, "Teams");
103
104 await dialog.getByRole("button", { name: "New" }).click();
105 await dialog.getByRole("textbox", { name: /Team Name/ }).fill(name);
106 await pickAgent(page, dialog, "Bela E2E");
107 await dialog.getByRole("button", { name: "Save", exact: true }).click();
108 await expect.poll(() => teamUsers(api, name)).toEqual([personas.agent2.email]);
109
110 await pickAgent(page, dialog, "Arjun E2E");
111 await dialog.getByRole("button", { name: "Add Member" }).click();
112 await expect
113 .poll(() => teamUsers(api, name))
114 .toEqual([personas.agent2.email, personas.agent.email].sort());
115
116 await teamMenu(page, dialog, "Rename");
117 const rename = page.getByRole("dialog", { name: "Rename team" });
118 await rename.getByRole("textbox", { name: "Title" }).fill(renamed);
119 await rename.getByRole("button", { name: "Confirm" }).click();
120 await expect.poll(() => api.exists("OD Team", { name: renamed })).toBeTruthy();
121
122 await dialog.getByText(renamed, { exact: true }).click();
123 await dialog.getByRole("switch").click();
124 await expect.poll(async () => (await api.get("OD Team", renamed)).disabled).toBe(1);
125
126 await teamMenu(page, dialog, "Delete");
127 await page.getByRole("menuitem", { name: "Confirm Delete" }).click();
128 await expect.poll(() => api.exists("OD Team", { name: renamed })).toBeFalsy();
129});
130
131async function pickAgent(page: Page, dialog: Locator, agentName: string) {
132 await dialog.getByPlaceholder("Type agent name or email").fill(agentName);
133 await page.getByRole("option", { name: agentName }).click();
134}
135
136async function teamMenu(page: Page, dialog: Locator, item: string) {
137 const header = dialog.getByText("Enabled", { exact: true }).locator("xpath=../..");
138 await header.getByRole("button").click();
139 await page.getByRole("menuitem", { name: item }).click();
140}
141
142async function teamUsers(api: Api, name: string) {
143 if (!(await api.exists("OD Team", { name }))) return [];
144 const team = await api.get("OD Team", name);
145 return team.users.map((row) => row.user).sort();
146}
147
148async function assigneeOption(page: Page, agentName: string) {
149 const field = page
150 .getByText("Assignee", { exact: true })
151 .locator("xpath=ancestor::div[.//button][1]");
152 await field.getByRole("button").click();
153 const search = page.getByRole("textbox", { name: "Search agents..." });
154 await search.fill(agentName);
155 return page.getByRole("dialog").getByRole("button", { name: agentName });
156}
157
158async function invitationStatus(api: Api, email: string) {
159 const [invitation] = await api.list("User Invitation", {
160 fields: ["status"],
161 filters: { email },
162 });
163 return invitation?.status;
164}
165
166// The raw key only exists in the invitation email, so read it from the queue.
167async function invitationKey(api: Api, email: string) {
168 const [mail] = await api.list("Email Queue", {
169 fields: ["message"],
170 filters: [["Email Queue Recipient", "recipient", "=", email]],
171 });
172 const body = mail.message.replace(/=\r?\n/g, "").replace(/=3D/g, "=");
173 return body.match(/accept_invitation\?key=(\w+)/)?.[1];
174}
175