1import { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4import type { Page } from "@playwright/test";
5import type { Api } from "../../../helpers/api";
6import { ensureTemplate } from "../../../helpers/form-script";
7import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
8
9usePersona("customer");
10
11const UPLOAD = "**/api/method/upload_file";
12const MB = 1024 * 1024;
13
14async function useUrlMethodField(api: Api) {
15 await ensureTemplate(api);
16 const before = await api.get("OD Ticket Template", "Default");
17 await api.update("OD Ticket Template", "Default", {
18 fields: [{ fieldname: "e2e_needed", url_method: "opendesk.e2e_options", visible_to: "Everyone", required: 1 }],
19 });
20 return () => api.update("OD Ticket Template", "Default", { fields: before.fields });
21}
22
23async function openUrlMethodField(page: Page) {
24 await page.goto("/help/my-tickets/new");
25 await page
26 .getByRole("button", { name: /^E2E needed/ })
27 .or(page.getByRole("combobox", { name: /^E2E needed/ }))
28 .first()
29 .click();
30}
31
32function attach(page: Page, files: { name: string; mimeType: string; buffer: Buffer }) {
33 return page.locator("input[type=file].sr-only").setInputFiles(files);
34}
35
36function row(page: Page, name: string) {
37 return page.locator("div.flex.items-center.gap-3").filter({ hasText: name });
38}
39
40test.describe("url_method options", () => {
41 test("a failing url_method shows an error, not an empty list", async ({ page, api }) => {
42 const restore = await useUrlMethodField(api);
43 const errors: Error[] = [];
44 page.on("pageerror", (error) => errors.push(error));
45 await page.route("**/api/method/opendesk.e2e_options*", (route) =>
46 route.fulfill({ status: 500, json: { exc_type: "Exception" } })
47 );
48 try {
49 await openUrlMethodField(page);
50 await expect(page.getByText("Could not load the options")).toBeVisible();
51 expect(errors).toEqual([]);
52 } finally {
53 await restore();
54 }
55 });
56
57 test("a slow url_method shows a loading state", async ({ page, api }) => {
58 const restore = await useUrlMethodField(api);
59 let release = () => {};
60 await page.route("**/api/method/opendesk.e2e_options*", async (route) => {
61 await new Promise<void>((resolve) => (release = resolve));
62 await route.fulfill({ json: { message: ["Red"] } });
63 });
64 try {
65 await openUrlMethodField(page);
66 await expect(page.getByText("Loading...")).toBeVisible();
67 await expect(page.getByText("No results")).toHaveCount(0);
68 release();
69 await expect(page.getByRole("option", { name: "Red" })).toBeVisible();
70 } finally {
71 await restore();
72 }
73 });
74});
75
76test.describe("upload messages", () => {
77 test("server errors show as plain text", async ({ page }) => {
78 await page.route(UPLOAD, (route) =>
79 route.fulfill({
80 status: 417,
81 json: {
82 exc_type: "ValidationError",
83 _server_messages: JSON.stringify([JSON.stringify({ message: "<strong>File Name</strong> is too long" })]),
84 },
85 })
86 );
87 await page.goto("/help/my-tickets/new");
88 await attach(page, { name: "long.txt", mimeType: "text/plain", buffer: Buffer.from("x") });
89 await expect(row(page, "long.txt").getByText("File Name is too long")).toBeVisible();
90 await expect(page.getByText("<strong>")).toHaveCount(0);
91 });
92
93 test("an empty file is refused before upload", async ({ page }) => {
94 let uploads = 0;
95 page.on("request", (request) => request.url().includes("upload_file") && uploads++);
96 await page.goto("/help/my-tickets/new");
97 await attach(page, { name: "empty.txt", mimeType: "text/plain", buffer: Buffer.alloc(0) });
98 await expect(page.getByText('"empty.txt" was skipped — the file is empty.')).toBeVisible();
99 expect(uploads).toBe(0);
100 });
101
102 test("a tiny file shows its size in bytes", async ({ page }) => {
103 await page.goto("/help/my-tickets/new");
104 await attach(page, { name: "one.txt", mimeType: "text/plain", buffer: Buffer.from("x") });
105 await expect(row(page, "one.txt").getByText("1 B", { exact: true })).toBeVisible();
106 });
107
108 test("a file name over 140 characters uploads under a shortened name", async ({ page, api }) => {
109 const stem = `long-${uid()}-`;
110 for (const length of [200, 260]) {
111 const name = `${stem}${"y".repeat(length)}.txt`;
112 await page.goto("/help/my-tickets/new");
113 await attach(page, { name, mimeType: "text/plain", buffer: Buffer.from(name) });
114 await expect(row(page, stem).getByText("Upload failed")).toHaveCount(0);
115 await expect(row(page, stem).getByRole("link")).toBeVisible();
116 const saved = await api.list("File", { filters: { file_name: ["like", `${stem}%`] }, fields: ["file_name"] });
117 expect(saved.map((file) => file.file_name)).toContainEqual(`${stem}${"y".repeat(134 - stem.length - 4)}.txt`);
118 }
119 });
120});
121
122test.describe("pasting", () => {
123 test("a non-image file pasted in the editor is attached", async ({ page }) => {
124 await page.goto("/help/my-tickets/new");
125 await page.locator(".ProseMirror").click();
126 await page.evaluate(() => {
127 const data = new DataTransfer();
128 data.items.add(new File(["%PDF-1.4"], "report.pdf", { type: "application/pdf" }));
129 document
130 .querySelector(".ProseMirror")!
131 .dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true }));
132 });
133 await expect(row(page, "report.pdf")).toBeVisible();
134 });
135});
136
137test.describe("size limit", () => {
138 test("a file over the site limit is refused before upload", async ({ page, api }) => {
139 const limit: number = await api.call("frappe.core.api.file.get_max_file_size");
140 const dir = mkdtempSync(join(tmpdir(), "e2e-"));
141 // Just under the limit: the multipart wrapping would push the request over it.
142 const big = join(dir, "big.txt");
143 writeFileSync(big, "");
144 truncateSync(big, limit - 100);
145 let uploads = 0;
146 page.on("request", (request) => request.url().includes("upload_file") && uploads++);
147 try {
148 await page.goto("/help/my-tickets/new");
149 await page.locator("input[type=file].sr-only").setInputFiles(big);
150 await expect(row(page, "big.txt").getByText(`File is larger than ${limit / MB} MB`)).toBeVisible();
151 expect(uploads).toBe(0);
152 } finally {
153 rmSync(dir, { recursive: true });
154 }
155 });
156
157 test("a 413 from the server shows the size limit", async ({ page, api }) => {
158 const limit: number = await api.call("frappe.core.api.file.get_max_file_size");
159 await page.route(UPLOAD, (route) => route.fulfill({ status: 413, body: "Request Entity Too Large" }));
160 await page.goto("/help/my-tickets/new");
161 await attach(page, { name: "huge.txt", mimeType: "text/plain", buffer: Buffer.from("x") });
162 await expect(row(page, "huge.txt").getByText(`File is larger than ${limit / MB} MB`)).toBeVisible();
163 });
164});
165
166test.describe("orphan files", () => {
167 test("removing a row mid-upload deletes the File the server saved", async ({ page, api }) => {
168 const name = `late-${uid()}.txt`;
169 await page.goto("/help/my-tickets/new");
170 await expect(page.getByText("Drag or paste files here, or click to browse")).toBeVisible();
171 // A trickling download holds the response back after the server has saved the File.
172 const cdp = await page.context().newCDPSession(page);
173 await cdp.send("Network.emulateNetworkConditions", {
174 offline: false,
175 latency: 0,
176 downloadThroughput: 20,
177 uploadThroughput: -1,
178 });
179 await attach(page, { name, mimeType: "text/plain", buffer: Buffer.from("late") });
180 await expect.poll(() => api.exists("File", { file_name: name })).toBeTruthy();
181 const saved = (await api.exists("File", { file_name: name }))!;
182 await row(page, name).getByRole("button", { name: "Cancel upload" }).click();
183 await expect(row(page, name)).toHaveCount(0);
184 await cdp.send("Network.emulateNetworkConditions", {
185 offline: false,
186 latency: 0,
187 downloadThroughput: -1,
188 uploadThroughput: -1,
189 });
190 await expect.poll(() => api.exists("File", { name: saved }), { timeout: 30_000 }).toBeUndefined();
191 });
192
193 test("leaving the form without creating deletes its uploads", async ({ page, api }) => {
194 const name = `left-${uid()}.txt`;
195 await page.goto("/help/my-tickets");
196 await page.getByRole("button", { name: "Raise a ticket" }).click();
197 await attach(page, { name, mimeType: "text/plain", buffer: Buffer.from("left") });
198 await expect.poll(() => api.exists("File", { file_name: name })).toBeTruthy();
199 await expect(row(page, name).getByRole("button", { name: "Cancel upload" })).toHaveCount(0);
200 await page.goBack();
201 await expect(page).toHaveURL(/\/help\/my-tickets$/);
202 await expect.poll(() => api.exists("File", { file_name: name })).toBeUndefined();
203 });
204});
205