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(); + }); + }); +});