From 74931ec44b1591b0b7f321c0987b96ea25df3597 Mon Sep 17 00:00:00 2001 From: Matias Simon Date: Sat, 12 Sep 2026 17:08:00 +0200 Subject: [PATCH] Cover the parts of the library nothing reached Coverage went from 72.26% of statements to 79.84%, and from 80.47% of functions to 92.85%. 61 tests, in four new files plus the emitter's. The largest hole was drag and drop. The existing suite calls dropzone.drop() directly, which skips the listeners the constructor binds, so every handler on the element -- the whole point of the library -- ran in no test at all. They are covered now through real DOM events, including the guard that leaves a drag carrying anything other than files alone, and the dropEffect that makes dragging out of Chrome's download bar work. Also covered: paste, the transport failures (ontimeout, onerror and progress forwarding, previously only the success path had tests), displayExistingFile, the fallback form, the removal confirmations, renameFilename, handleFiles and Dropzone.discover. emitter.js reaches 100%. Three of these pin behaviour that is wrong rather than right, each pointing at the roadmap entry that will change it: paste still does not emit addedfiles, displayExistingFile still does not count towards maxFiles (#2003), and constructing with forceFallback returns the fallback element rather than a Dropzone, because fallback() ends in a return and a constructor returning an object overrides this. The emitter's tests sat behind `return describe(...)`, a CoffeeScript conversion artefact that ends the enclosing callback, so anything appended after it was never registered -- which is exactly what happened when the first four tests here were added and silently did not run. The eleven other occurrences in all.js hide nothing today, but they are the same trap, and are removed too. The suite count is unchanged by that, which is the point. The end-to-end suite already drives a real drop through a real DataTransfer against the built bundle, so the gap there was failure: the test server only ever answered 200. It can now reject an upload, and a test drives a real 500 through to the message in the preview. --- packages/dropzone/e2e/upload-error.spec.js | 29 +++ packages/dropzone/test/test-server.js | 9 + .../test/test-sites/1-basic/upload_error.html | 12 + packages/dropzone/test/unit-tests/all.js | 22 +- .../dropzone/test/unit-tests/drag-and-drop.js | 172 +++++++++++++ packages/dropzone/test/unit-tests/emitter.js | 46 +++- packages/dropzone/test/unit-tests/paste.js | 97 +++++++ .../dropzone/test/unit-tests/public-api.js | 242 ++++++++++++++++++ .../dropzone/test/unit-tests/upload-errors.js | 153 +++++++++++ 9 files changed, 770 insertions(+), 12 deletions(-) create mode 100644 packages/dropzone/e2e/upload-error.spec.js create mode 100644 packages/dropzone/test/test-sites/1-basic/upload_error.html create mode 100644 packages/dropzone/test/unit-tests/drag-and-drop.js create mode 100644 packages/dropzone/test/unit-tests/paste.js create mode 100644 packages/dropzone/test/unit-tests/public-api.js create mode 100644 packages/dropzone/test/unit-tests/upload-errors.js diff --git a/packages/dropzone/e2e/upload-error.spec.js b/packages/dropzone/e2e/upload-error.spec.js new file mode 100644 index 000000000..72ee86689 --- /dev/null +++ b/packages/dropzone/e2e/upload-error.spec.js @@ -0,0 +1,29 @@ +import { test, expect } from "@playwright/test"; +import { dropFile } from "./support/drop-file.js"; + +// The unit suite drives failures through a fake XMLHttpRequest. This is the +// same path against the built bundle, a real request and a real 500, so the +// error reaches the preview the way a user would see it. +test.describe("Dropzone against a server that rejects the upload", () => { + test("marks the file as errored and shows the message", async ({ page }) => { + await page.goto("/1-basic/upload_error.html"); + + const upload = page.waitForResponse( + (response) => + response.request().method() === "POST" && new URL(response.url()).pathname === "/fail", + ); + + await dropFile(page, ".dropzone", "image.jpg", "image/jpeg"); + await upload; + + const preview = page.locator(".dz-preview"); + await expect(preview).toHaveClass(/dz-error/); + await expect(preview).not.toHaveClass(/dz-success/); + // The server answers with {"error": "..."} as application/json, and the + // default error handler unwraps that `error` key, so what the user sees is + // the server's own message rather than dictResponseError. + await expect(preview.locator("[data-dz-errormessage]")).toContainText( + "Upload rejected by the server", + ); + }); +}); diff --git a/packages/dropzone/test/test-server.js b/packages/dropzone/test/test-server.js index b67a65a2b..9542bf1f1 100644 --- a/packages/dropzone/test/test-server.js +++ b/packages/dropzone/test/test-server.js @@ -56,6 +56,15 @@ const httpServer = http.createServer((req, res) => { .on("data", () => {}) .on("end", () => { const headers = { "Content-Type": "application/json" }; + + // Lets the end-to-end tests drive a rejected upload. Everything else + // here succeeds, so the failure path had no way of being reached. + if (req.url.startsWith("/fail")) { + res.writeHead(500, headers); + res.end('{"error": "Upload rejected by the server"}'); + return; + } + if (req.url.startsWith("/amazon-multipart-upload")) { headers.ETag = `"${Math.round(Math.random() * 10000)}"`; } diff --git a/packages/dropzone/test/test-sites/1-basic/upload_error.html b/packages/dropzone/test/test-sites/1-basic/upload_error.html new file mode 100644 index 000000000..a3d140e0f --- /dev/null +++ b/packages/dropzone/test/test-sites/1-basic/upload_error.html @@ -0,0 +1,12 @@ + + +Dropzone Test — upload error + + + + +
+ + diff --git a/packages/dropzone/test/unit-tests/all.js b/packages/dropzone/test/unit-tests/all.js index c5a214f1c..78de3f045 100644 --- a/packages/dropzone/test/unit-tests/all.js +++ b/packages/dropzone/test/unit-tests/all.js @@ -75,7 +75,7 @@ describe("Dropzone", function () { return expect(dropzone.options.url).toBe("real-action"); }); - return describe("options", function () { + describe("options", function () { let element = null; let element2 = null; beforeEach(function () { @@ -125,7 +125,7 @@ describe("Dropzone", function () { return expect(dropzone.options.acceptedFiles).toBe("my/type"); }); - return describe("options.clickable", function () { + describe("options.clickable", function () { let clickableElement = null; dropzone = null; beforeEach(function () { @@ -348,7 +348,7 @@ describe("Dropzone", function () { })); }); - return describe("file specific", function () { + describe("file specific", function () { let file = null; beforeEach(function () { file = { @@ -425,7 +425,7 @@ describe("Dropzone", function () { ).toEqual("100%"); })); - return describe(".resize()", function () { + describe(".resize()", function () { describe("with default thumbnail settings", function () { it("should properly return target dimensions for 'contain'", function () { let info = dropzone.options.resize.call(dropzone, file, 120, 120, "crop"); @@ -446,7 +446,7 @@ describe("Dropzone", function () { }); }); - return describe("with null thumbnail settings", function () { + describe("with null thumbnail settings", function () { it("should properly return target dimensions for crop", function () { let testSettings = [ [null, null], @@ -870,7 +870,7 @@ describe("Dropzone", function () { }); }); - return describe("events", () => { + describe("events", () => { describe("progress updates", () => it("should properly emit a totaluploadprogress event", () => new Promise((done) => { @@ -1121,7 +1121,7 @@ describe("Dropzone", function () { }, 10); }))); - return describe("getFilesWithStatus()", () => + describe("getFilesWithStatus()", () => it("should return all files with provided status", function () { let mock1 = getMockFile(); let mock2 = getMockFile(); @@ -1290,7 +1290,7 @@ describe("Dropzone", function () { return expect(dropzone.removeFile.mock.calls.length).toEqual(2); }); - return describe("thumbnails", function () { + describe("thumbnails", function () { it("should properly queue the thumbnail creation", () => new Promise((done) => { let ct_callback; @@ -1377,7 +1377,7 @@ describe("Dropzone", function () { expect(thumbnail.draggable).toBe(false); }); - return describe("when file is SVG", () => + describe("when file is SVG", () => it("should use the SVG image itself", () => new Promise((done) => { let createBlob = function (data, type) { @@ -2224,7 +2224,7 @@ describe("Dropzone", function () { })); }); - return describe("should properly set status of file", () => + describe("should properly set status of file", () => it("should correctly set `withCredentials` on the xhr object", () => new Promise((done) => { dropzone.addFile(mockFile); @@ -2328,7 +2328,7 @@ describe("Dropzone", function () { })); }); - return describe("complete file", () => + describe("complete file", () => it("should properly emit the queuecomplete event when the complete queue is finished", () => new Promise((done) => { let mock1 = getMockFile("text/html", "mock1"); diff --git a/packages/dropzone/test/unit-tests/drag-and-drop.js b/packages/dropzone/test/unit-tests/drag-and-drop.js new file mode 100644 index 000000000..db75ce2bf --- /dev/null +++ b/packages/dropzone/test/unit-tests/drag-and-drop.js @@ -0,0 +1,172 @@ +import { vi } from "vitest"; +import { Dropzone } from "../../src/dropzone.js"; + +// The listeners the constructor binds to the element. The existing suite calls +// dropzone.drop() directly, which skips this wiring entirely -- so every one of +// these handlers, the whole point of the library, was previously unreached. +describe("drag and drop", function () { + let element = null; + let dropzone = null; + + beforeEach(function () { + element = document.createElement("div"); + // The class matters: the default message, and so the .dz-message element + // the click handler looks for, is only injected when it is present. + element.className = "dropzone"; + document.body.appendChild(element); + dropzone = new Dropzone(element, { url: "/upload", autoProcessQueue: false }); + }); + + afterEach(function () { + dropzone.destroy(); + element.remove(); + }); + + // A drag event carrying whatever dataTransfer the test needs. `types` is what + // the library inspects to decide whether a drag is worth intercepting at all. + let dragEvent = function (type, { types = ["Files"], effectAllowed } = {}) { + let event = new Event(type, { bubbles: true, cancelable: true }); + event.dataTransfer = { types, effectAllowed, dropEffect: null, files: [], items: [] }; + return event; + }; + + describe("events", function () { + for (let type of ["dragstart", "dragenter", "dragover", "dragleave", "dragend"]) { + it(`should emit ${type}`, function () { + let received = null; + dropzone.on(type, (e) => (received = e)); + + let event = dragEvent(type); + element.dispatchEvent(event); + + expect(received).toBe(event); + }); + } + + it("should hand a drop to drop()", function () { + let drop = vi.spyOn(dropzone, "drop").mockImplementation(() => {}); + + let event = dragEvent("drop"); + element.dispatchEvent(event); + + expect(drop).toHaveBeenCalledTimes(1); + expect(drop.mock.calls[0][0]).toBe(event); + }); + }); + + // A drag carrying anything other than files belongs to whatever else is on + // the page, so the library deliberately keeps its hands off it. + describe("propagation", function () { + for (let type of ["dragenter", "dragover", "drop"]) { + it(`should swallow ${type} when the drag carries files`, function () { + vi.spyOn(dropzone, "drop").mockImplementation(() => {}); + let event = dragEvent(type); + let stop = vi.spyOn(event, "stopPropagation"); + let prevent = vi.spyOn(event, "preventDefault"); + + element.dispatchEvent(event); + + expect(stop).toHaveBeenCalled(); + expect(prevent).toHaveBeenCalled(); + }); + + it(`should leave ${type} alone when the drag carries no files`, function () { + vi.spyOn(dropzone, "drop").mockImplementation(() => {}); + let event = dragEvent(type, { types: ["text/plain"] }); + let stop = vi.spyOn(event, "stopPropagation"); + let prevent = vi.spyOn(event, "preventDefault"); + + element.dispatchEvent(event); + + expect(stop).not.toHaveBeenCalled(); + expect(prevent).not.toHaveBeenCalled(); + }); + } + + it("should not be confused by a dataTransfer with no types at all", function () { + let event = new Event("dragenter", { bubbles: true, cancelable: true }); + event.dataTransfer = { files: [], items: [] }; + let stop = vi.spyOn(event, "stopPropagation"); + + expect(() => element.dispatchEvent(event)).not.toThrow(); + expect(stop).not.toHaveBeenCalled(); + }); + }); + + // Without this, dragging a file out of Chrome's download bar drops nothing: + // the browser needs to be told the drag is a copy. + describe("dropEffect", function () { + it("should be copy by default", function () { + let event = dragEvent("dragover"); + element.dispatchEvent(event); + expect(event.dataTransfer.dropEffect).toBe("copy"); + }); + + for (let effectAllowed of ["move", "linkMove"]) { + it(`should be move when effectAllowed is ${effectAllowed}`, function () { + let event = dragEvent("dragover", { effectAllowed }); + element.dispatchEvent(event); + expect(event.dataTransfer.dropEffect).toBe("move"); + }); + } + + it("should survive a dataTransfer that throws when read", function () { + // Internet Explorer 11 threw SCRIPT65535 here, which is why the read is + // wrapped. The guard is still load-bearing for any exotic dataTransfer. + let event = new Event("dragover", { bubbles: true, cancelable: true }); + event.dataTransfer = { + types: ["Files"], + get effectAllowed() { + throw new Error("nope"); + }, + set dropEffect(value) { + this._dropEffect = value; + }, + get dropEffect() { + return this._dropEffect; + }, + }; + + expect(() => element.dispatchEvent(event)).not.toThrow(); + expect(event.dataTransfer.dropEffect).toBe("copy"); + }); + }); + + describe("clicking", function () { + it("should forward a click on the element to the hidden input", function () { + let click = vi.spyOn(dropzone.hiddenFileInput, "click").mockImplementation(() => {}); + + element.dispatchEvent(new Event("click", { bubbles: true })); + + expect(click).toHaveBeenCalledTimes(1); + }); + + it("should forward a click on the message element", function () { + let message = element.querySelector(".dz-message"); + let click = vi.spyOn(dropzone.hiddenFileInput, "click").mockImplementation(() => {}); + + message.dispatchEvent(new Event("click", { bubbles: true })); + + expect(click).toHaveBeenCalledTimes(1); + }); + + it("should not forward a click on some other child", function () { + let other = document.createElement("span"); + element.appendChild(other); + let click = vi.spyOn(dropzone.hiddenFileInput, "click").mockImplementation(() => {}); + + other.dispatchEvent(new Event("click", { bubbles: true })); + + expect(click).not.toHaveBeenCalled(); + }); + + it("should not forward anything once disabled", function () { + let click = vi.spyOn(dropzone.hiddenFileInput, "click").mockImplementation(() => {}); + dropzone.disable(); + + element.dispatchEvent(new Event("click", { bubbles: true })); + + expect(click).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/dropzone/test/unit-tests/emitter.js b/packages/dropzone/test/unit-tests/emitter.js index fbc6db428..e0993ac93 100644 --- a/packages/dropzone/test/unit-tests/emitter.js +++ b/packages/dropzone/test/unit-tests/emitter.js @@ -70,7 +70,7 @@ describe("Emitter", function () { return expect(callCount2).toBe(1); }); - return describe(".off()", function () { + describe(".off()", function () { let callback1 = function () {}; let callback2 = function () {}; let callback3 = function () {}; @@ -120,4 +120,48 @@ describe("Emitter", function () { return expect(emt).toBe(emitter); }); }); + + describe("off() with nothing to remove", function () { + it("should be a no-op for an event that has no listeners", function () { + emitter.on("test", function () {}); + let returned = emitter.off("unknown"); + + expect(returned).toBe(emitter); + expect(emitter._callbacks["test"].length).toBe(1); + }); + + it("should be a no-op before any listener was registered", function () { + expect(emitter.off("unknown")).toBe(emitter); + }); + }); + + describe("makeEvent()", function () { + it("should build a CustomEvent carrying the detail", function () { + let event = emitter.makeEvent("dropzone:test", { args: [1, 2] }); + + expect(event).toBeInstanceOf(CustomEvent); + expect(event.type).toBe("dropzone:test"); + expect(event.bubbles).toBe(true); + expect(event.cancelable).toBe(true); + expect(event.detail).toEqual({ args: [1, 2] }); + }); + + // The branch behind this exists for IE 11, which cannot be reached from a + // modern browser without taking the constructor away. It is on the 7.0 + // list for deletion; until then this at least proves it still works. + it("should fall back to initCustomEvent when the constructor is missing", function () { + let CustomEventConstructor = window.CustomEvent; + window.CustomEvent = undefined; + + try { + let event = emitter.makeEvent("dropzone:test", { args: [3] }); + + expect(event.type).toBe("dropzone:test"); + expect(event.bubbles).toBe(true); + expect(event.detail).toEqual({ args: [3] }); + } finally { + window.CustomEvent = CustomEventConstructor; + } + }); + }); }); diff --git a/packages/dropzone/test/unit-tests/paste.js b/packages/dropzone/test/unit-tests/paste.js new file mode 100644 index 000000000..5c1ac8cd1 --- /dev/null +++ b/packages/dropzone/test/unit-tests/paste.js @@ -0,0 +1,97 @@ +import { vi } from "vitest"; +import { Dropzone } from "../../src/dropzone.js"; + +describe("paste", function () { + let element = null; + let dropzone = null; + + beforeEach(function () { + element = document.createElement("div"); + document.body.appendChild(element); + dropzone = new Dropzone(element, { url: "/upload", autoProcessQueue: false }); + }); + + afterEach(function () { + dropzone.destroy(); + element.remove(); + }); + + let clipboardEvent = (items) => ({ clipboardData: items == null ? undefined : { items } }); + let fileItem = (name) => ({ + kind: "file", + webkitGetAsEntry: () => null, + getAsFile: () => new File(["x"], name), + }); + + describe("guards", function () { + it("should ignore an event with no clipboardData", function () { + let addFiles = vi.spyOn(dropzone, "_addFilesFromItems"); + dropzone.paste({}); + expect(addFiles).not.toHaveBeenCalled(); + }); + + it("should ignore clipboardData with no items", function () { + let addFiles = vi.spyOn(dropzone, "_addFilesFromItems"); + dropzone.paste(clipboardEvent(null)); + expect(addFiles).not.toHaveBeenCalled(); + }); + + it("should ignore being called with nothing at all", function () { + expect(() => dropzone.paste()).not.toThrow(); + }); + + it("should not reach the items when the clipboard is empty", function () { + let addFiles = vi.spyOn(dropzone, "_addFilesFromItems"); + dropzone.paste(clipboardEvent([])); + expect(addFiles).not.toHaveBeenCalled(); + }); + }); + + describe("with files on the clipboard", function () { + it("should emit paste with the event", function () { + let received = null; + dropzone.on("paste", (e) => (received = e)); + + let event = clipboardEvent([fileItem("pasted.png")]); + dropzone.paste(event); + + expect(received).toBe(event); + }); + + it("should emit paste before adding anything", function () { + let order = []; + dropzone.on("paste", () => order.push("paste")); + dropzone.on("addedfile", () => order.push("addedfile")); + + dropzone.paste(clipboardEvent([fileItem("pasted.png")])); + + expect(order[0]).toBe("paste"); + }); + + it("should add the pasted file", function () { + dropzone.paste(clipboardEvent([fileItem("pasted.png")])); + + expect(dropzone.files.map((f) => f.name)).toEqual(["pasted.png"]); + }); + + it("should add every pasted file", function () { + dropzone.paste(clipboardEvent([fileItem("one.png"), fileItem("two.png")])); + + expect(dropzone.files.map((f) => f.name)).toEqual(["one.png", "two.png"]); + }); + }); + + // Documenting current behaviour rather than endorsing it. drop() emits + // addedfiles as of 6.2 and the hidden input's change handler always has; + // paste is the one entry point that does not, which the 7.0 roadmap lists as + // an inconsistency to settle. If this test starts failing because paste + // learned to emit it, that is the fix landing -- update the test. + it("should not emit addedfiles, unlike the other two entry points", function () { + let addedfiles = vi.fn(); + dropzone.on("addedfiles", addedfiles); + + dropzone.paste(clipboardEvent([fileItem("pasted.png")])); + + expect(addedfiles).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dropzone/test/unit-tests/public-api.js b/packages/dropzone/test/unit-tests/public-api.js new file mode 100644 index 000000000..aaa3bc9e6 --- /dev/null +++ b/packages/dropzone/test/unit-tests/public-api.js @@ -0,0 +1,242 @@ +import { vi } from "vitest"; +import { Dropzone } from "../../src/dropzone.js"; +import defaultOptions from "../../src/options.js"; + +// Documented, publicly reachable behaviour that nothing exercised: the +// fallback form, files added by the server, the removal confirmations, and +// Dropzone.discover. +describe("public API", function () { + let element = null; + let dropzone = null; + + let mockFile = (name = "server.png") => ({ name, size: 123 }); + + afterEach(function () { + if (dropzone != null && typeof dropzone.destroy === "function") dropzone.destroy(); + if (element != null) element.remove(); + dropzone = element = null; + }); + + let create = (options = {}, html = '
') => { + element = Dropzone.createElement(html); + document.body.appendChild(element); + dropzone = new Dropzone(element, { url: "/upload", autoProcessQueue: false, ...options }); + return dropzone; + }; + + describe("displayExistingFile()", function () { + it("should emit addedfile and complete", function () { + create(); + let events = []; + dropzone.on("addedfile", () => events.push("addedfile")); + dropzone.on("complete", () => events.push("complete")); + + dropzone.displayExistingFile(mockFile(), "/thumb.png", null, null, false); + + expect(events).toEqual(["addedfile", "complete"]); + }); + + it("should emit the thumbnail url unchanged when not resizing", function () { + create(); + let thumbnail = null; + dropzone.on("thumbnail", (file, url) => (thumbnail = url)); + + dropzone.displayExistingFile(mockFile(), "/thumb.png", null, null, false); + + expect(thumbnail).toBe("/thumb.png"); + }); + + it("should run the callback when not resizing", function () { + create(); + let callback = vi.fn(); + + dropzone.displayExistingFile(mockFile(), "/thumb.png", callback, null, false); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should keep the url on the file when resizing", function () { + create(); + vi.spyOn(dropzone, "createThumbnailFromUrl").mockImplementation(() => {}); + let file = mockFile(); + + dropzone.displayExistingFile(file, "/thumb.png"); + + expect(file.dataURL).toBe("/thumb.png"); + expect(dropzone.createThumbnailFromUrl).toHaveBeenCalledTimes(1); + }); + + // #2003 and the 7.0 roadmap: files added this way never reach this.files, + // so maxFiles cannot see them. The documented workaround is to push them + // by hand, which is why fixing it is a breaking change rather than a + // patch. This pins the behaviour so the fix is a deliberate act. + it("should not count towards maxFiles", function () { + create({ maxFiles: 1 }); + + dropzone.displayExistingFile(mockFile(), "/thumb.png", null, null, false); + + expect(dropzone.files).toHaveLength(0); + }); + }); + + describe("fallback()", function () { + // Worth pinning: fallback() ends with `return this.element.appendChild(...)`, + // and a constructor returning an object overrides `this`, so this call + // hands back the fallback input element rather than a Dropzone. + let fallbackOn = () => { + element = Dropzone.createElement('
'); + document.body.appendChild(element); + return new Dropzone(element, { url: "/upload", forceFallback: true }); + }; + + it("should return the fallback element, not a Dropzone", function () { + let returned = fallbackOn(); + + expect(returned).toBeInstanceOf(HTMLElement); + expect(returned).not.toBeInstanceOf(Dropzone); + }); + + it("should mark the element as unsupported", function () { + fallbackOn(); + + expect(element.className).toContain("dz-browser-not-supported"); + }); + + it("should show the fallback message", function () { + fallbackOn(); + + expect(element.textContent).toContain(defaultOptions.dictFallbackMessage); + }); + + it("should add a file input that posts to the url", function () { + fallbackOn(); + + expect(element.querySelector("input[type=file]")).not.toBe(null); + expect(element.querySelector("form").getAttribute("action")).toBe("/upload"); + }); + }); + + describe("removal confirmation", function () { + let removeLink = () => element.querySelector("a[data-dz-remove]"); + + let addFile = () => { + let file = new File(["contents"], "file.txt", { type: "text/plain" }); + dropzone.addFile(file); + return dropzone.files[0]; + }; + + it("should ask before removing when dictRemoveFileConfirmation is set", function () { + create({ addRemoveLinks: true, dictRemoveFileConfirmation: "Really?" }); + let confirm = vi.spyOn(Dropzone, "confirm").mockImplementation(() => {}); + addFile(); + + removeLink().dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0][0]).toBe("Really?"); + confirm.mockRestore(); + }); + + it("should remove the file when the confirmation is accepted", function () { + create({ addRemoveLinks: true, dictRemoveFileConfirmation: "Really?" }); + vi.spyOn(Dropzone, "confirm").mockImplementation((message, accepted) => accepted()); + let file = addFile(); + + removeLink().dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); + + expect(dropzone.files).not.toContain(file); + Dropzone.confirm.mockRestore(); + }); + + it("should not ask when there is no confirmation message", function () { + create({ addRemoveLinks: true }); + let confirm = vi.spyOn(Dropzone, "confirm").mockImplementation(() => {}); + let file = addFile(); + + removeLink().dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); + + expect(confirm).not.toHaveBeenCalled(); + expect(dropzone.files).not.toContain(file); + confirm.mockRestore(); + }); + + it("should ask with the cancel message while the file is uploading", function () { + create({ addRemoveLinks: true }); + let confirm = vi.spyOn(Dropzone, "confirm").mockImplementation(() => {}); + let file = addFile(); + file.status = Dropzone.UPLOADING; + + removeLink().dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); + + expect(confirm.mock.calls[0][0]).toBe(dropzone.options.dictCancelUploadConfirmation); + confirm.mockRestore(); + }); + }); + + describe("renameFilename", function () { + it("should be wrapped into renameFile for backwards compatibility", function () { + create({ renameFilename: (name) => `prefix-${name}` }); + + expect(dropzone.options.renameFile({ name: "photo.png" })).toBe("prefix-photo.png"); + }); + + it("should receive the name and the file", function () { + let seen = null; + create({ + renameFilename(name, file) { + seen = { name, file }; + return name; + }, + }); + let file = { name: "photo.png" }; + + dropzone.options.renameFile(file); + + expect(seen.name).toBe("photo.png"); + expect(seen.file).toBe(file); + }); + }); + + describe("handleFiles()", function () { + it("should add every file it is given", function () { + create(); + + dropzone.handleFiles([ + new File(["a"], "a.txt", { type: "text/plain" }), + new File(["b"], "b.txt", { type: "text/plain" }), + ]); + + expect(dropzone.files.map((f) => f.name)).toEqual(["a.txt", "b.txt"]); + }); + }); + + describe("Dropzone.discover()", function () { + it("should attach to elements carrying the dropzone class", function () { + element = Dropzone.createElement('
'); + element.setAttribute("action", "/upload"); + document.body.appendChild(element); + + Dropzone.discover(); + + expect(element.dropzone).toBeInstanceOf(Dropzone); + dropzone = element.dropzone; + }); + + // Opting out is done by id, not by a class: Dropzone.optionsForElement + // looks up Dropzone.options[camelizedId], and discover skips the element + // when that is exactly false. + it("should skip an element whose options are false", function () { + element = Dropzone.createElement('
'); + element.setAttribute("action", "/upload"); + document.body.appendChild(element); + Dropzone.options.optedOut = false; + + try { + Dropzone.discover(); + expect(element.dropzone).toBeUndefined(); + } finally { + delete Dropzone.options.optedOut; + } + }); + }); +}); diff --git a/packages/dropzone/test/unit-tests/upload-errors.js b/packages/dropzone/test/unit-tests/upload-errors.js new file mode 100644 index 000000000..b4f4d14f1 --- /dev/null +++ b/packages/dropzone/test/unit-tests/upload-errors.js @@ -0,0 +1,153 @@ +import { vi } from "vitest"; +import { Dropzone } from "../../src/dropzone.js"; +import { useFakeXMLHttpRequest } from "../fake-xhr.js"; +import { sleep } from "./utils"; + +// The transport-level failure paths. onload had tests; ontimeout, onerror and +// the progress forwarding did not, which is the half of uploading that only +// runs when something has already gone wrong. +describe("upload failures", function () { + let getMockFile = (filename = "test file name") => { + let file = new File(["file contents"], filename, { type: "text/html" }); + file.status = Dropzone.ADDED; + file.accepted = true; + file.upload = { filename }; + return file; + }; + + let xhr = null; + let element = null; + let dropzone = null; + let requests = null; + + beforeEach(function () { + xhr = useFakeXMLHttpRequest(); + requests = []; + xhr.onCreate = (request) => requests.push(request); + + element = Dropzone.createElement("
"); + document.body.appendChild(element); + }); + + afterEach(function () { + dropzone.destroy(); + element.remove(); + xhr.restore(); + }); + + let start = (options = {}) => { + dropzone = new Dropzone(element, { url: "/upload", timeout: 30000, ...options }); + return dropzone; + }; + + describe("a request that times out", function () { + it("should report the timeout in seconds", async function () { + start(); + let errors = []; + dropzone.on("error", (file, message) => errors.push(message)); + + dropzone.addFile(getMockFile()); + await sleep(10); + requests[0].ontimeout(); + + expect(errors).toEqual(["Request timedout after 30 seconds"]); + }); + + it("should mark the file as errored", async function () { + start(); + let file = getMockFile(); + + dropzone.addFile(file); + await sleep(10); + requests[0].ontimeout(); + + expect(file.status).toBe(Dropzone.ERROR); + }); + + it("should still emit complete", async function () { + start(); + let complete = vi.fn(); + dropzone.on("complete", complete); + + dropzone.addFile(getMockFile()); + await sleep(10); + requests[0].ontimeout(); + + expect(complete).toHaveBeenCalledTimes(1); + }); + }); + + describe("a request that errors", function () { + it("should emit error with the default message", async function () { + start(); + let errors = []; + dropzone.on("error", (file, message) => errors.push(message)); + + dropzone.addFile(getMockFile()); + await sleep(10); + requests[0].onerror(); + + expect(errors).toEqual([dropzone.options.dictResponseError.replace("{{statusCode}}", "0")]); + }); + + it("should leave a cancelled file alone", async function () { + start(); + let error = vi.fn(); + dropzone.on("error", error); + let file = getMockFile(); + + dropzone.addFile(file); + await sleep(10); + file.status = Dropzone.CANCELED; + requests[0].onerror(); + + expect(error).not.toHaveBeenCalled(); + }); + }); + + describe("progress", function () { + it("should forward upload progress to the file", async function () { + start(); + dropzone.addFile(getMockFile()); + await sleep(10); + + requests[0].upload.onprogress({ lengthComputable: true, loaded: 50, total: 100 }); + + expect(dropzone.files[0].upload.progress).toBe(50); + }); + + it("should emit uploadprogress", async function () { + start(); + let progress = []; + dropzone.on("uploadprogress", (file, percent) => progress.push(percent)); + + dropzone.addFile(getMockFile()); + await sleep(10); + requests[0].upload.onprogress({ lengthComputable: true, loaded: 25, total: 100 }); + + expect(progress).toContain(25); + }); + }); + + describe("_getChunk", function () { + it("should find the chunk belonging to an xhr", async function () { + start({ forceChunking: true, chunking: true, chunkSize: 4 }); + dropzone.addFile(getMockFile()); + await sleep(10); + + let file = dropzone.files[0]; + let chunk = dropzone._getChunk(file, requests[0]); + + expect(chunk).toBeDefined(); + expect(chunk.xhr).toBe(requests[0]); + }); + + it("should return undefined for an xhr that belongs to no chunk", async function () { + start({ forceChunking: true, chunking: true, chunkSize: 4 }); + dropzone.addFile(getMockFile()); + await sleep(10); + + expect(dropzone._getChunk(dropzone.files[0], {})).toBeUndefined(); + }); + }); +});