1import type { Page } from "@playwright/test";
2import type { Api } from "../../../helpers/api";
3import { FormScripts, ensureTemplate } from "../../../helpers/form-script";
4import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
5
6usePersona("customer");
7
8const PIXEL = "e2e/fixtures/pixel.png";
9const DATE_FIELDS = [
10 { fieldname: "e2e_date", fieldtype: "Date" },
11 { fieldname: "e2e_datetime", fieldtype: "Datetime" },
12];
13
14async function useTemplate(api: Api, rows: Record<string, any>[], extra: Record<string, any> = {}) {
15 const before = await api.get("OD Ticket Template", "Default");
16 await api.update("OD Ticket Template", "Default", {
17 fields: rows.map((row) => ({ visible_to: "Everyone", required: 0, ...row })),
18 ...extra,
19 });
20 return () =>
21 api.update("OD Ticket Template", "Default", {
22 fields: before.fields,
23 about: before.about || "",
24 description_template: before.description_template || "",
25 });
26}
27
28async function ensureDateFields(api: Api) {
29 for (const field of DATE_FIELDS) {
30 if (await api.exists("Custom Field", { name: `OD Ticket-${field.fieldname}` })) continue;
31 await api.insert("Custom Field", {
32 dt: "OD Ticket",
33 label: field.fieldname.replace("e2e_", "E2E "),
34 insert_after: "description",
35 ...field,
36 });
37 }
38}
39
40function field(page: Page, label: string) {
41 return page.getByRole("combobox", { name: new RegExp(`^${label}( \\(required\\))?$`) });
42}
43
44async function pick(page: Page, label: string, option: string) {
45 await field(page, label).click();
46 await page.getByRole("option", { name: option, exact: true }).click();
47}
48
49async function fillBasics(page: Page, subject = `E2E walk ${uid()}`) {
50 await page.getByPlaceholder("A short description").fill(subject);
51 await page.locator(".ProseMirror").fill("Details");
52 return subject;
53}
54
55function createButton(page: Page) {
56 return page.getByRole("button", { name: "Create ticket" });
57}
58
59/** Submitting with a required field empty names it and stays on the form. */
60async function expectRequired(page: Page, label: string) {
61 await createButton(page).click();
62 await expect(page.getByRole("alert").filter({ hasText: /^Required:/ })).toContainText(label);
63 await expect(page).toHaveURL(/\/help\/my-tickets\/new/);
64}
65
66function requiredHint(page: Page) {
67 return page.getByRole("alert").filter({ hasText: /^Required:/ });
68}
69
70test.describe("template", () => {
71 test("about, description template and required subject", async ({ page, api }) => {
72 const restore = await useTemplate(api, [], {
73 about: "<p>Tell us what broke</p>",
74 description_template: "<p>Steps to reproduce:</p>",
75 });
76 try {
77 await page.goto("/help/my-tickets/new");
78 await expect(page.getByText("Tell us what broke")).toBeVisible();
79 await expect(page.locator(".ProseMirror")).toContainText("Steps to reproduce:");
80 await expectRequired(page, "Subject");
81 await page.getByPlaceholder("A short description").fill(`E2E ${uid()}`);
82 await expect(requiredHint(page)).toBeHidden();
83 } finally {
84 await restore();
85 }
86 });
87
88 test("link options load and a required field blocks submit", async ({ page, api }) => {
89 const restore = await useTemplate(api, [{ fieldname: "ticket_type", required: 1 }]);
90 try {
91 await page.goto("/help/my-tickets/new");
92 const subject = await fillBasics(page);
93 await expectRequired(page, "Ticket Type");
94 await field(page, "Ticket Type").click();
95 await expect(page.getByRole("option", { name: "Bug", exact: true })).toBeVisible();
96 await page.getByRole("option", { name: "Bug", exact: true }).click();
97 await expect(requiredHint(page)).toBeHidden();
98 await createButton(page).click();
99 await expect(page).toHaveURL(/\/help\/my-tickets\/\d+$/);
100 const [ticket] = await api.list("OD Ticket", { filters: { subject }, fields: ["ticket_type"] });
101 expect(ticket.ticket_type).toBe("Bug");
102 } finally {
103 await restore();
104 }
105 });
106
107 test("date fields use the site date format and save", async ({ page, api }) => {
108 await ensureDateFields(api);
109 const restore = await useTemplate(api, DATE_FIELDS.map(({ fieldname }) => ({ fieldname })));
110 const format = await api.callGet("frappe.client.get_single_value", {
111 doctype: "System Settings",
112 field: "date_format",
113 });
114 try {
115 await page.goto("/help/my-tickets/new");
116 const subject = await fillBasics(page);
117 const date = field(page, "E2E date");
118 await date.fill("2026-10-15");
119 await date.press("Enter");
120 // the picker shows the site's own format
121 expect(format).toBe("yyyy-mm-dd");
122 await expect(date).toHaveValue("2026-10-15");
123 await createButton(page).click();
124 await expect(page).toHaveURL(/\/help\/my-tickets\/\d+$/);
125 const [ticket] = await api.list("OD Ticket", { filters: { subject }, fields: ["e2e_date"] });
126 expect(ticket.e2e_date).toBe("2026-10-15");
127 } finally {
128 await restore();
129 }
130 });
131
132 test("depends_on, mandatory_depends_on and a hidden required field", async ({ page, api }) => {
133 await ensureTemplate(api);
134 const restore = await useTemplate(api, [
135 { fieldname: "e2e_trigger" },
136 { fieldname: "e2e_shown", required: 1 },
137 { fieldname: "e2e_needed" },
138 ]);
139 try {
140 await page.goto("/help/my-tickets/new");
141 await fillBasics(page);
142 await expect(page.getByText("E2E shown")).toHaveCount(0);
143 // hidden, so its `required` does not block submit; abort the create to stay on the form
144 await page.route("**/api/method/opendesk.opendesk.doctype.od_ticket.api.new", (route) => route.abort());
145 const create = page.waitForRequest("**/api/method/opendesk.opendesk.doctype.od_ticket.api.new");
146 await createButton(page).click();
147 await create;
148 await page.unroute("**/api/method/opendesk.opendesk.doctype.od_ticket.api.new");
149 await expect(requiredHint(page)).toBeHidden();
150
151 await pick(page, "E2E trigger", "Show");
152 await expect(page.getByRole("textbox", { name: /^E2E shown/ })).toBeVisible();
153 await expect(page.getByRole("textbox", { name: /^E2E needed \(required\)/ })).toBeVisible();
154 await expect(requiredHint(page)).toContainText("E2E needed");
155 await page.getByRole("textbox", { name: /^E2E shown/ }).fill("x");
156 await page.getByRole("textbox", { name: /^E2E needed/ }).fill("y");
157 await expect(requiredHint(page)).toBeHidden();
158 } finally {
159 await restore();
160 }
161 });
162
163 test("a form script's onChange narrows options with applyFilters", async ({ page, api }) => {
164 await ensureTemplate(api);
165 const restore = await useTemplate(api, [{ fieldname: "e2e_trigger" }, { fieldname: "e2e_choice" }]);
166 const scripts = new FormScripts(api);
167 // Not portal-flagged: the portal new page loads agent scripts (form-script.spec fixme).
168 await scripts.create(
169 `return { actions: [], onChange: { e2e_trigger: (value) =>
170 ctx.applyFilters("e2e_choice", value === "Show" ? ["Alpha", "Beta"] : null) } };`,
171 { newPage: true }
172 );
173 try {
174 await page.goto("/help/my-tickets/new");
175 await pick(page, "E2E trigger", "Show");
176 await field(page, "E2E choice").click();
177 await expect(page.getByRole("option")).toHaveText(["Alpha", "Beta"]);
178 await page.keyboard.press("Escape");
179
180 await pick(page, "E2E trigger", "Hide");
181 await expect(field(page, "E2E choice")).toBeDisabled();
182 } finally {
183 await scripts.removeAll();
184 await restore();
185 }
186 });
187
188 test("priority shows its description as a hint", async ({ page, api }) => {
189 const restore = await useTemplate(api, [{ fieldname: "priority" }]);
190 const hint = `Within four hours ${uid()}`;
191 await api.update("OD Ticket Priority", "High", { description: hint });
192 try {
193 await page.goto("/help/my-tickets/new");
194 await pick(page, "Priority", "High");
195 await expect(page.getByText(hint)).toBeVisible();
196 } finally {
197 await api.update("OD Ticket Priority", "High", { description: "" });
198 await restore();
199 }
200 });
201
202 test("a url_method row offers that API's options", async ({ page, api }) => {
203 await ensureTemplate(api);
204 const restore = await useTemplate(api, [
205 { fieldname: "e2e_needed", url_method: "opendesk.e2e_options" },
206 ]);
207 await page.route("**/api/method/opendesk.e2e_options*", (route) =>
208 route.fulfill({ json: { message: ["Red", { label: "Green", value: "green" }] } })
209 );
210 try {
211 await page.goto("/help/my-tickets/new");
212 await page.getByRole("button", { name: /^E2E needed/ }).or(field(page, "E2E needed")).first().click();
213 await expect(page.getByRole("option")).toHaveText(["Red", "Green"]);
214 } finally {
215 await restore();
216 }
217 });
218});
219
220test.describe("attachments", () => {
221 async function attach(page: Page, files: string | { name: string; mimeType: string; buffer: Buffer }) {
222 await page.locator("input[type=file].sr-only").setInputFiles(files);
223 }
224
225 test("upload, remove and submit with an attachment", async ({ page, api }) => {
226 await page.goto("/help/my-tickets/new");
227 const subject = await fillBasics(page);
228 await attach(page, PIXEL);
229 await expect(page.getByRole("link", { name: "pixel.png" }).or(page.getByText("pixel.png"))).toBeVisible();
230 await expect(createButton(page)).toBeEnabled();
231
232 await attach(page, { name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("hello") });
233 await expect(page.getByRole("link", { name: "notes.txt" })).toHaveAttribute("target", "_blank");
234 await page
235 .locator("div")
236 .filter({ has: page.getByText("notes.txt") })
237 .filter({ has: page.getByRole("button", { name: "Remove" }) })
238 .last()
239 .getByRole("button", { name: "Remove" })
240 .click();
241 await expect(page.getByText("notes.txt")).toHaveCount(0);
242
243 await createButton(page).click();
244 await expect(page).toHaveURL(/\/help\/my-tickets\/\d+$/);
245 const name = page.url().split("/").pop()!;
246 const files = await api.list("File", {
247 filters: { attached_to_doctype: "OD Ticket", attached_to_name: name },
248 fields: ["file_name", "is_private"],
249 });
250 expect(files).toEqual([{ file_name: "pixel.png", is_private: 1 }]);
251 expect((await api.get("OD Ticket", name)).subject).toBe(subject);
252 });
253
254 test("a disallowed type is refused for a customer", async ({ page }) => {
255 await page.goto("/help/my-tickets/new");
256 await attach(page, { name: "evil.html", mimeType: "text/html", buffer: Buffer.from("<b>x</b>") });
257 await expect(page.getByText('"evil.html" was skipped')).toBeVisible();
258 });
259
260 test("an agent may attach any type", async ({ pageAs }) => {
261 const page = await pageAs("agent");
262 await page.goto("/help/my-tickets/new");
263 await expect(page.locator("input[type=file].sr-only")).not.toHaveAttribute("accept", /.+/);
264 await attach(page, { name: "page.html", mimeType: "text/html", buffer: Buffer.from("<b>x</b>") });
265 await expect(page.getByText("page.html")).toBeVisible();
266 await expect(page.getByText(/was skipped/)).toHaveCount(0);
267 });
268
269 test("files can be dropped or pasted onto the page", async ({ page }) => {
270 await page.goto("/help/my-tickets/new");
271 const dropZone = page.getByRole("button", { name: "Drag or paste files here, or click to browse" });
272 const transfer = await page.evaluateHandle(() => {
273 const data = new DataTransfer();
274 data.items.add(new File(["dropped"], "dropped.txt", { type: "text/plain" }));
275 return data;
276 });
277 await dropZone.dispatchEvent("dragover", { dataTransfer: transfer });
278 await dropZone.dispatchEvent("drop", { dataTransfer: transfer });
279 await expect(page.getByText("dropped.txt")).toBeVisible();
280
281 await page.evaluate(() => {
282 const data = new DataTransfer();
283 data.items.add(new File(["pasted"], "pasted.txt", { type: "text/plain" }));
284 // a real paste targets the focused element, never the document itself
285 document.body.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true }));
286 });
287 await expect(page.getByText("pasted.txt")).toBeVisible();
288 await expect(page.getByText("Add more")).toBeVisible();
289 });
290});
291
292test.describe("submit", () => {
293 test("a failed create shows the error and stays on the form", async ({ page }) => {
294 await page.route("**/api/method/opendesk.opendesk.doctype.od_ticket.api.new", (route) =>
295 route.fulfill({ status: 417, json: { exc_type: "ValidationError", _server_messages: JSON.stringify([JSON.stringify({ message: "E2E refused" })]) } })
296 );
297 await page.goto("/help/my-tickets/new");
298 await fillBasics(page);
299 await createButton(page).click();
300 await expect(page.getByText("E2E refused").first()).toBeVisible();
301 await expect(page).toHaveURL(/\/help\/my-tickets\/new/);
302 await expect(createButton(page)).toBeEnabled();
303 });
304
305 test("odd origins fall back safely", async ({ page, api }) => {
306 const longQuery = "q".repeat(200);
307 await page.goto(`/help/my-tickets/new?from=bogus&article=no-such-article&q=${longQuery}`);
308 const subjectField = page.getByPlaceholder("A short description");
309 await subjectField.fill(`E2E origin ${uid()}`);
310 const subject = await subjectField.inputValue();
311 await page.locator(".ProseMirror").fill("Where from?");
312 await createButton(page).click();
313 await expect(page).toHaveURL(/\/help\/my-tickets\/\d+$/);
314 const [ticket] = await api.list("OD Ticket", {
315 filters: { subject },
316 fields: ["entry_point", "source_article", "source_search"],
317 });
318 expect(ticket.entry_point).toBe("Direct");
319 expect(ticket.source_article).toBeNull();
320 });
321
322 test("search text over 140 characters is cut", async ({ page, api }) => {
323 const longQuery = `${uid()} ${"q".repeat(200)}`;
324 await page.goto(`/help/my-tickets/new?from=search&q=${longQuery}`);
325 // cut to the 140 characters a subject holds
326 const subjectField = page.getByPlaceholder("A short description");
327 await expect(subjectField).toHaveValue(longQuery.slice(0, 140));
328 const subject = `E2E long search ${uid()}`;
329 await subjectField.fill(subject);
330 await page.locator(".ProseMirror").fill("Long search");
331 await createButton(page).click();
332 await expect(page).toHaveURL(/\/help\/my-tickets\/\d+$/);
333 const [ticket] = await api.list("OD Ticket", {
334 filters: { subject },
335 fields: ["entry_point", "source_search"],
336 });
337 expect(ticket.entry_point).toBe("Search");
338 expect(ticket.source_search).toBe(longQuery.slice(0, 140));
339 });
340
341 test("a long search can still be raised as it is", async ({ page }) => {
342 await page.goto(`/help/my-tickets/new?from=search&q=${"q".repeat(200)}`);
343 await page.locator(".ProseMirror").fill("Long search");
344 await createButton(page).click();
345 await expect(page).toHaveURL(/\/help\/my-tickets\/\d+$/, { timeout: 5_000 });
346 });
347});
348