// There are at least seven different ways in a which a file can be saved or downloaded. This // test ensures that the filename is determined correctly when saving in these ways. The seven // ways are: // - save the file individually from the File menu // - save as complete web page (this uses a different codepath than the previous one) // - dragging an image to the local file system // - copy an image and paste it as a file to the local file system (windows only) // - open a link with content-disposition set to attachment // - open a link with the download attribute // - save a link or image from the context menu requestLongerTimeout(8); let types = { text: "text/plain", html: "text/html", png: "image/png", jpeg: "image/jpeg", webp: "image/webp", otherimage: "image/unknown", // Other js types (application/javascript and text/javascript) are handled by the system // inconsistently, but application/x-javascript is handled by the external helper app service, // so it is used here for this test. js: "application/x-javascript", binary: "application/octet-stream", nonsense: "application/x-nonsense", zip: "application/zip", json: "application/json", tar: "application/x-tar", }; const PNG_DATA = atob( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" ); const JPEG_DATA = atob( "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4z" + "NDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEB" + "AxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS" + "0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKz" + "tLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgEC" + "BAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpj" + "ZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6" + "/9oADAMBAAIRAxEAPwD3+iiigD//2Q==" ); const WEBP_DATA = atob( "UklGRiIAAABXRUJQVlA4TBUAAAAvY8AYAAfQ/4j+B4CE8H+/ENH/VCIA" ); const DEFAULT_FILENAME = AppConstants.platform == "win" ? "Untitled.htm" : "Untitled.html"; const PROMISE_FILENAME_TYPE = "application/x-moz-file-promise-dest-filename"; let MockFilePicker = SpecialPowers.MockFilePicker; MockFilePicker.init(window); let expectedItems; let sendAsAttachment = false; let httpServer = null; function handleRequest(aRequest, aResponse) { const queryString = new URLSearchParams(aRequest.queryString); let type = queryString.get("type"); let filename = queryString.get("filename"); let dispname = queryString.get("dispname"); aResponse.setStatusLine(aRequest.httpVersion, 200); if (type) { aResponse.setHeader("Content-Type", types[type]); } if (dispname) { let dispositionType = sendAsAttachment ? "attachment" : "inline"; aResponse.setHeader( "Content-Disposition", dispositionType + ';name="' + dispname + '"' ); } else if (filename) { let dispositionType = sendAsAttachment ? "attachment" : "inline"; aResponse.setHeader( "Content-Disposition", dispositionType + ';filename="' + filename + '"' ); } else if (sendAsAttachment) { aResponse.setHeader("Content-Disposition", "attachment"); } if (type == "png") { aResponse.write(PNG_DATA); } else if (type == "jpeg") { aResponse.write(JPEG_DATA); } else if (type == "webp") { aResponse.write(WEBP_DATA); } else if (type == "html") { aResponse.write( "file.invFile" ); } else { aResponse.write("// Some Text"); } } function handleBasicImageRequest(aRequest, aResponse) { aResponse.setHeader("Content-Type", "image/png"); aResponse.write(PNG_DATA); } function handleRedirect(aRequest, aResponse) { const queryString = new URLSearchParams(aRequest.queryString); let filename = queryString.get("filename"); aResponse.setStatusLine(aRequest.httpVersion, 302); aResponse.setHeader("Location", "/bell" + filename[0] + "?" + queryString); } function promiseDownloadFinished(list) { return new Promise(resolve => { list.addView({ onDownloadChanged(download) { if (download.stopped) { list.removeView(this); resolve(download); } }, }); }); } // nsIFile::CreateUnique crops long filenames if the path is too long, but // we don't know exactly how long depending on the full path length, so // for those save methods that use CreateUnique, instead just verify that // the filename starts with the right string and has the correct extension. function checkShortenedFilename(actual, expected) { if (actual.length < expected.length) { let actualDot = actual.lastIndexOf("."); let actualExtension = actual.substring(actualDot); let expectedExtension = expected.substring(expected.lastIndexOf(".")); if ( actualExtension == expectedExtension && expected.startsWith(actual.substring(0, actualDot)) ) { return true; } } return false; } add_setup(async function () { const { HttpServer } = ChromeUtils.import( "resource://testing-common/httpd.js" ); httpServer = new HttpServer(); httpServer.start(8000); // Need to load the page from localhost:8000 as the download attribute // only applies to links from the same domain. let saveFilenamesPage = FileUtils.getFile( "CurWorkD", "/browser/uriloader/exthandler/tests/mochitest/save_filenames.html".split( "/" ) ); httpServer.registerFile("/save_filenames.html", saveFilenamesPage); // A variety of different scripts are set up to better ensure uniqueness. httpServer.registerPathHandler("/save_filename.sjs", handleRequest); httpServer.registerPathHandler("/save_thename.sjs", handleRequest); httpServer.registerPathHandler("/getdata.png", handleRequest); httpServer.registerPathHandler("/base", handleRequest); httpServer.registerPathHandler("/basedata", handleRequest); httpServer.registerPathHandler("/basetext", handleRequest); httpServer.registerPathHandler("/text2.txt", handleRequest); httpServer.registerPathHandler("/text3.gonk", handleRequest); httpServer.registerPathHandler("/basic.png", handleBasicImageRequest); httpServer.registerPathHandler("/aquamarine.jpeg", handleBasicImageRequest); httpServer.registerPathHandler("/lazuli.exe", handleBasicImageRequest); httpServer.registerPathHandler("/redir", handleRedirect); httpServer.registerPathHandler("/bellr", handleRequest); httpServer.registerPathHandler("/bellg", handleRequest); httpServer.registerPathHandler("/bellb", handleRequest); httpServer.registerPathHandler("/executable.exe", handleRequest); await BrowserTestUtils.openNewForegroundTab( gBrowser, "http://localhost:8000/save_filenames.html" ); expectedItems = await getItems("items"); }); function getItems(parentid) { return SpecialPowers.spawn( gBrowser.selectedBrowser, [parentid, AppConstants.platform], (id, platform) => { let elements = []; let elem = content.document.getElementById(id).firstElementChild; while (elem) { let filename = elem.dataset["filenamePlatform" + platform] || elem.dataset.filename; let url = elem.getAttribute("src"); let draggable = elem.localName == "img" && elem.dataset.nodrag != "true"; let unknown = elem.dataset.unknown; let noattach = elem.dataset.noattach; let savepagename = elem.dataset.savepagename; elements.push({ draggable, unknown, filename, url, noattach, savepagename, }); elem = elem.nextElementSibling; } return elements; } ); } function getDirectoryEntries(dir) { let files = []; let entries = dir.directoryEntries; while (true) { let file = entries.nextFile; if (!file) { break; } files.push(file.leafName); } entries.close(); return files; } // This test saves the document as a complete web page and verifies // that the resources are saved with the correct filename. add_task(async function save_document() { let browser = gBrowser.selectedBrowser; let tmp = SpecialPowers.Services.dirsvc.get("TmpD", Ci.nsIFile); const baseFilename = "test_save_filenames_" + Date.now(); let tmpFile = tmp.clone(); tmpFile.append(baseFilename + "_document.html"); let tmpDir = tmp.clone(); tmpDir.append(baseFilename + "_document_files"); MockFilePicker.displayDirectory = tmpDir; MockFilePicker.showCallback = function (fp) { MockFilePicker.setFiles([tmpFile]); MockFilePicker.filterIndex = 0; // kSaveAsType_Complete }; let downloadsList = await Downloads.getList(Downloads.PUBLIC); let savePromise = new Promise((resolve, reject) => { downloadsList.addView({ onDownloadChanged(download) { if (download.succeeded) { downloadsList.removeView(this); downloadsList.removeFinished(); resolve(); } }, }); }); saveBrowser(browser); await savePromise; let filesSaved = getDirectoryEntries(tmpDir); for (let idx = 0; idx < expectedItems.length; idx++) { let filename = expectedItems[idx].filename; if (idx == 66 && AppConstants.platform == "win") { // This is special-cased on Windows. The default filename will be used, since // the filename is invalid, but since the previous test file has the same issue, // this second file will be saved with a number suffix added to it. filename = "Untitled_002"; } let file = tmpDir.clone(); file.append(filename); let fileIdx = -1; // Use checkShortenedFilename to check long filenames. if (filename.length > 240) { for (let t = 0; t < filesSaved.length; t++) { if ( filesSaved[t].length > 60 && checkShortenedFilename(filesSaved[t], filename) ) { fileIdx = t; break; } } } else { fileIdx = filesSaved.indexOf(filename); } ok( fileIdx >= 0, "file i" + idx + " " + filename + " was saved with the correct name using saveDocument" ); if (fileIdx >= 0) { // If found, remove it from the list. At end of the test, the // list should be empty. filesSaved.splice(fileIdx, 1); } } is(filesSaved.length, 0, "all files accounted for"); tmpDir.remove(true); tmpFile.remove(false); }); // This test simulates dragging the images in the document and ensuring that // the correct filename is used for each one. // On Mac, the data is added in the parent process instead, so we cannot // test dragging directly. if (AppConstants.platform != "macosx") { add_task(async function drag_files() { let browser = gBrowser.selectedBrowser; await SpecialPowers.spawn(browser, [PROMISE_FILENAME_TYPE], type => { content.addEventListener("dragstart", event => { content.draggedFile = event.dataTransfer.getData(type); event.preventDefault(); }); }); for (let idx = 0; idx < expectedItems.length; idx++) { if (!expectedItems[idx].draggable) { // You can't drag non-images and invalid images. continue; } await BrowserTestUtils.synthesizeMouse( "#i" + idx, 1, 1, { type: "mousedown" }, browser ); await BrowserTestUtils.synthesizeMouse( "#i" + idx, 11, 11, { type: "mousemove" }, browser ); await BrowserTestUtils.synthesizeMouse( "#i" + idx, 20, 20, { type: "mousemove" }, browser ); await BrowserTestUtils.synthesizeMouse( "#i" + idx, 20, 20, { type: "mouseup" }, browser ); let draggedFile = await SpecialPowers.spawn(browser, [], () => { let file = content.draggedFile; content.draggedFile = null; return file; }); is( draggedFile, expectedItems[idx].filename, "i" + idx + " " + expectedItems[idx].filename + " was saved with the correct name when dragging" ); } }); } // This test checks that copying an image provides the right filename // for pasting to the local file system. This is only implemented on Windows. if (AppConstants.platform == "win") { add_task(async function copy_image() { for (let idx = 0; idx < expectedItems.length; idx++) { if (!expectedItems[idx].draggable) { // You can't context-click on non-images. continue; } let data = await SpecialPowers.spawn( gBrowser.selectedBrowser, [idx, PROMISE_FILENAME_TYPE], (imagenum, type) => { // No need to wait for the data to be really on the clipboard, we only // need the promise data added when the command is performed. SpecialPowers.setCommandNode( content, content.document.getElementById("i" + imagenum) ); SpecialPowers.doCommand(content, "cmd_copyImageContents"); return SpecialPowers.getClipboardData(type); } ); is( data, expectedItems[idx].filename, "i" + idx + " " + expectedItems[idx].filename + " was saved with the correct name when copying" ); } }); } // This test checks the default filename selected when selecting to save // a file from either the context menu or what would happen when save page // as was selected from the file menu. Note that this tests a filename assigned // when using content-disposition: inline. add_task(async function saveas_files() { // Iterate over each item and try saving them from the context menu, // and the Save Page As command on the File menu. for (let testname of ["context menu", "save page as"]) { for (let idx = 0; idx < expectedItems.length; idx++) { let menu; if (testname == "context menu") { if (!expectedItems[idx].draggable) { // You can't context-click on non-images. continue; } menu = document.getElementById("contentAreaContextMenu"); let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); BrowserTestUtils.synthesizeMouse( "#i" + idx, 5, 5, { type: "contextmenu", button: 2 }, gBrowser.selectedBrowser ); await popupShown; } else { if (expectedItems[idx].unknown == "typeonly") { // Items marked with unknown="typeonly" have unknown content types and // will be downloaded instead of opened in a tab. let list = await Downloads.getList(Downloads.PUBLIC); let downloadFinishedPromise = promiseDownloadFinished(list); await BrowserTestUtils.openNewForegroundTab({ gBrowser, opening: expectedItems[idx].url, waitForLoad: false, waitForStateStop: true, }); let download = await downloadFinishedPromise; let filename = PathUtils.filename(download.target.path); let expectedFilename = expectedItems[idx].filename; if (expectedFilename.length > 240) { ok( checkShortenedFilename(filename, expectedFilename), "open link" + idx + " " + expectedFilename + " was downloaded with the correct name when opened as a url (with long name)" ); } else { is( filename, expectedFilename, "open link" + idx + " " + expectedFilename + " was downloaded with the correct name when opened as a url" ); } try { await IOUtils.remove(download.target.path); } catch (ex) {} await BrowserTestUtils.removeTab(gBrowser.selectedTab); continue; } await BrowserTestUtils.openNewForegroundTab({ gBrowser, opening: expectedItems[idx].url, waitForLoad: false, waitForStateStop: true, }); } let filename = await new Promise(resolve => { MockFilePicker.showCallback = function (fp) { setTimeout(() => { resolve(fp.defaultString); }, 0); return Ci.nsIFilePicker.returnCancel; }; if (testname == "context menu") { let menuitem = document.getElementById("context-saveimage"); menu.activateItem(menuitem); } else if (testname == "save page as") { document.getElementById("Browser:SavePage").doCommand(); } }); // Trying to open an unknown or binary type will just open a blank // page, so trying to save will just save the blank page with the // filename Untitled.html. let expectedFilename = expectedItems[idx].unknown ? DEFAULT_FILENAME : expectedItems[idx].savepagename || expectedItems[idx].filename; // When saving via contentAreaUtils.js, the content disposition name // field is used as an alternate. if (expectedFilename == "save_thename.png") { expectedFilename = "withname.png"; } is( filename, expectedFilename, "i" + idx + " " + expectedFilename + " was saved with the correct name " + testname ); if (testname == "save page as") { await BrowserTestUtils.removeTab(gBrowser.selectedTab); } } } }); // This test checks that links that result in files with // content-disposition: attachment are saved with the right filenames. add_task(async function save_links() { sendAsAttachment = true; // Create some links based on each image and insert them into the document. await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { let doc = content.document; let insertPos = doc.getElementById("attachment-links"); let idx = 0; let elem = doc.getElementById("items").firstElementChild; while (elem) { let attachmentlink = doc.createElement("a"); attachmentlink.id = "attachmentlink" + idx; attachmentlink.href = elem.localName == "object" ? elem.data : elem.src; attachmentlink.textContent = elem.dataset.filename; insertPos.appendChild(attachmentlink); insertPos.appendChild(doc.createTextNode(" ")); elem = elem.nextElementSibling; idx++; } }); let list = await Downloads.getList(Downloads.PUBLIC); for (let idx = 0; idx < expectedItems.length; idx++) { // Skip the items that won't have a content-disposition. if (expectedItems[idx].noattach) { continue; } let downloadFinishedPromise = promiseDownloadFinished(list); BrowserTestUtils.synthesizeMouse( "#attachmentlink" + idx, 5, 5, {}, gBrowser.selectedBrowser ); let download = await downloadFinishedPromise; let filename = PathUtils.filename(download.target.path); let expectedFilename = expectedItems[idx].filename; // Use checkShortenedFilename to check long filenames. if (expectedItems[idx].filename.length > 240) { ok( checkShortenedFilename(filename, expectedFilename), "attachmentlink" + idx + " " + expectedFilename + " was saved with the correct name when opened as attachment (with long name)" ); } else { is( filename, expectedFilename, "attachmentlink" + idx + " " + expectedFilename + " was saved with the correct name when opened as attachment" ); } try { await IOUtils.remove(download.target.path); } catch (ex) {} } sendAsAttachment = false; }); // This test checks some cases where links to images are saved using Save Link As, // and when opening them in a new tab and then using Save Page As. add_task(async function saveas_image_links() { let links = await getItems("links"); // Iterate over each link and try saving the links from the context menu, // and then after opening a new tab for that link and then selecting // the Save Page As command on the File menu. for (let testname of ["save link as", "save link then save page as"]) { for (let idx = 0; idx < links.length; idx++) { let menu = document.getElementById("contentAreaContextMenu"); let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); BrowserTestUtils.synthesizeMouse( "#link" + idx, 5, 5, { type: "contextmenu", button: 2 }, gBrowser.selectedBrowser ); await popupShown; let promptPromise = new Promise(resolve => { MockFilePicker.showCallback = function (fp) { setTimeout(() => { resolve(fp.defaultString); }, 0); return Ci.nsIFilePicker.returnCancel; }; }); if (testname == "save link as") { let menuitem = document.getElementById("context-savelink"); menu.activateItem(menuitem); } else { let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); let menuitem = document.getElementById("context-openlinkintab"); menu.activateItem(menuitem); let tab = await newTabPromise; await BrowserTestUtils.switchTab(gBrowser, tab); document.getElementById("Browser:SavePage").doCommand(); } let filename = await promptPromise; let expectedFilename = links[idx].filename; // Only codepaths that go through contentAreaUtils.js use the // name from the content disposition. if (testname == "save link as" && expectedFilename == "four.png") { expectedFilename = "save_filename.png"; } is( filename, expectedFilename, "i" + idx + " " + expectedFilename + " link was saved with the correct name " + testname ); if (testname == "save link then save page as") { await BrowserTestUtils.removeTab(gBrowser.selectedTab); } } } }); // This test checks that links that with a download attribute // are saved with the right filenames. add_task(async function save_download_links() { let downloads = await getItems("downloads"); let list = await Downloads.getList(Downloads.PUBLIC); for (let idx = 0; idx < downloads.length; idx++) { let downloadFinishedPromise = promiseDownloadFinished(list); BrowserTestUtils.synthesizeMouse( "#download" + idx, 2, 2, {}, gBrowser.selectedBrowser ); let download = await downloadFinishedPromise; let filename = PathUtils.filename(download.target.path); if (downloads[idx].filename.length > 240) { ok( checkShortenedFilename(filename, downloads[idx].filename), "download" + idx + " " + downloads[idx].filename + " was saved with the correct name when link has download attribute" ); } else { if (idx == 66 && filename == "Untitled(1)") { // Sometimes, the previous test's file still exists or wasn't created in time // and a non-duplicated name is created. Allow this rather than figuring out // how to avoid it since it doesn't affect what is being tested here. filename = "Untitled"; } is( filename, downloads[idx].filename, "download" + idx + " " + downloads[idx].filename + " was saved with the correct name when link has download attribute" ); } try { await IOUtils.remove(download.target.path); } catch (ex) {} } }); // This test verifies that invalid extensions are not removed when they // have been entered in the file picker. add_task(async function save_page_with_invalid_after_filepicker() { await BrowserTestUtils.openNewForegroundTab( gBrowser, "http://localhost:8000/save_filename.sjs?type=html&filename=invfile.lnk" ); let filename = await new Promise(resolve => { MockFilePicker.showCallback = function (fp) { let expectedFilename = AppConstants.platform == "win" ? "invfile.lnk.htm" : "invfile.lnk.html"; is(fp.defaultString, expectedFilename, "supplied filename is correct"); setTimeout(() => { resolve("otherfile.local"); }, 0); return Ci.nsIFilePicker.returnCancel; }; document.getElementById("Browser:SavePage").doCommand(); }); is(filename, "otherfile.local", "lnk extension has been preserved"); await BrowserTestUtils.removeTab(gBrowser.selectedTab); }); add_task(async function save_page_with_invalid_extension() { await BrowserTestUtils.openNewForegroundTab( gBrowser, "http://localhost:8000/save_filename.sjs?type=html" ); let filename = await new Promise(resolve => { MockFilePicker.showCallback = function (fp) { setTimeout(() => { resolve(fp.defaultString); }, 0); return Ci.nsIFilePicker.returnCancel; }; document.getElementById("Browser:SavePage").doCommand(); }); is( filename, AppConstants.platform == "win" ? "file.inv.htm" : "file.inv.html", "html extension has been added" ); await BrowserTestUtils.removeTab(gBrowser.selectedTab); }); add_task(async () => { BrowserTestUtils.removeTab(gBrowser.selectedTab); MockFilePicker.cleanup(); await new Promise(resolve => httpServer.stop(resolve)); });