Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
"use strict";
const { sinon } = ChromeUtils.importESModule(
);
const { DragUtils } = ChromeUtils.importESModule(
"moz-src:///comm/mail/modules/DragUtils.sys.mjs"
);
const { MessageGenerator } = ChromeUtils.importESModule(
);
const { ensure_table_view } = ChromeUtils.importESModule(
);
const { AttachmentInfo } = ChromeUtils.importESModule(
);
add_task(async function test_multiDragImage() {
const sandbox = sinon.createSandbox();
registerCleanupFunction(() => sandbox.restore());
const text = sandbox.spy(CanvasRenderingContext2D.prototype, "fillText");
const dataTransfer = { setDragImage: sandbox.spy() };
const event = { target: document.documentElement, dataTransfer };
Assert.ok(
!DragUtils.setMultiDragImage(event, 1),
"a single item should retain its existing drag image"
);
Assert.ok(
dataTransfer.setDragImage.notCalled,
"a single item should not receive a count badge"
);
for (const [count, scale] of [
[2, "1"],
[12, "2"],
[10000, "2"],
]) {
await SpecialPowers.pushPrefEnv({
set: [["layout.css.devPixelsPerPx", scale]],
});
await TestUtils.waitForCondition(
() => devicePixelRatio == Number(scale),
"the window should use the requested pixel ratio"
);
dataTransfer.setDragImage.resetHistory();
text.resetHistory();
Assert.ok(
DragUtils.setMultiDragImage(event, count),
"multiple items should receive a custom drag image"
);
Assert.equal(
dataTransfer.setDragImage.callCount,
1,
"the drag image should be set once"
);
const [canvas, x, y] = dataTransfer.setDragImage.firstCall.args;
Assert.ok(
HTMLCanvasElement.isInstance(canvas),
"the drag image should be a canvas"
);
Assert.equal(
text.firstCall.args[0],
count.toLocaleString(),
"the badge should display the number of exported items"
);
Assert.equal(
canvas.height,
Math.ceil(48 * devicePixelRatio),
"the canvas should account for the screen pixel ratio"
);
Assert.ok(
canvas.width > x * devicePixelRatio &&
canvas.height > y * devicePixelRatio,
"the pointer hotspot should be inside the drag image"
);
const pixels = canvas
.getContext("2d")
.getImageData(0, 0, canvas.width, canvas.height);
Assert.ok(
pixels.data.some((value, index) => index % 4 == 3 && value > 0),
"the preview should contain painted pixels"
);
await SpecialPowers.popPrefEnv();
}
sandbox.restore();
});
add_task(function test_attachmentDragImage() {
const sandbox = sinon.createSandbox();
registerCleanupFunction(() => sandbox.restore());
const preview = sandbox.spy(DragUtils, "setMultiDragImage");
const attachments = [
{ name: "missing.txt", url: "", contentType: "text/plain" },
{
name: "second.txt",
contentType: "text/plain",
},
];
for (const selected of [
attachments.slice(0, 1),
attachments,
[...attachments, { contentType: "text/x-moz-deleted" }, attachments[0]],
]) {
const items = [];
const dataTransfer = {
mozSetDataAt(type, value, index) {
(items[index] ??= new Map()).set(type, value);
},
setDragImage: sandbox.spy(),
};
DragUtils.setupAttachmentDataTransfer(
{ target: document.documentElement, dataTransfer },
selected
);
Assert.equal(
preview.lastCall.args[1],
items.length,
"the preview count should match exported attachments, excluding missing URLs"
);
Assert.equal(
dataTransfer.setDragImage.callCount,
items.length > 1 ? 1 : 0,
"only multiple exported attachments should receive a custom preview"
);
Assert.equal(
items[0].get("application/x-moz-file-promise-url"),
attachments[0].url,
"the first attachment should retain its exported URL"
);
if (items.length > 1) {
Assert.equal(
items[1].get("application/x-moz-file-promise-url"),
attachments[2].url,
"the second exported attachment should retain its URL"
);
}
}
sandbox.restore();
});
add_task(async function test_attachmentFilePromises() {
const sandbox = sinon.createSandbox();
registerCleanupFunction(() => sandbox.restore());
const save = sandbox.spy(AttachmentInfo.prototype, "saveToFile");
const rootPath = await IOUtils.createUniqueDirectory(
PathUtils.tempDir,
"attachmentFilePromises"
);
const list = await Downloads.getList(Downloads.ALL);
registerCleanupFunction(async () => {
for (const download of await list.getAll()) {
if (PathUtils.parent(download.target.path) == rootPath) {
await list.remove(download);
}
}
await IOUtils.remove(rootPath, { recursive: true });
});
const sourcePath = PathUtils.join(rootPath, "null-report.txt");
await IOUtils.writeUTF8(sourcePath, "Composer attachment");
const reader = new AttachmentInfo({
url: "data:text/plain,Reader%20attachment",
name: "reader.txt",
contentType: "text/plain",
});
const composer = Cc[
"@mozilla.org/messengercompose/attachment;1"
].createInstance(Ci.nsIMsgAttachment);
composer.url = PathUtils.toFileURI(sourcePath);
composer.name = "long-" + "a".repeat(100) + ".txt";
composer.contentType = "text/plain";
const attachments = [reader, composer];
function beginDrag() {
const items = [];
DragUtils.setupAttachmentDataTransfer(
{
target: document.documentElement,
dataTransfer: {
mozSetDataAt(type, value, index) {
(items[index] ??= new Map()).set(type, value);
},
setDragImage() {},
},
},
attachments
);
return items;
}
const items = beginDrag();
const transferables = [];
const savedPaths = [];
Assert.strictEqual(
items[0].get("application/x-moz-file-promise"),
items[1].get("application/x-moz-file-promise"),
"all items in a drag should share a provider"
);
const originalPath = PathUtils.join(rootPath, reader.name);
await IOUtils.writeUTF8(originalPath, "Existing file");
// Composer edits after dragstart should not change the promised source.
composer.url = PathUtils.toFileURI(PathUtils.join(rootPath, "missing.txt"));
for (const [index, item] of items.entries()) {
const transferable = Cc[
"@mozilla.org/widget/transferable;1"
].createInstance(Ci.nsITransferable);
transferable.init(window.docShell);
const source = Cc["@mozilla.org/supports-string;1"].createInstance(
Ci.nsISupportsString
);
source.data = item.get("application/x-moz-file-promise-url");
transferable.setTransferData("application/x-moz-file-promise-url", source);
const filename = Cc["@mozilla.org/supports-string;1"].createInstance(
Ci.nsISupportsString
);
filename.data = item.get("application/x-moz-file-promise-dest-filename");
transferable.setTransferData(
"application/x-moz-file-promise-dest-filename",
filename
);
const directory = Cc["@mozilla.org/file/local;1"].createInstance(
Ci.nsIFile
);
directory.initWithPath(rootPath);
transferable.setTransferData(
"application/x-moz-file-promise-dir",
directory
);
const provider = item
.get("application/x-moz-file-promise")
.QueryInterface(Ci.nsIFlavorDataProvider);
const result = {};
const before = save.callCount;
provider.getFlavorData(
transferable,
"application/x-moz-file-promise",
result
);
const file = result.value.QueryInterface(Ci.nsIFile);
transferables.push(transferable);
savedPaths.push(file.path);
Assert.equal(
save.callCount,
before + 1,
"the provider should save the requested attachment"
);
Assert.deepEqual(
save.lastCall.args,
[file.path],
"saving should use the default isTmp flag rather than passing the message URI"
);
await save.lastCall.returnValue;
Assert.equal(
await IOUtils.readUTF8(file.path),
index == 0 ? "Reader attachment" : "Composer attachment",
"the promised file should contain the corresponding attachment"
);
Assert.ok(
(await list.getAll()).some(download => download.target.path == file.path),
"the saved attachment should appear in the downloads list"
);
if (index == 0) {
Assert.notEqual(
file.path,
originalPath,
"saving should preserve an existing file"
);
} else {
Assert.equal(
file.leafName,
item.get("application/x-moz-file-promise-dest-filename"),
"saving should use the shortened filename exported at dragstart"
);
}
const repeated = {};
provider.getFlavorData(
transferable,
"application/x-moz-file-promise",
repeated
);
if (AppConstants.platform == "macosx") {
Assert.equal(
save.callCount,
before + 1,
"repeated macOS requests should save only once"
);
Assert.equal(
repeated.value.path,
file.path,
"repeated macOS requests should reuse the reserved file"
);
} else {
Assert.equal(
save.callCount,
before + 2,
"another request should save to another unique file"
);
await save.lastCall.returnValue;
}
}
Assert.equal(
await IOUtils.readUTF8(originalPath),
"Existing file",
"the original file should remain intact"
);
const nextProvider = beginDrag()[0].get("application/x-moz-file-promise");
Assert.notStrictEqual(
nextProvider,
items[0].get("application/x-moz-file-promise"),
"a new drag should have an independent provider"
);
const nextResult = {};
const before = save.callCount;
nextProvider.getFlavorData(
transferables[0],
"application/x-moz-file-promise",
nextResult
);
Assert.equal(
save.callCount,
before + 1,
"a new drag should save the attachment again, including on macOS"
);
await save.lastCall.returnValue;
Assert.notEqual(
nextResult.value.path,
savedPaths[0],
"a new drag should reserve a new destination file"
);
Assert.ok(
!Object.hasOwn(reader, "lastSavedPath"),
"duplicate-save tracking should not mutate the attachment"
);
sandbox.restore();
});
add_task(async function test_messageDragImage() {
const sandbox = sinon.createSandbox();
registerCleanupFunction(() => sandbox.restore());
const preview = sandbox.spy(DragUtils, "setMultiDragImage");
const tabmail = document.getElementById("tabmail");
const about3Pane = tabmail.currentAbout3Pane;
const originalFolder = about3Pane.gFolder;
const account = MailServices.accounts.createLocalMailAccount();
const folder = account.incomingServer.rootFolder
.QueryInterface(Ci.nsIMsgLocalMailFolder)
.createLocalSubfolder("dragImages")
.QueryInterface(Ci.nsIMsgLocalMailFolder);
let searchWindow = null;
registerCleanupFunction(async () => {
if (searchWindow && !searchWindow.closed) {
await BrowserTestUtils.closeWindow(searchWindow);
}
about3Pane.restoreState({
folderURI: originalFolder?.URI ?? account.incomingServer.rootFolder.URI,
messagePaneVisible: true,
});
MailServices.accounts.removeAccount(account, false);
});
folder.addMessageBatch(
new MessageGenerator()
.makeMessages({ count: 3, subject: "Drag image" })
.map(msg => msg.toMessageString())
);
about3Pane.restoreState({ folderURI: folder.URI, messagePaneVisible: false });
await ensure_table_view(document);
const opened = BrowserTestUtils.domWindowOpenedAndLoaded(
null,
win =>
win.document.documentURI ==
);
window.searchAllMessages();
searchWindow = await opened;
searchWindow.document.querySelector(
"#searchTermList search-value input"
).value = "Drag image";
searchWindow.onSearch();
const searchTree = searchWindow.document.getElementById("threadTree");
await TestUtils.waitForCondition(
() => searchTree.view?.rowCount == 3,
"the search should find all three messages"
);
for (const win of [about3Pane, searchWindow]) {
const threadTree = win.document.getElementById("threadTree");
for (const indices of [[0], [0, 2]]) {
preview.resetHistory();
threadTree.selectedIndices = indices;
const row = threadTree.getRowAtIndex(0);
const images = [];
const items = [];
const dataTransfer = {
mozSetDataAt(type, value, index) {
(items[index] ??= new Map()).set(type, value);
},
setDragImage(image) {
images.push(image);
},
addElement: sandbox.spy(),
};
const event = {
target: row,
clientX: 20,
clientY: 20,
dataTransfer,
};
if (win == about3Pane) {
about3Pane.threadPane._onDragStart(event);
} else {
searchWindow.ThreadPaneOnDragStart(event);
}
Assert.equal(
items.length,
indices.length,
"all selected messages should be exported"
);
Assert.equal(
images.length,
indices.length > 1 || win == about3Pane ? 1 : 0,
"the drag should use a count preview for multiple messages and retain single-item feedback"
);
Assert.equal(
preview.lastCall.args[1],
indices.length,
"the preview should count the exported messages"
);
if (indices.length == 1 && win == about3Pane) {
Assert.equal(
images[0],
row,
"a single message should retain its row preview"
);
} else if (indices.length > 1) {
Assert.ok(
win.HTMLCanvasElement.isInstance(images[0]),
"multiple messages should use a count preview"
);
} else {
Assert.ok(
dataTransfer.addElement.calledOnceWithExactly(row),
"a single search result should retain its drag source"
);
}
}
}
await BrowserTestUtils.closeWindow(searchWindow);
sandbox.restore();
});