new function () {
var { HttpServer } = ChromeUtils.importESModule("chrome://remote/content/server/httpd.sys.mjs");;

const { HiddenBrowser } = ChromeUtils.importESModule('chrome://zotero/content/HiddenBrowser.mjs');

/**
 * Create a new translator that saves the specified items
 * @param {String} translatorType - "import" or "web"
 * @param {Object} items - items as translator JSON
 */
function saveItemsThroughTranslator(translatorType, items, translateOptions = {}) {
	let tyname;
	if (translatorType == "web") {
		tyname = "Web";
	} else if (translatorType == "import") {
		tyname = "Import";
	} else {
		throw new Error("invalid translator type "+translatorType);
	}

	let translate = new Zotero.Translate[tyname]();
	if (translatorType == "web") {
		let doc = new DOMParser().parseFromString('<!DOCTYPE html><html></html>', 'text/html');
		doc = Zotero.HTTP.wrapDocument(doc, 'https://www.example.com/');
		translate.setDocument(doc);
	} else if (translatorType == "import") {
		translate.setString("");
	}
	translate.setTranslator(buildDummyTranslator(
		translatorType,
		"function detectWeb() {}\n"+
		"function do"+tyname+"() {\n"+
		"	var json = JSON.parse('"+JSON.stringify(items).replace(/['\\]/g, "\\$&")+"');\n"+
		"	for (var i=0; i<json.length; i++) {"+
		"		var item = new Zotero.Item;\n"+
		"		for (var field in json[i]) { item[field] = json[i][field]; }\n"+
		"		item.complete();\n"+
		"	}\n"+
		"}"));
	return translate.translate(translateOptions).then(function (items) {
		return items;
	});
}

/**
 * Convert an array of items to an object in which they are indexed by
 * their display titles
 */
function itemsArrayToObject(items) {
	var obj = {};
	for (let item of items) {
		obj[item.getDisplayTitle()] = item;
	}
	return obj;
}

const TEST_TAGS = [
	"manual tag as string",
	{"tag":"manual tag as object"},
	{"tag":"manual tag as object with type", "type":0},
	{"tag":"automatic tag as object", "type":1},
	{"name":"tag in name property"},
	{ name: "" },
	{ name: "  " },
];

/**
 * Check that tags match expected values, if TEST_TAGS is passed as test array
 */
function checkTestTags(newItem, web) {
	assert.equal(newItem.getTagType("manual tag as string"), web ? 1 : 0);
	assert.equal(newItem.getTagType("manual tag as object"), web ? 1 : 0);
	assert.equal(newItem.getTagType("manual tag as object with type"), web ? 1 : 0);
	assert.equal(newItem.getTagType("automatic tag as object"), 1);
	assert.equal(newItem.getTagType("tag in name property"), web ? 1 : 0);
}

/**
 * Get included test snapshot file
 * @returns {nsIFile}
 */
function getTestSnapshot() {
	let snapshot = getTestDataDirectory();
	snapshot.append("snapshot");
	snapshot.append("index.html");
	return snapshot;
}

/**
 * Get included test snapshot file
 * @returns {nsIFile}
 */
function getTestPDF() {
	let testPDF = getTestDataDirectory();
	testPDF.append("empty.pdf");
	return testPDF;
}

/**
 * Set up endpoints for testing attachment saving
 * This must happen immediately before the test, since Zotero might get
 * restarted by resetDB(), which would erase our registered endpoints.
 */
function setupAttachmentEndpoints() {
	var SnapshotTest = function () {};
	Zotero.Server.Endpoints["/test/translate/test.html"] = SnapshotTest;
	SnapshotTest.prototype = {
		"supportedMethods":["GET"],
		"init":function (data, sendResponseCallback) {
			Zotero.File.getBinaryContentsAsync(getTestSnapshot()).then(function (data) {
				sendResponseCallback(200, "text/html", data);
			});
		}
	}
	var PDFTest = function () {};
	Zotero.Server.Endpoints["/test/translate/test.pdf"] = PDFTest;
	PDFTest.prototype = {
		"supportedMethods":["GET"],
		"init":function (data, sendResponseCallback) {
			Zotero.File.getBinaryContentsAsync(getTestPDF()).then(function (data) {
				sendResponseCallback(200, "application/pdf", data);
			});
		}
	}
	var NonExistentTest = function () {};
	Zotero.Server.Endpoints["/test/translate/does_not_exist.html"] = NonExistentTest;
	NonExistentTest.prototype = {
		"supportedMethods":["GET"],
		"init":function (data, sendResponseCallback) {
			sendResponseCallback(404, "text/html", "File does not exist");
		}
	}
}

/**
 * Set up endpoints for testing async translators
 * As above, this must happen immediately before the test.
 */
function setupAsyncEndpoints() {
	var JSONTest = function () {};
	Zotero.Server.Endpoints["/test/translate/test.json"] = JSONTest;
	JSONTest.prototype = {
		"supportedMethods": ["GET"],
		"init": function (data, sendResponseCallback) {
			sendResponseCallback(200, "application/json", JSON.stringify({
				success: true,
				array: [1, 2, 3]
			}));
		}
	}
}

describe("Zotero.Translate", function () {
	let serverURL
	let htmlURL;
	
	before(function* () {
		serverURL = `http://127.0.0.1:${Zotero.Server.port}/test/translate/`;
		htmlURL = serverURL + 'test.html';
		
		// TEMP: Fix for slow translator initialization on Linux/Travis
		this.timeout(20000);
		yield Zotero.Translators.init();
		
		setupAttachmentEndpoints();
	});

	describe("Zotero.Item", function () {
		it('should save ordinary fields and creators', async function () {
			this.timeout(10000);
			let data = loadSampleData('allTypesAndFields');
			let trueItems = loadSampleData('itemJSON');
			let saveItems = [];
			for (let itemType in data) {
				saveItems.push(data[itemType]);
				let trueItem = trueItems[itemType];
				delete trueItem.dateAdded;
				delete trueItem.dateModified;
				delete trueItem.key;
			}

			let newItems = await saveItemsThroughTranslator("import", saveItems);
			let savedItems = {};
			for (let i=0; i<newItems.length; i++) {
				let savedItem = newItems[i].toJSON();
				savedItems[Zotero.ItemTypes.getName(newItems[i].itemTypeID)] = savedItem;
				delete savedItem.dateAdded;
				delete savedItem.dateModified;
				delete savedItem.key;
			}
			assert.deepEqual(savedItems, trueItems, "saved items match inputs");
		});
		
		it("should set shortTitle by truncating before first colon or after first question mark", async function () {
			let mapping = {
				'A title: a subtitle': 'A title',
				'A title: a subtitle: a second subtitle': 'A title',
				'Does this have a subtitle? Yes': 'Does this have a subtitle?',
				'Does this have a subtitle? Yes: an investigation': 'Does this have a subtitle?',
			};
			
			let items = await saveItemsThroughTranslator('web', Object.keys(mapping).map(title => ({
				itemType: 'book',
				title
			})));
			for (let item of items) {
				assert.equal(item.getField('shortTitle'), mapping[item.getField('title')]);
			}
		});

		it("should close supported tags when setting shortTitle", async function () {
			let mapping = {
				'Review of <i>Conflict in a Buddhist Society: Tibet under the Dalai Lamas</i>': 'Review of <i>Conflict in a Buddhist Society</i>',
				'Fearing Fear: <i>The War of the Worlds</i> and Disaster Coverage': 'Fearing Fear',
				'Review of <span class="nocase">ibn Battuta\'s Tuḥfat an-Nuẓẓār: The Origins of the Travelogue</span>': 'Review of <span class="nocase">ibn Battuta\'s Tuḥfat an-Nuẓẓār</span>',
				'Low text <sup><b>and high, bold text:</b></sup> More text': 'Low text <sup><b>and high, bold text</b></sup>',
				'<marquee>This is my <font color=red>AWESOME</font> website:</marquee>': '<marquee>This is my <font color=red>AWESOME</font> website',
				'<i><marquee>Italic AND marquee? It\'s more likely than you think</marquee></i>': '<i><marquee>Italic AND marquee?</i>',
			};

			let items = await saveItemsThroughTranslator('web', Object.keys(mapping).map(title => ({
				itemType: 'book',
				title
			})));
			for (let item of items) {
				assert.equal(item.getField('shortTitle'), mapping[item.getField('title')]);
			}
		});

		it('should accept deprecated SQL accessDates', async function () {
			let myItem = {
				"itemType":"webpage",
				"title":"Test Item",
				"accessDate":"2015-01-02 03:04:05"
			}
			let newItems = await saveItemsThroughTranslator("import", [myItem]);
			assert.equal(newItems[0].getField("accessDate"), "2015-01-02 03:04:05");
		});

		it('should save tags', async function () {
			let myItem = {
				"itemType":"book",
				"title":"Test Item",
				"tags":TEST_TAGS
			};
			checkTestTags(((await saveItemsThroughTranslator("import", [myItem])))[0]);
		});

		it('should save notes', async function () {
			let myItems = [
				{
					"itemType":"book",
					"title":"Test Item",
					"notes":[
						"1 note as string",
							{
								"note":"2 note as object",
								"tags":TEST_TAGS
							}
					]
				},
				{
					"itemType":"note",
					"note":"standalone note",
					"tags":TEST_TAGS
				}
			];

			let newItems = itemsArrayToObject(await saveItemsThroughTranslator("import", myItems));
			let noteIDs = newItems["Test Item"].getNotes();
			let note1 = await Zotero.Items.getAsync(noteIDs[0]);
			assert.equal(Zotero.ItemTypes.getName(note1.itemTypeID), "note");
			assert.equal(note1.note, "1 note as string");
			let note2 = await Zotero.Items.getAsync(noteIDs[1]);
			assert.equal(Zotero.ItemTypes.getName(note2.itemTypeID), "note");
			assert.equal(note2.note, "2 note as object");
			checkTestTags(note2);
			let note3 = newItems["standalone note"];
			assert.equal(note3.note, "standalone note");
			checkTestTags(note3);
		});
		
		it('should save relations', async function () {
			var item = await createDataObject('item');
			var itemURI = Zotero.URI.getItemURI(item);
			let myItem = {
				itemType: "book",
				title: "Test Item",
				relations: {
					"dc:relation": [itemURI]
				}
			};
			let newItems = await saveItemsThroughTranslator("import", [myItem]);
			var relations = newItems[0].getRelations();
			assert.lengthOf(Object.keys(relations), 1);
			assert.lengthOf(relations["dc:relation"], 1);
			assert.equal(relations["dc:relation"][0], itemURI);
		});
		
		it('should save collections', async function () {
			let translate = new Zotero.Translate.Import();
			translate.setString("");
			translate.setTranslator(buildDummyTranslator(4,
				'function detectWeb() {}\n'+
				'function doImport() {\n'+
				'	var item1 = new Zotero.Item("book");\n'+
				'   item1.title = "Not in Collection";\n'+
				'   item1.complete();\n'+
				'	var item2 = new Zotero.Item("book");\n'+
				'   item2.id = 1;\n'+
				'   item2.title = "In Parent Collection";\n'+
				'   item2.complete();\n'+
				'	var item3 = new Zotero.Item("book");\n'+
				'   item3.id = 2;\n'+
				'   item3.title = "In Child Collection";\n'+
				'   item3.complete();\n'+
				'	var collection = new Zotero.Collection();\n'+
				'	collection.name = "Parent Collection";\n'+
				'	collection.children = [{"id":1}, {"type":"collection", "name":"Child Collection", "children":[{"id":2}]}];\n'+
				'	collection.complete();\n'+
				'}'));
			let newItems = await translate.translate();
			assert.equal(newItems.length, 3);
			newItems = itemsArrayToObject(newItems);
			assert.equal(newItems["Not in Collection"].getCollections().length, 0);

			let parentCollection = newItems["In Parent Collection"].getCollections();
			assert.equal(parentCollection.length, 1);
			parentCollection = ((await Zotero.Collections.getAsync(parentCollection)))[0];
			assert.equal(parentCollection.name, "Parent Collection");
			assert.isTrue(parentCollection.hasChildCollections());

			let childCollection = newItems["In Child Collection"].getCollections();
			assert.equal(childCollection.length, 1);
			childCollection = ((await Zotero.Collections.getAsync(childCollection[0])));
			assert.equal(childCollection.name, "Child Collection");
			let parentChildren = parentCollection.getChildCollections();
			assert.equal(parentChildren.length, 1);
			assert.equal(parentChildren[0], childCollection);
		});

		it('import translators should save attachments', async function () {
			let emptyPDF = getTestPDF().path;
			let snapshot = getTestSnapshot().path;
			let myItems = [
				{
					"itemType":"attachment",
					"path":emptyPDF,
					"title":"Empty PDF",
					"note":"attachment note",
					"tags":TEST_TAGS
				},
				{
					"itemType":"attachment",
					"url":"http://www.zotero.org/",
					"title":"Link to zotero.org",
					"note":"attachment 2 note",
					"tags":TEST_TAGS
				}
			];
			let childAttachments = myItems.slice();
			childAttachments.push({
				"itemType":"attachment",
				"path":snapshot,
				"url":"http://www.example.com/",
				"title":"Snapshot",
				"note":"attachment 3 note",
				"tags":TEST_TAGS
			});
			myItems.push({
				"itemType":"book",
				"title":"Container Item",
				"attachments":childAttachments
			});

			let newItems = itemsArrayToObject(await saveItemsThroughTranslator("import", myItems));
			let containedAttachments = await Zotero.Items.getAsync(newItems["Container Item"].getAttachments());
			assert.equal(containedAttachments.length, 3);

			for (let savedAttachments of [[newItems["Empty PDF"], newItems["Link to zotero.org"]],
				                          [containedAttachments[0], containedAttachments[1]]]) {
				assert.equal(savedAttachments[0].getField("title"), "Empty PDF");
				assert.equal(savedAttachments[0].note, "attachment note");
				assert.equal(savedAttachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_FILE);
				checkTestTags(savedAttachments[0]);

				assert.equal(savedAttachments[1].getField("title"), "Link to zotero.org");
				assert.equal(savedAttachments[1].getField("url"), "http://www.zotero.org/");
				assert.equal(savedAttachments[1].note, "attachment 2 note");
				assert.equal(savedAttachments[1].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
				checkTestTags(savedAttachments[1]);
			}

			assert.equal(containedAttachments[2].getField("title"), "Snapshot");
			assert.equal(containedAttachments[2].getField("url"), "http://www.example.com/");
			assert.equal(containedAttachments[2].note, "attachment 3 note");
			assert.equal(containedAttachments[2].attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
			checkTestTags(containedAttachments[2]);
		});

		it('import translators should save missing snapshots as links', async function () {
			let missingFile = getTestDataDirectory();
			missingFile.append("missing");
			assert.isFalse(missingFile.exists());
			missingFile = missingFile.path;
			let myItems = [
				{
					"itemType":"book",
					"title":"Container Item",
					"attachments":[
						{
							"itemType":"attachment",
							"path":missingFile,
							"url":"http://www.example.com/",
							"title":"Snapshot with missing file",
							"note":"attachment note",
							"tags":TEST_TAGS
						}
					]
				}
			];

			let newItems = await saveItemsThroughTranslator("import", myItems);
			assert.equal(newItems.length, 1);
			assert.equal(newItems[0].getField("title"), "Container Item");
			let containedAttachments = await Zotero.Items.getAsync(newItems[0].getAttachments());
			assert.equal(containedAttachments.length, 1);

			assert.equal(containedAttachments[0].getField("title"), "Snapshot with missing file");
			assert.equal(containedAttachments[0].getField("url"), "http://www.example.com/");
			assert.equal(containedAttachments[0].note, "attachment note");
			assert.equal(containedAttachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
			checkTestTags(containedAttachments[0]);
		});

		it('import translators should ignore missing file attachments', async function () {
			let missingFile = getTestDataDirectory();
			missingFile.append("missing");
			assert.isFalse(missingFile.exists());
			missingFile = missingFile.path;
			let myItems = [
				{
					"itemType":"attachment",
					"path":missingFile,
					"title":"Missing file"
				},
				{
					"itemType":"book",
					"title":"Container Item",
					"attachments":[
						{
							"itemType":"attachment",
							"path":missingFile,
							"title":"Missing file"
						}
					]
				}
			];

			let newItems = await saveItemsThroughTranslator("import", myItems);
			assert.equal(newItems.length, 1);
			assert.equal(newItems[0].getField("title"), "Container Item");
			assert.equal(newItems[0].getAttachments().length, 0);
		});
		
		it('import translators should save link attachments', async function () {
			// Start a local server so we can make sure a web request isn't made for the URL
			var port = 16213;
			var baseURL = `http://127.0.0.1:${port}/`;
			var httpd = new HttpServer();
			httpd.start(port);
			var callCount = 0;
			var handler = function (_request, response) {
				callCount++;
				response.setStatusLine(null, 200, "OK");
				response.write("<html><head><title>Title</title><body>Body</body></html>");
			};
			httpd.registerPathHandler("/1", { handle: handler });
			httpd.registerPathHandler("/2", { handle: handler });
			
			var items = [{
				itemType: "book",
				title: "Item",
				attachments: [
					// With mimeType
					{
						itemType: "attachment",
						linkMode: Zotero.Attachments.LINK_MODE_LINKED_URL,
						title: "Link 1",
						url: baseURL + "1",
						mimeType: 'text/html'
					},
					// Without mimeType
					{
						itemType: "attachment",
						linkMode: Zotero.Attachments.LINK_MODE_LINKED_URL,
						title: "Link 2",
						url: baseURL + "2"
					}
				]
			}];
			
			var newItems = itemsArrayToObject(await saveItemsThroughTranslator("import", items));
			
			assert.equal(callCount, 0);
			
			var attachments = await Zotero.Items.getAsync(newItems.Item.getAttachments());
			assert.equal(attachments.length, 2);
			
			assert.equal(attachments[0].getField("title"), "Link 1");
			assert.equal(attachments[0].getField("url"), baseURL + "1");
			assert.equal(attachments[0].attachmentContentType, "text/html");
			assert.equal(attachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
			
			assert.equal(attachments[1].getField("title"), "Link 2");
			assert.equal(attachments[1].getField("url"), baseURL + "2");
			assert.equal(attachments[1].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
			assert.equal(attachments[1].attachmentContentType, '');
			
			await new Promise(function (resolve) {
				httpd.stop(resolve);
			});
		});
		
		it("import translators should save linked-URL attachments with savingAttachments: false", async function () {
			var json = [
				{
					itemType: "journalArticle",
					title: "Parent Item",
					attachments: [
						// snapshot: false
						{
							title: "Link",
							mimeType: "text/html",
							url: "http://example.com",
							snapshot: false
						},
						// linkMode (used by RDF import)
						{
							title: "Link",
							mimeType: "text/html",
							url: "http://example.com",
							linkMode: Zotero.Attachments.LINK_MODE_LINKED_URL
						}
					]
				}
			];
			
			var newItems = itemsArrayToObject(
				await saveItemsThroughTranslator(
					"import",
					json,
					{
						saveAttachments: false
					}
				)
			);
			var attachmentIDs = newItems["Parent Item"].getAttachments();
			assert.lengthOf(attachmentIDs, 2);
			var attachments = await Zotero.Items.getAsync(attachmentIDs);
			assert.equal(attachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
			assert.equal(attachments[1].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
		});
		
		it("import translators should save linked-file attachments with linkFiles: true", async function () {
			var testDir = getTestDataDirectory().path;
			var file1 = OS.Path.join(testDir, 'test.pdf');
			var file2 = OS.Path.join(testDir, 'test.html');
			var file2URL = "http://example.com";
			var json = [
				{
					itemType: "journalArticle",
					title: "Parent Item",
					attachments: [
						{
							title: "PDF",
							mimeType: "application/pdf",
							path: file1
						},
						{
							title: "Snapshot",
							mimeType: "text/html",
							charset: "utf-8",
							url: file2URL,
							path: file2
						}
					]
				}
			];
			
			var newItems = itemsArrayToObject(
				await saveItemsThroughTranslator(
					"import",
					json,
					{
						linkFiles: true
					}
				)
			);
			var attachmentIDs = newItems["Parent Item"].getAttachments();
			assert.lengthOf(attachmentIDs, 2);
			var attachments = await Zotero.Items.getAsync(attachmentIDs);
			assert.equal(attachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_FILE);
			assert.equal(attachments[0].attachmentContentType, 'application/pdf');
			assert.equal(attachments[1].attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_FILE);
			assert.equal(attachments[1].attachmentContentType, 'text/html');
			assert.equal(attachments[1].attachmentCharset, 'utf-8');
			assert.equal(attachments[1].note, file2URL);
		});
		
		it("import translators shouldn't save linked-file attachment with linkFiles: true if path is within current storage directory", async function () {
			var attachment = await importFileAttachment('test.png');
			var path = attachment.getFilePath();
			var json = [
				{
					itemType: "journalArticle",
					title: "Parent Item",
					attachments: [
						{
							title: "PDF",
							mimeType: "application/pdf",
							path
						}
					]
				}
			];
			
			var newItems = itemsArrayToObject(
				await saveItemsThroughTranslator(
					"import",
					json,
					{
						linkFiles: true
					}
				)
			);
			var attachmentIDs = newItems["Parent Item"].getAttachments();
			assert.lengthOf(attachmentIDs, 1);
			var attachments = await Zotero.Items.getAsync(attachmentIDs);
			assert.equal(attachments[0].attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_FILE);
			var newPath = attachments[0].getFilePath();
			assert.ok(newPath);
			assert.notEqual(newPath, path);
		});
		
		it('web translators should set accessDate to current date', async function () {
			let myItem = {
				"itemType":"webpage",
				"title":"Test Item",
				"url":"http://www.zotero.org/"
			};
			let newItems = await saveItemsThroughTranslator("web", [myItem]);
			let currentDate = new Date();
			let delta = currentDate - Zotero.Date.sqlToDate(newItems[0].getField("accessDate"), true);
			assert.isAbove(delta, -500);
			assert.isBelow(delta, 5000);
		});
		
		it('web translators should set accessDate to current date for CURRENT_TIMESTAMP', async function () {
			let myItem = {
				itemType: "webpage",
				title: "Test Item",
				url: "https://www.zotero.org/",
				accessDate: 'CURRENT_TIMESTAMP'
			};
			let newItems = await saveItemsThroughTranslator("web", [myItem]);
			let currentDate = new Date();
			let delta = currentDate - Zotero.Date.sqlToDate(newItems[0].getField("accessDate"), true);
			assert.isAbove(delta, -500);
			assert.isBelow(delta, 5000);
		});

		it('web translators should save attachments', async function () {
			let myItems = [
				{
					"itemType":"book",
					"title":"Container Item",
					"attachments":[
						{
							"url":"http://www.zotero.org/",
							"title":"Link to zotero.org",
							"note":"attachment note",
							"tags":TEST_TAGS,
							"snapshot":false
						},
						{
							"url": htmlURL,
							"title":"Test Snapshot",
							"note":"attachment 2 note",
							"tags":TEST_TAGS
						},
						{
							"url": `${serverURL}test.pdf`,
							"title":"Test PDF",
							"note":"attachment 3 note",
							"tags":TEST_TAGS
						}
					]
				}
			];

			let newItems = await saveItemsThroughTranslator("web", myItems);
			assert.equal(newItems.length, 1);
			let containedAttachments = itemsArrayToObject(await Zotero.Items.getAsync(newItems[0].getAttachments()));

			let link = containedAttachments["Link to zotero.org"];
			assert.equal(link.getField("url"), "http://www.zotero.org/");
			assert.equal(link.note, "attachment note");
			assert.equal(link.attachmentLinkMode, Zotero.Attachments.LINK_MODE_LINKED_URL);
			checkTestTags(link, true);

			let snapshot = containedAttachments["Test Snapshot"];
			assert.equal(snapshot.getField("url"), htmlURL);
			assert.equal(snapshot.note, "attachment 2 note");
			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
			assert.equal(snapshot.attachmentContentType, "text/html");
			checkTestTags(snapshot, true);

			let pdf = containedAttachments["Test PDF"];
			assert.equal(pdf.getField("url"), `${serverURL}test.pdf`);
			assert.equal(pdf.note, "attachment 3 note");
			assert.equal(pdf.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
			assert.equal(pdf.attachmentContentType, "application/pdf");
			checkTestTags(pdf, true);
		});

		it('web translators should save attachment from browser document', async function () {
			let browser = new HiddenBrowser();
			await browser.load(htmlURL);
			let doc = await browser.getDocument();

			let translate = new Zotero.Translate.Web();
			translate.setDocument(doc);
			translate.setTranslator(buildDummyTranslator(4,
				'function detectWeb() {}\n'+
				'function doWeb(doc) {\n'+
				'	var item = new Zotero.Item("book");\n'+
				'	item.title = "Container Item";\n'+
				'	item.attachments = [{\n'+
				'		"document":doc,\n'+
				'		"title":"Snapshot from Document",\n'+
				'		"note":"attachment note",\n'+
				'		"tags":'+JSON.stringify(TEST_TAGS)+'\n'+
				'	}];\n'+
				'	item.complete();\n'+
				'}'));
			let newItems = await translate.translate();
			assert.equal(newItems.length, 1);
			let containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
			assert.equal(containedAttachments.length, 1);

			let snapshot = containedAttachments[0];
			assert.equal(snapshot.getField("url"), htmlURL);
			assert.equal(snapshot.note, "attachment note");
			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
			assert.equal(snapshot.attachmentContentType, "text/html");
			checkTestTags(snapshot, true);

			browser.destroy();
		});
		
		it('web translators should save attachment from non-browser document', async function () {
			return Zotero.HTTP.processDocuments(
				htmlURL,
				async function (doc) {
					let translate = new Zotero.Translate.Web();
					translate.setDocument(doc);
					translate.setTranslator(buildDummyTranslator(4,
						'function detectWeb() {}\n'+
						'function doWeb(doc) {\n'+
						'	var item = new Zotero.Item("book");\n'+
						'	item.title = "Container Item";\n'+
						'	item.attachments = [{\n'+
						'		"document":doc,\n'+
						'		"title":"Snapshot from Document",\n'+
						'		"note":"attachment note",\n'+
						'		"tags":'+JSON.stringify(TEST_TAGS)+'\n'+
						'	}];\n'+
						'	item.complete();\n'+
						'}'));
					let newItems = await translate.translate();
					assert.equal(newItems.length, 1);
					let containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
					assert.equal(containedAttachments.length, 1);
		
					let snapshot = containedAttachments[0];
					assert.equal(snapshot.getField("url"), htmlURL);
					assert.equal(snapshot.note, "attachment note");
					assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
					assert.equal(snapshot.attachmentContentType, "text/html");
					checkTestTags(snapshot, true);
				}
			);
		});

		it('web translators should ignore attachments that return error codes', async function () {
			this.timeout(60000);
			let myItems = [
				{
					"itemType":"book",
					"title":"Container Item",
					"attachments":[
						{
							"url": `${serverURL}does_not_exist.html`,
							"title":"Non-Existent HTML"
						},
						{
							"url": `${serverURL}does_not_exist.pdf`,
							"title":"Non-Existent PDF"
						}
					]
				}
			];

			let newItems = await saveItemsThroughTranslator("web", myItems);
			assert.equal(newItems.length, 1);
			let containedAttachments = await Zotero.Items.getAsync(newItems[0].getAttachments());
			assert.equal(containedAttachments.length, 0);
		});

		it('web translators should save PDFs only if the content type matches', async function () {
			this.timeout(60000);
			let myItems = [
				{
					"itemType":"book",
					"title":"Container Item",
					"attachments":[
						{
							"url": htmlURL,
							"mimeType":"application/pdf",
							"title":"Test PDF with wrong mime type"
						},
						{
							"url": `${serverURL}test.pdf`,
							"mimeType":"application/pdf",
							"title":"Test PDF",
							"note":"attachment note",
							"tags":TEST_TAGS
						}
					]
				}
			];

			let newItems = await saveItemsThroughTranslator("web", myItems);
			assert.equal(newItems.length, 1);
			let containedAttachments = await Zotero.Items.getAsync(newItems[0].getAttachments());
			assert.equal(containedAttachments.length, 1);

			let pdf = containedAttachments[0];
			assert.equal(pdf.getField("title"), "Test PDF");
			assert.equal(pdf.getField("url"), `${serverURL}test.pdf`);
			assert.equal(pdf.note, "attachment note");
			assert.equal(pdf.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
			checkTestTags(pdf, true);
		});
		
		it('should not convert tags to canonical form in child translators', async function () {
			var childTranslator = buildDummyTranslator(1, 
				`function detectWeb() {}
				function doImport() {
					var item = new Zotero.Item;
					item.itemType = "book";
					item.title = "The Definitive Guide of Owls";
					item.tags = ['owl', 'tag'];
					item.complete();
				}`, {translatorID: 'child-dummy-translator'}
			);
			sinon.stub(Zotero.Translators, 'get').withArgs('child-dummy-translator').returns(childTranslator);
			
			var parentTranslator = buildDummyTranslator(1,
				`function detectWeb() {}
				function doImport() {
					var translator = Zotero.loadTranslator("import");
					translator.setTranslator('child-dummy-translator');
					translator.setHandler('itemDone', Zotero.childItemDone);
					translator.translate();
				}`
			);
			
			function childItemDone(obj, item) {
				// Non-canonical tags after child translator is done
				assert.deepEqual(['owl', 'tag'], item.tags);
				item.complete();
			}
			
			var translate = new Zotero.Translate.Import();
			translate.setTranslator(parentTranslator);
			translate.setString("");
			await translate._loadTranslator(parentTranslator);
			translate._sandboxManager.importObject({childItemDone});
			
			var items = await translate.translate();
			
			// Canonicalized tags after parent translator
			assert.deepEqual([{tag: 'owl'}, {tag: 'tag'}], items[0].getTags());
			
			Zotero.Translators.get.restore();
		});
		
		describe("#setExtra()", function () {
			it("should set extra field", async function () {
				let translator = buildDummyTranslator(1, 
					String.raw`function doImport() {
						var item = new Zotero.Item();
						item.itemType = "book";
						item.title = "The Ultimate Owl Guide";
						item.setExtra("Key 1", "Value 1");
						item.extra += "\nRandom junk";
						item.setExtra("Key 2", "Value 2");
						item.complete();
					}`
				);
				let translate = new Zotero.Translate.Import();
				translate.setTranslator(translator);
				translate.setString("");
				let items = await translate.translate();
				assert.lengthOf(items, 1);
				let [item] = items;
				assert.equal(item.getField("extra"), "Key 1: Value 1\nRandom junk\nKey 2: Value 2");
				assert.isUndefined(item.setExtra);
			});
			
			it("should overwrite field if already present", async function () {
				let translator = buildDummyTranslator(1,
					String.raw`function doImport() {
						var item = new Zotero.Item();
						item.itemType = "book";
						item.title = "The Ultimate Owl Guide";
						item.extra = "Random junk\nKey 1: Value 1.1";
						item.setExtra("Key 1", "Value 1.2");
						item.extra += "\nRandom junk";
						item.setExtra("Key 2", "Value 2");
						item.setExtra("Key 1", "Value 1.3");
						item.complete();
					}`
				);
				let translate = new Zotero.Translate.Import();
				translate.setTranslator(translator);
				translate.setString("");
				let items = await translate.translate();
				assert.lengthOf(items, 1);
				let [item] = items;
				assert.equal(item.getField("extra"), "Random junk\nKey 1: Value 1.3\nRandom junk\nKey 2: Value 2");
				assert.isUndefined(item.setExtra);
			});
		});
	});
	
	
	describe("#processDocuments()", function () {
		var doc;
		
		beforeEach(function* () {
			// This is the main processDocuments, not the translation sandbox one being tested
			doc = (yield Zotero.HTTP.processDocuments(htmlURL, doc => doc))[0];
		});
		
		it("should provide document object", async function () {
			var translate = new Zotero.Translate.Web();
			translate.setDocument(doc);
			translate.setTranslator(
				buildDummyTranslator(
					4,
					`function detectWeb() {}
					function doWeb(doc) {
						ZU.processDocuments(
							doc.location.href + '?t',
							function (doc) {
								var item = new Zotero.Item("book");
								item.title = "Container Item";
								// document.location
								item.url = doc.location.href;
								// document.evaluate()
								item.extra = doc
									.evaluate('//p', doc, null, XPathResult.ANY_TYPE, null)
									.iterateNext()
									.textContent;
								item.attachments = [{
									document: doc,
									title: "Snapshot from Document",
									note: "attachment note",
									tags: ${JSON.stringify(TEST_TAGS)}
								}];
								item.complete();
							}
						);
					}`
				)
			);
			var newItems = await translate.translate();
			assert.equal(newItems.length, 1);
			
			var item = newItems[0];
			assert.equal(item.getField('url'), htmlURL + '?t');
			assert.include(item.getField('extra'), 'your research sources');
			
			var containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
			assert.equal(containedAttachments.length, 1);
			
			var snapshot = containedAttachments[0];
			assert.equal(snapshot.getField("url"), htmlURL + '?t');
			assert.equal(snapshot.note, "attachment note");
			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
			assert.equal(snapshot.attachmentContentType, "text/html");
			checkTestTags(snapshot, true);
		});
		
		it("should use loaded document instead of reloading if possible", async function () {
			var translate = new Zotero.Translate.Web();
			translate.setDocument(doc);
			translate.setTranslator(
				buildDummyTranslator(
					4,
					`function detectWeb() {}
					function doWeb(doc) {
						ZU.processDocuments(
							doc.location.href,
							function (doc) {
								var item = new Zotero.Item("book");
								item.title = "Container Item";
								// document.location
								item.url = doc.location.href;
								// document.evaluate()
								item.extra = doc
									.evaluate('//p', doc, null, XPathResult.ANY_TYPE, null)
									.iterateNext()
									.textContent;
								item.attachments = [{
									document: doc,
									title: "Snapshot from Document",
									note: "attachment note",
									tags: ${JSON.stringify(TEST_TAGS)}
								}];
								item.complete();
							}
						);
					}`
				)
			);
			var newItems = await translate.translate();
			assert.equal(newItems.length, 1);
			
			var item = newItems[0];
			assert.equal(item.getField('url'), htmlURL);
			assert.include(item.getField('extra'), 'your research sources');
			
			var containedAttachments = Zotero.Items.get(newItems[0].getAttachments());
			assert.equal(containedAttachments.length, 1);
			
			var snapshot = containedAttachments[0];
			assert.equal(snapshot.getField("url"), htmlURL);
			assert.equal(snapshot.note, "attachment note");
			assert.equal(snapshot.attachmentLinkMode, Zotero.Attachments.LINK_MODE_IMPORTED_URL);
			assert.equal(snapshot.attachmentContentType, "text/html");
			checkTestTags(snapshot, true);
		});
	});
	
	
	describe("#setTranslatorProvider()", function () {
		var doc;
		
		beforeEach(function* () {
			// This is the main processDocuments, not the translation sandbox one being tested
			doc = (yield Zotero.HTTP.processDocuments(htmlURL, doc => doc))[0];
		});
		
		it("should set a custom version of Zotero.Translators", async function () {
			// Create a dummy translator to be returned by the stub methods
			var info = {
				translatorID: "e6111720-1f6c-42b0-a487-99b9fa50b8a1",
				label: "Test",
				creator: "Creator",
				target: "^" + serverURL.replace(/\//g, '\\/'),
				minVersion: "5.0",
				maxVersion: "",
				priority: 100,
				translatorType: 4,
				browserSupport: "gcsibv",
				lastUpdated: "2019-07-10 05:50:39",
				cacheCode: true
			};
			info.code = JSON.stringify(info, null, '\t') + "\n\n"
				+ "function detectWeb(doc, url) {"
				+ "return 'journalArticle';"
				+ "}\n"
				+ "function doWeb(doc, url) {"
				+ "var item = new Zotero.Item('journalArticle');"
				+ "item.title = 'Test';"
				+ "item.complete();"
				+ "}\n";
			var translator = new Zotero.Translator(info);
			
			var translate = new Zotero.Translate.Web();
			var provider = Zotero.Translators.makeTranslatorProvider({
				get: function (translatorID) {
					if (translatorID == info.translatorID) {
						return translator;
					}
					return false;
				},
				
				getAllForType: async function (type) {
					var translators = [];
					if (type == 'web') {
						translators.push(translator);
					}
					return translators;
				}
			});
			translate.setTranslatorProvider(provider);
			translate.setDocument(doc);
			var translators = await translate.getTranslators();
			translate.setTranslator(translators[0]);
			var newItems = await translate.translate();
			assert.equal(newItems.length, 1);
			
			var item = newItems[0];
			assert.equal(item.getField('title'), 'Test');
		});
		
		it("should set a custom version of Zotero.Translators in a child translator", async function () {
			// Create dummy translators to be returned by the stub methods
			var info1 = {
				translatorID: "e6111720-1f6c-42b0-a487-99b9fa50b8a1",
				label: "Test",
				creator: "Creator",
				target: "^" + serverURL.replace(/\//g, '\\/'),
				minVersion: "5.0",
				maxVersion: "",
				priority: 100,
				translatorType: 4,
				browserSupport: "gcsibv",
				lastUpdated: "2019-07-10 05:50:39",
				cacheCode: true
			};
			info1.code = JSON.stringify(info1, null, '\t') + "\n\n"
				+ "function detectWeb(doc, url) {"
					+ "return 'journalArticle';"
				+ "}\n"
				+ "function doWeb(doc, url) {"
					+ "var translator = Zotero.loadTranslator('import');"
					+ "translator.setTranslator('86e58f50-4e2d-4ee8-8a20-bafa225381fa');"
					+ "translator.setString('foo\\n');"
					+ "translator.setHandler('itemDone', function(obj, item) {"
						+ "item.complete();"
					+ "});"
					+ "translator.translate();"
				+ "}\n";
			var translator1 = new Zotero.Translator(info1);
			
			var info2 = {
				translatorID: "86e58f50-4e2d-4ee8-8a20-bafa225381fa",
				label: "Child Test",
				creator: "Creator",
				target: "",
				minVersion: "5.0",
				maxVersion: "",
				priority: 100,
				translatorType: 3,
				browserSupport: "gcsibv",
				lastUpdated: "2019-07-19 06:22:21",
				cacheCode: true
			};
			info2.code = JSON.stringify(info2, null, '\t') + "\n\n"
				+ "function detectImport() {"
					+ "return true;"
				+ "}\n"
				+ "function doImport() {"
					+ "var item = new Zotero.Item('journalArticle');"
					+ "item.title = 'Test';"
					+ "item.complete();"
				+ "}\n";
			var translator2 = new Zotero.Translator(info2);
			
			var translate = new Zotero.Translate.Web();
			var provider = Zotero.Translators.makeTranslatorProvider({
				get: function (translatorID) {
					switch (translatorID) {
						case info1.translatorID:
							return translator1;
						
						case info2.translatorID:
							return translator2;
					}
					return false;
				},
				
				getAllForType: async function (type) {
					var translators = [];
					if (type == 'web') {
						translators.push(translator1);
					}
					if (type == 'import') {
						translators.push(translator2);
					}
					return translators;
				}
			});
			translate.setTranslatorProvider(provider);
			translate.setDocument(doc);
			var translators = await translate.getTranslators();
			translate.setTranslator(translators[0]);
			var newItems = await translate.translate();
			assert.equal(newItems.length, 1);
			
			var item = newItems[0];
			assert.equal(item.getField('title'), 'Test');
		});
	});
	
	
	describe("Translators", function () {
		it("should round-trip child attachment via BibTeX", async function () {
			var item = await createDataObject('item');
			await importFileAttachment('test.png', { parentItemID: item.id });
			
			var translation = new Zotero.Translate.Export();
			var tmpDir = await getTempDirectory();
			var exportDir = OS.Path.join(tmpDir, 'export');
			translation.setLocation(Zotero.File.pathToFile(exportDir));
			translation.setItems([item]);
			translation.setTranslator("9cb70025-a888-4a29-a210-93ec52da40d4");
			translation.setDisplayOptions({
				exportFileData: true
			});
			await translation.translate();
			
			var exportFile = OS.Path.join(exportDir, 'export.bib');
			assert.isTrue(await OS.File.exists(exportFile));
			
			var translation = new Zotero.Translate.Import();
			translation.setLocation(Zotero.File.pathToFile(exportFile));
			var translators = await translation.getTranslators();
			translation.setTranslator(translators[0]);
			var importCollection = await createDataObject('collection');
			var items = await translation.translate({
				libraryID: Zotero.Libraries.userLibraryID,
				collections: [importCollection.id]
			});
			
			assert.lengthOf(items, 1);
			var attachments = items[0].getAttachments();
			assert.lengthOf(attachments, 1);
			var attachment = Zotero.Items.get(attachments[0]);
			assert.isTrue(await attachment.fileExists());
		});
		
		it("should round-trip collections via Zotero RDF", async function () {
			this.timeout(60000);
			await resetDB();
			
			var c1 = await createDataObject('collection', { name: '1' });
			var c2 = await createDataObject('collection', { name: '2', parentID: c1.id });
			var c3 = await createDataObject('collection', { name: '3', parentID: c2.id });
			var c4 = await createDataObject('collection', { name: '4', parentID: c3.id });
			var c5 = await createDataObject('collection', { name: '5', parentID: c4.id });
			// Add item, standalone note, and standalone attachment to collection
			var item = await createDataObject(
				'item',
				{ collections: [c5.id], title: Zotero.Utilities.randomString() }
			);
			var note = await createDataObject(
				'item',
				{ itemType: 'note', collections: [c5.id], note: Zotero.Utilities.randomString() }
			);
			var attachment = await importFileAttachment('test.pdf', {
				url: 'https://example.com/test.pdf',
				title: Zotero.Utilities.randomString(),
				collections: [c5.id]
			});
			
			var tmpDir = await getTempDirectory();
			var libraryExportDir = OS.Path.join(tmpDir, 'export-library');
			var libraryExportFile = OS.Path.join(libraryExportDir, 'export-library.rdf');
			var collectionExportDir = OS.Path.join(tmpDir, 'export-collection');
			var collectionExportFile = OS.Path.join(collectionExportDir, 'export-collection.rdf');
			
			// Export library
			var translation = new Zotero.Translate.Export();
			translation.setLocation(Zotero.File.pathToFile(libraryExportDir));
			translation.setLibraryID(Zotero.Libraries.userLibraryID);
			translation.setDisplayOptions({
				exportFileData: true,
				exportNotes: true
			});
			translation.setTranslator('14763d24-8ba0-45df-8f52-b8d1108e7ac9'); // Zotero RDF
			await translation.translate();
			
			// Export top-most collection
			translation = new Zotero.Translate.Export();
			translation.setLocation(Zotero.File.pathToFile(collectionExportDir));
			translation.setCollection(c1);
			translation.setDisplayOptions({
				exportFileData: true,
				exportNotes: true
			});
			translation.setTranslator('14763d24-8ba0-45df-8f52-b8d1108e7ac9'); // Zotero RDF
			await translation.translate();
			
			async function check(file, mode) {
				var collectionNames = [c1.name, c2.name, c3.name, c4.name, c5.name];
				
				var translation = new Zotero.Translate.Import();
				translation.setLocation(Zotero.File.pathToFile(file));
				var translators = await translation.getTranslators();
				translation.setTranslator(translators[0]);
				var importCollection = await createDataObject('collection');
				await translation.translate({
					libraryID: Zotero.Libraries.userLibraryID,
					collections: [importCollection.id]
				});
				
				// When exporting a library, the top-most collection should be included. When
				// exporting a collection, the selected collection isn't included, so remove it.
				if (mode == 'collection') {
					collectionNames.shift();
				}
				
				var collections = importCollection.getChildCollections();
				assert.lengthOf(collections, 1, mode);
				assert.equal(collections[0].name, collectionNames.shift(), mode)
				
				var name;
				while (name = collectionNames.shift()) {
					collections = collections[0].getChildCollections();
					assert.lengthOf(collections, 1, mode);
					let c = collections[0];
					assert.equal(c.name, name, mode);
					
					// Get the collection we imported items into
					if (name == c5.name) {
						// Make sure items were imported and added to collection
						let titles = c.getChildItems().map(x => x.getDisplayTitle());
						assert.sameMembers(titles, [item, note, attachment].map(x => x.getDisplayTitle()));
					}
					// Other collections should be empty
					else {
						assert.lengthOf(c.getChildItems(), 0);
					}
				}
			}
			
			await check(libraryExportFile, 'library');
			await check(collectionExportFile, 'collection');
		});
	});
	
	describe("Async translators", function () {
		let jsonURL;
		let notFoundURL;
		let doc;

		before(function* () {
			jsonURL = `${serverURL}test.json`
			notFoundURL = `${serverURL}does_not_exist.html`;
			setupAttachmentEndpoints();
			setupAsyncEndpoints();
			doc = (yield Zotero.HTTP.processDocuments(htmlURL, doc => doc))[0];
		});

		it('should support async detectWeb', async function () {
			var info = {
				translatorID: "e6111720-1f6c-42b0-a487-99b9fa50b8a1",
				label: "Test",
				creator: "Creator",
				target: "^" + serverURL.replace(/\//g, '\\/'),
				minVersion: "5.0",
				maxVersion: "",
				priority: 100,
				translatorType: 4,
				browserSupport: "gcsibv",
				lastUpdated: "2021-10-23 00:00:00",
				cacheCode: true
			};
			info.code = JSON.stringify(info, null, '\t') + "\n\n"
				+ `
				// asynchronous detectWeb
				async function detectWeb() {
					await doNothing();
					return 'book';
				}

				function doNothing() {
					return new Promise(resolve => resolve('nothing'));
				}

				// synchronous doWeb
				function doWeb(doc) {
					let item = new Zotero.Item('webpage');
					item.title = 'Untitled';
					item.complete();
				}
				`;
			var translator = new Zotero.Translator(info);
			
			var translate = new Zotero.Translate.Web();
			var provider = Zotero.Translators.makeTranslatorProvider({
				get: function (translatorID) {
					if (translatorID == info.translatorID) {
						return translator;
					}
					return false;
				},
				
				getAllForType: async function (type) {
					var translators = [];
					if (type == 'web') {
						translators.push(translator);
					}
					return translators;
				}
			});
			translate.setTranslatorProvider(provider);
			translate.setDocument(doc);

			var translators = await translate.getTranslators();
			assert.equal(translators.length, 1);
			assert.equal(translators[0].translatorID, info.translatorID);

			var newItems = await translate.translate();
			assert.equal(newItems.length, 1);
			assert.equal(newItems[0].getField('title'), 'Untitled');
		});

		it('should return detection result passed to Zotero.done() synchronously', async function () {
			var translator = buildDummyTranslator('web', `
				function detectWeb() {
					Zotero.done('book');
				}
			`);

			var translate = new Zotero.Translate.Web();
			translate.setDocument(doc);
			// This is the "internal hack" that Scaffold and TranslatorTester use
			translate._potentialTranslators = [translator];
			translate._foundTranslators = [];
			translate._currentState = "detect";
			let detectResult = await translate._detect();
			assert.equal(detectResult, "book");
		});

		it('should support async doWeb', async function () {
			var translate = new Zotero.Translate.Web();
			translate.setDocument(doc);
			translate.setTranslator(
				buildDummyTranslator(
					4,
					`
					function detectWeb() {}

					async function doWeb(doc) {
						let item = new Zotero.Item('webpage');

						let otherDoc = await requestDocument('${htmlURL}');
						item.title = otherDoc.title;

						let { status } = await request('${htmlURL}');
						item.abstractNote = 'Status ' + status;

						item.complete();

						let json = await requestJSON('${jsonURL}');
						if (json.success) {
							item = new Zotero.Item('webpage');
							item.title = 'JSON Test';
							item.complete();
						}
					}
					`
				)
			);
			var newItems = await translate.translate();
			assert.equal(newItems.length, 2);
			
			var item = newItems[0];
			assert.equal(item.getField('title'), 'Test');
			assert.equal(item.getField('abstractNote'), 'Status 200');

			var item = newItems[1];
			assert.equal(item.getField('title'), 'JSON Test');
		});

		it('should not fail translation on a non-200 status code', async function () {
			var translate = new Zotero.Translate.Web();
			translate.setDocument(doc);
			translate.setTranslator(
				buildDummyTranslator(
					4,
					`function detectWeb() {}

					async function doWeb(doc) {
						await request('${notFoundURL}').catch(e => {});
						let item = new Zotero.Item('webpage');
						item.title = 'Nothing';
						item.complete();
					}`
				)
			);
			var newItems = await translate.translate();
			assert.equal(newItems.length, 1);
			
			var item = newItems[0];
			assert.equal(item.getField('title'), 'Nothing');
		});
	});
	
	describe("ItemSaver", function () {
		describe("#saveCollections()", function () {
			it("should add top-level collections to specified collection", async function () {
				var collection = await createDataObject('collection');
				var collections = [
					{
						name: "Collection",
						type: "collection",
						children: []
					}
				];
				var items = [
					{
						itemType: "book",
						title: "Test"
					}
				];
				
				var translation = new Zotero.Translate.Import();
				translation.setString("");
				translation.setTranslator(buildDummyTranslator(
					"import",
					"function detectImport() {}\n"
					+ "function doImport() {\n"
					+ "	var json = JSON.parse('" + JSON.stringify(collections).replace(/['\\]/g, "\\$&") + "');\n"
					+ "	for (let o of json) {"
					+ "		var collection = new Zotero.Collection;\n"
					+ "		for (let field in o) { collection[field] = o[field]; }\n"
					+ "		collection.complete();\n"
					+ "	}\n"
					+ "	json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');\n"
					+ "	for (let o of json) {"
					+ "		var item = new Zotero.Item;\n"
					+ "		for (let field in o) { item[field] = o[field]; }\n"
					+ "		item.complete();\n"
					+ "	}\n"
					+ "}"
				));
				await translation.translate({
					collections: [collection.id]
				});
				assert.lengthOf(translation.newCollections, 1);
				assert.isNumber(translation.newCollections[0].id);
				assert.lengthOf(translation.newItems, 1);
				assert.isNumber(translation.newItems[0].id);
				var childCollections = collection.getChildCollections(true);
				assert.sameMembers(childCollections, translation.newCollections.map(c => c.id));
			});
		});
		
		describe("#_saveAttachment()", function () {
			it("should save standalone attachment to collection", async function () {
				var collection = await createDataObject('collection');
				var items = [
					{
						itemType: "attachment",
						title: "Test",
						mimeType: "text/html",
						url: "http://example.com"
					}
				];
				
				var translation = new Zotero.Translate.Import();
				translation.setString("");
				translation.setTranslator(buildDummyTranslator(
					"import",
					"function detectImport() {}\n"
					+ "function doImport() {\n"
					+ "	var json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');\n"
					+ "	for (var i=0; i<json.length; i++) {"
					+ "		var item = new Zotero.Item;\n"
					+ "		for (var field in json[i]) { item[field] = json[i][field]; }\n"
					+ "		item.complete();\n"
					+ "	}\n"
					+ "}"
				));
				await translation.translate({
					collections: [collection.id]
				});
				assert.lengthOf(translation.newItems, 1);
				assert.isNumber(translation.newItems[0].id);
				assert.ok(collection.hasItem(translation.newItems[0].id));
			});

		});
		describe('#saveItems', function () {
			it("should deproxify item and attachment urls when proxy provided", async function () {
				var itemID;
				var item = loadSampleData('journalArticle');
				item = item.journalArticle;
				item.url = 'https://www-example-com.proxy.example.com/';
				item.attachments = [{
					url: 'https://www-example-com.proxy.example.com/pdf.pdf',
					mimeType: 'application/pdf',
					title: 'Example PDF'}];
				var itemSaver = new Zotero.Translate.ItemSaver({
					libraryID: Zotero.Libraries.userLibraryID,
					attachmentMode: Zotero.Translate.ItemSaver.ATTACHMENT_MODE_FILE,
					proxy: new Zotero.Proxy({scheme: 'https://%h.proxy.example.com/%p'})
				});
				var itemDeferred = Zotero.Promise.defer();
				var attachmentDeferred = Zotero.Promise.defer();
				itemSaver.saveItems([item], async function (attachment, progressPercentage) {
					// ItemSaver returns immediately without waiting for attachments, so we use the callback
					// to test attachments
					if (progressPercentage != 100) return;
					try {
						await itemDeferred.promise;
						let item = Zotero.Items.get(itemID);
						attachment = Zotero.Items.get(item.getAttachments()[0]);
						assert.equal(attachment.getField('url'), 'https://www.example.com/pdf.pdf');
						attachmentDeferred.resolve();
					} catch (e) {
						attachmentDeferred.reject(e);
					}
				}).then(function (items) {
					try {
						assert.equal(items[0].getField('url'), 'https://www.example.com/');
						itemID = items[0].id;
						itemDeferred.resolve();
					} catch (e) {
						itemDeferred.reject(e);
					}
				});
				await Promise.all([itemDeferred.promise, attachmentDeferred.promise]);
			});
		});
	});
	
	
	describe("Error Handling", function () {
		it("should propagate saveItems() errors from synchronous doImport()", async function () {
			var items = [
				{
					// Invalid object
				},
				{
					itemType: "book",
					title: "B"
				}
			];
			
			var added = 0;
			var notifierID = Zotero.Notifier.registerObserver({
				notify: function (event, type, ids, extraData) {
					added++;
				}
			}, ['item']);
			
			var translation = new Zotero.Translate.Import();
			translation.setString("");
			translation.setTranslator(buildDummyTranslator(
				"import",
				"function detectImport() {}"
				+ "function doImport() {"
				+ "	var json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');"
				+ "	for (let o of json) {"
				+ "		let item = new Zotero.Item;"
				+ "		for (let field in o) { item[field] = o[field]; }"
				+ "		item.complete();"
				+ "	}"
				+ "}"
			));
			var e = await getPromiseError(translation.translate());
			Zotero.Notifier.unregisterObserver(notifierID);
			assert.ok(e);
			
			// Saving should be stopped without any saved items
			assert.equal(added, 0);
			assert.equal(translation._savingItems, 0);
			assert.equal(translation._runningAsyncProcesses, 0);
			assert.isNull(translation._currentState);
		});
		
		it("should propagate saveItems() errors from asynchronous doImport()", async function () {
			var items = [
				{
					// Invalid object
				},
				{
					itemType: "book",
					title: "B"
				}
			];
			
			var added = 0;
			var notifierID = Zotero.Notifier.registerObserver({
				notify: function (event, type, ids, extraData) {
					added++;
				}
			}, ['item']);
			
			var translation = new Zotero.Translate.Import();
			translation.setString("");
			translation.setTranslator(buildDummyTranslator(
				"import",
				"function detectImport() {}"
					+ "function doImport() {"
					+ "	var json = JSON.parse('" + JSON.stringify(items).replace(/['\\]/g, "\\$&") + "');"
					+ "	return new Promise(function (resolve, reject) {"
					+ "		function next() {"
					+ "			var data = json.shift();"
					+ "			if (!data) {"
					+ "				resolve();"
					+ "				return;"
					+ "			}"
					+ "			var item = new Zotero.Item;"
					+ "			for (let field in data) { item[field] = data[field]; }"
					+ "			item.complete().then(next).catch(reject);"
					+ "		}"
					+ "		next();"
					+ "	});"
					+ "}",
				{
					configOptions: {
						async: true
					}
				}
			));
			var e = await getPromiseError(translation.translate());
			Zotero.Notifier.unregisterObserver(notifierID);
			assert.ok(e);
			
			// Saving should be stopped without any saved items
			assert.equal(added, 0);
			assert.equal(translation._savingItems, 0);
			assert.equal(translation._runningAsyncProcesses, 0);
			assert.isNull(translation._currentState);
		});
		
		it("should propagate errors from saveItems with synchronous doSearch()", async function () {
			var stub = sinon.stub(Zotero.Translate.ItemSaver.prototype, "saveItems");
			stub.returns(Promise.reject(new Error("Save error")));
			
			var translation = new Zotero.Translate.Search();
			translation.setTranslator(buildDummyTranslator(
				"search",
				"function detectSearch() {}"
					+ "function doSearch() {"
					+ "	var item = new Zotero.Item('journalArticle');"
					+ "	item.itemType = 'book';"
					+ "	item.title = 'A';"
					+ "	item.complete();"
					+ "}"
			));
			translation.setSearch({ itemType: "journalArticle", DOI: "10.111/Test"});
			var e = await getPromiseError(translation.translate({
				libraryID: Zotero.Libraries.userLibraryID,
				saveAttachments: false
			}));
			assert.ok(e);
			
			stub.restore();
		});
		
		it("should propagate errors from saveItems() with asynchronous doSearch()", async function () {
			var stub = sinon.stub(Zotero.Translate.ItemSaver.prototype, "saveItems");
			stub.returns(Promise.reject(new Error("Save error")));
			
			var translation = new Zotero.Translate.Search();
			translation.setTranslator(buildDummyTranslator(
				"search",
				"function detectSearch() {}"
					+ "function doSearch() {"
					+ "	var item = new Zotero.Item('journalArticle');"
					+ "	item.itemType = 'book';"
					+ "	item.title = 'A';"
					+ "	return new Promise(function (resolve, reject) {"
					+ "		item.complete().then(next).catch(reject);"
					+ "	});"
					+ "}",
				{
					configOptions: {
						async: true
					}
				}
			));
			translation.setSearch({ itemType: "journalArticle", DOI: "10.111/Test"});
			var e = await getPromiseError(translation.translate({
				libraryID: Zotero.Libraries.userLibraryID,
				saveAttachments: false
			}));
			assert.ok(e);
			
			stub.restore();
		});
	});
});

describe("Zotero.Translate.ItemGetter", function () {
	describe("nextItem", function () {
		it('should return false for an empty database', async function () {
			let getter = new Zotero.Translate.ItemGetter();
			assert.isFalse(getter.nextItem());
		});
		it('should return items in order they are supplied', async function () {
			let getter = new Zotero.Translate.ItemGetter();
			let items, itemIDs, itemURIs;

			await Zotero.DB.executeTransaction(async function () {
				items = [
					await new Zotero.Item('journalArticle'),
					await new Zotero.Item('book')
				];
				
				itemIDs = [ await items[0].save(), await items[1].save() ];
				itemURIs = items.map(i => Zotero.URI.getItemURI(i));
			});
			
			getter._itemsLeft = items;
			
			assert.equal((getter.nextItem()).uri, itemURIs[0], 'first item comes out first');
			assert.equal((getter.nextItem()).uri, itemURIs[1], 'second item comes out second');
			assert.isFalse((getter.nextItem()), 'end of item queue');
		});
		it('should return items with tags in expected format', async function () {
			let getter = new Zotero.Translate.ItemGetter();
			let itemWithAutomaticTag, itemWithManualTag, itemWithMultipleTags
			
			await Zotero.DB.executeTransaction(async function () {
				itemWithAutomaticTag = new Zotero.Item('journalArticle');
				itemWithAutomaticTag.addTag('automatic tag', 0);
				await itemWithAutomaticTag.save();
				
				itemWithManualTag = new Zotero.Item('journalArticle');
				itemWithManualTag.addTag('manual tag', 1);
				await itemWithManualTag.save();
				
				itemWithMultipleTags = new Zotero.Item('journalArticle');
				itemWithMultipleTags.addTag('tag1', 0);
				itemWithMultipleTags.addTag('tag2', 1);
				await itemWithMultipleTags.save();
			});
			
			let legacyMode = [false, true];
			for (let i=0; i<legacyMode.length; i++) {
				getter._itemsLeft = [itemWithAutomaticTag, itemWithManualTag, itemWithMultipleTags];
				getter.legacy = legacyMode[i];
				let suffix = legacyMode[i] ? ' in legacy mode' : '';
				
				// itemWithAutomaticTag
				let translatorItem = getter.nextItem();
				assert.isArray(translatorItem.tags, 'item contains automatic tags in an array' + suffix);
				assert.isObject(translatorItem.tags[0], 'automatic tag is an object' + suffix);
				assert.equal(translatorItem.tags[0].tag, 'automatic tag', 'automatic tag name provided as "tag" property' + suffix);
				if (legacyMode[i]) {
					assert.equal(translatorItem.tags[0].type, 0, 'automatic tag "type" is 0' + suffix);
				} else {
					assert.isUndefined(translatorItem.tags[0].type, '"type" is undefined for automatic tag' + suffix);
				}
				
				// itemWithManualTag
				translatorItem = getter.nextItem();
				assert.isArray(translatorItem.tags, 'item contains manual tags in an array' + suffix);
				assert.isObject(translatorItem.tags[0], 'manual tag is an object' + suffix);
				assert.equal(translatorItem.tags[0].tag, 'manual tag', 'manual tag name provided as "tag" property' + suffix);
				assert.equal(translatorItem.tags[0].type, 1, 'manual tag "type" is 1' + suffix);
				
				// itemWithMultipleTags
				translatorItem = getter.nextItem();
				assert.isArray(translatorItem.tags, 'item contains multiple tags in an array' + suffix);
				assert.lengthOf(translatorItem.tags, 2, 'expected number of tags returned' + suffix);
			}
		});
		it('should return item collections in expected format', async function () {
			let getter = new Zotero.Translate.ItemGetter();
			let items, collections;
			
			await Zotero.DB.executeTransaction(async function () {
				items = getter._itemsLeft = [
					new Zotero.Item('journalArticle'), // Not in collection
					new Zotero.Item('journalArticle'), // In a single collection
					new Zotero.Item('journalArticle'), //In two collections
					new Zotero.Item('journalArticle') // In a nested collection
				];
				await Promise.all(items.map(item => item.save()));
				
				collections = [
					new Zotero.Collection,
					new Zotero.Collection,
					new Zotero.Collection,
					new Zotero.Collection
				];
				collections[0].name = "test1";
				collections[1].name = "test2";
				collections[2].name = "subTest1";
				collections[3].name = "subTest2";
				await collections[0].save();
				await collections[1].save();
				collections[2].parentID = collections[0].id;
				collections[3].parentID = collections[1].id;
				await collections[2].save();
				await collections[3].save();
				
				await collections[0].addItems([items[1].id, items[2].id]);
				await collections[1].addItem(items[2].id);
				await collections[2].addItem(items[3].id);
			});
			
			let translatorItem = getter.nextItem();
			assert.isArray(translatorItem.collections, 'item in library root has a collections array');
			assert.equal(translatorItem.collections.length, 0, 'item in library root does not list any collections');
			
			translatorItem = getter.nextItem();
			assert.isArray(translatorItem.collections, 'item in a single collection has a collections array');
			assert.equal(translatorItem.collections.length, 1, 'item in a single collection lists one collection');
			assert.equal(translatorItem.collections[0], collections[0].key, 'item in a single collection identifies correct collection');
			
			translatorItem = getter.nextItem();
			assert.isArray(translatorItem.collections, 'item in two collections has a collections array');
			assert.equal(translatorItem.collections.length, 2, 'item in two collections lists two collections');
			assert.deepEqual(
				translatorItem.collections.sort(),
				[collections[0].key, collections[1].key].sort(),
				'item in two collections identifies correct collections'
			);
			
			translatorItem = getter.nextItem();
			assert.isArray(translatorItem.collections, 'item in a nested collection has a collections array');
			assert.equal(translatorItem.collections.length, 1, 'item in a single nested collection lists one collection');
			assert.equal(translatorItem.collections[0], collections[2].key, 'item in a single collection identifies correct collection');
		});
		
		it('should return item relations in expected format', async function () {
			let getter = new Zotero.Translate.ItemGetter();
			let items;
			
			await Zotero.DB.executeTransaction(async function () {
					items = [
						new Zotero.Item('journalArticle'), // Item with no relations
						
						new Zotero.Item('journalArticle'), // Bidirectional relations
						new Zotero.Item('journalArticle'), // between these items
						
						new Zotero.Item('journalArticle'), // This item is related to two items below
						new Zotero.Item('journalArticle'), // But this item is not related to the item below
						new Zotero.Item('journalArticle')
					];
					await Promise.all(items.map(item => item.save()));
					
					await items[1].addRelatedItem(items[2]);
					await items[2].addRelatedItem(items[1]);
					
					await items[3].addRelatedItem(items[4]);
					await items[4].addRelatedItem(items[3]);
					await items[3].addRelatedItem(items[5]);
					await items[5].addRelatedItem(items[3]);
			});
			
			getter._itemsLeft = items.slice();
			
			let translatorItem = getter.nextItem();
			assert.isObject(translatorItem.relations, 'item with no relations has a relations object');
			assert.equal(Object.keys(translatorItem.relations).length, 0, 'item with no relations does not list any relations');
			
			translatorItem = getter.nextItem();
			
			assert.isObject(translatorItem.relations, 'item that is the subject of a single relation has a relations object');
			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the subject of a single relation lists one relations predicate');
			assert.lengthOf(translatorItem.relations['dc:relation'], 1, 'item that is the subject of a single relation lists one "dc:relation" object');
			assert.equal(translatorItem.relations['dc:relation'][0], Zotero.URI.getItemURI(items[2]), 'item that is the subject of a single relation identifies correct object URI');
			
			// We currently assign these bidirectionally above, so this is a bit redundant
			translatorItem = getter.nextItem();
			assert.isObject(translatorItem.relations, 'item that is the object of a single relation has a relations object');
			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the object of a single relation list one relations predicate');
			assert.lengthOf(translatorItem.relations['dc:relation'], 1, 'item that is the object of a single relation lists one "dc:relation" object');
			assert.equal(translatorItem.relations['dc:relation'][0], Zotero.URI.getItemURI(items[1]), 'item that is the object of a single relation identifies correct subject URI');
			
			translatorItem = getter.nextItem();
			assert.isObject(translatorItem.relations, 'item that is the subject of two relations has a relations object');
			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the subject of two relations list one relations predicate');
			assert.isDefined(translatorItem.relations['dc:relation'], 'item that is the subject of two relations uses "dc:relation" as the predicate');
			assert.isArray(translatorItem.relations['dc:relation'], 'item that is the subject of two relations lists "dc:relation" object as an array');
			assert.equal(translatorItem.relations['dc:relation'].length, 2, 'item that is the subject of two relations lists two relations in the "dc:relation" array');
			assert.deepEqual(
				translatorItem.relations['dc:relation'].sort(),
				[Zotero.URI.getItemURI(items[4]), Zotero.URI.getItemURI(items[5])].sort(),
				'item that is the subject of two relations identifies correct object URIs'
			);
			
			translatorItem = getter.nextItem();
			assert.isObject(translatorItem.relations, 'item that is the object of one relation from item with two relations has a relations object');
			assert.equal(Object.keys(translatorItem.relations).length, 1, 'item that is the object of one relation from item with two relations list one relations predicate');
			assert.isDefined(translatorItem.relations['dc:relation'], 'item that is the object of one relation from item with two relations uses "dc:relation" as the predicate');
			assert.lengthOf(translatorItem.relations['dc:relation'], 1, 'item that is the object of one relation from item with two relations lists one "dc:relation" object');
			assert.equal(translatorItem.relations['dc:relation'][0], Zotero.URI.getItemURI(items[3]), 'item that is the object of one relation from item with two relations identifies correct subject URI');
		});
		
		it('should return standalone note in expected format', async function () {
			let relatedItem, note, collection;
			
			await Zotero.DB.executeTransaction(async function () {
				relatedItem = new Zotero.Item('journalArticle');
				await relatedItem.save();

				note = new Zotero.Item('note');
				note.setNote('Note');
				note.addTag('automaticTag', 0);
				note.addTag('manualTag', 1);
				note.addRelatedItem(relatedItem);
				await note.save();
				
				relatedItem.addRelatedItem(note);
				await relatedItem.save();
				
				collection = new Zotero.Collection;
				collection.name = 'test';
				await collection.save();
				await collection.addItem(note.id);
			});
			
			let legacyMode = [false, true];
			for (let i=0; i<legacyMode.length; i++) {
				let getter = new Zotero.Translate.ItemGetter();
				getter._itemsLeft = [note];
				let legacy = getter.legacy = legacyMode[i];
				let suffix = legacy ? ' in legacy mode' : '';
				
				let translatorNote = getter.nextItem();
				assert.isDefined(translatorNote, 'returns standalone note' + suffix);
				assert.equal(translatorNote.itemType, 'note', 'itemType is correct' + suffix);
				assert.equal(translatorNote.note, 'Note', 'note is correct' + suffix);
				
				assert.isString(translatorNote.dateAdded, 'dateAdded is string' + suffix);
				assert.isString(translatorNote.dateModified, 'dateModified is string' + suffix);
				
				if (legacy) {
					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
					
					assert.isNumber(translatorNote.itemID, 'itemID is set' + suffix);
					assert.isString(translatorNote.key, 'key is set' + suffix);
				} else {
					assert.isTrue(isoDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
					assert.isTrue(isoDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
				}
				
				// Tags
				assert.isArray(translatorNote.tags, 'contains tags as array' + suffix);
				assert.equal(translatorNote.tags.length, 2, 'contains correct number of tags' + suffix);
				let possibleTags = [
					{ tag: 'automaticTag', type: 0 },
					{ tag: 'manualTag', type: 1 }
				];
				for (let i=0; i<possibleTags.length; i++) {
					let match = false;
					for (let j=0; j<translatorNote.tags.length; j++) {
						if (possibleTags[i].tag == translatorNote.tags[j].tag) {
							let type = possibleTags[i].type;
							if (!legacy && type == 0) type = undefined;
							
							assert.equal(translatorNote.tags[j].type, type, possibleTags[i].tag + ' tag is correct' + suffix);
							match = true;
							break;
						}
					}
					assert.isTrue(match, 'has ' + possibleTags[i].tag + ' tag ' + suffix);
				}
				
				// Relations
				assert.isObject(translatorNote.relations, 'has relations as object' + suffix);
				assert.lengthOf(translatorNote.relations['dc:relation'], 1, 'has one relation' + suffix);
				assert.equal(translatorNote.relations['dc:relation'][0], Zotero.URI.getItemURI(relatedItem), 'relation is correct' + suffix);
				
				if (!legacy) {
					// Collections
					assert.isArray(translatorNote.collections, 'has a collections array' + suffix);
					assert.equal(translatorNote.collections.length, 1, 'lists one collection' + suffix);
					assert.equal(translatorNote.collections[0], collection.key, 'identifies correct collection' + suffix);
				}
			}
		});
		it('should return attached note in expected format', async function () {
			let relatedItem, items, collection, note;
			await Zotero.DB.executeTransaction(async function () {
				relatedItem = new Zotero.Item('journalArticle');
				await relatedItem.save();
				
				items = [
					new Zotero.Item('journalArticle'),
					new Zotero.Item('journalArticle')
				];
				await Promise.all(items.map(item => item.save()));
				
				collection = new Zotero.Collection;
				collection.name = 'test';
				await collection.save();
				await collection.addItem(items[0].id);
				await collection.addItem(items[1].id);
				
				note = new Zotero.Item('note');
				note.setNote('Note');
				note.addTag('automaticTag', 0);
				note.addTag('manualTag', 1);
				await note.save();
				
				note.addRelatedItem(relatedItem);
				relatedItem.addRelatedItem(note);
				await note.save();
				await relatedItem.save();
			});
			
			let legacyMode = [false, true];
			for (let i=0; i<legacyMode.length; i++) {
				let item = items[i];
				
				let getter = new Zotero.Translate.ItemGetter();
				getter._itemsLeft = [item];
				let legacy = getter.legacy = legacyMode[i];
				let suffix = legacy ? ' in legacy mode' : '';
				
				let translatorItem = getter.nextItem();
				assert.isArray(translatorItem.notes, 'item with no notes contains notes array' + suffix);
				assert.equal(translatorItem.notes.length, 0, 'item with no notes contains empty notes array' + suffix);
				
				note.parentID = item.id;
				await note.saveTx();
				
				getter = new Zotero.Translate.ItemGetter();
				getter._itemsLeft = [item];
				getter.legacy = legacy;
				
				translatorItem = getter.nextItem();
				assert.isArray(translatorItem.notes, 'item with no notes contains notes array' + suffix);
				assert.equal(translatorItem.notes.length, 1, 'item with one note contains array with one note' + suffix);
				
				let translatorNote = translatorItem.notes[0];
				assert.equal(translatorNote.itemType, 'note', 'itemType is correct' + suffix);
				assert.equal(translatorNote.note, 'Note', 'note is correct' + suffix);
				
				assert.isString(translatorNote.dateAdded, 'dateAdded is string' + suffix);
				assert.isString(translatorNote.dateModified, 'dateModified is string' + suffix);
				
				if (legacy) {
					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
					assert.isTrue(sqlDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
					
					assert.isNumber(translatorNote.itemID, 'itemID is set' + suffix);
					assert.isString(translatorNote.key, 'key is set' + suffix);
				} else {
					assert.isTrue(isoDateTimeRe.test(translatorNote.dateAdded), 'dateAdded is in correct format' + suffix);
					assert.isTrue(isoDateTimeRe.test(translatorNote.dateModified), 'dateModified is in correct format' + suffix);
				}
				
				// Tags
				assert.isArray(translatorNote.tags, 'contains tags as array' + suffix);
				assert.equal(translatorNote.tags.length, 2, 'contains correct number of tags' + suffix);
				let possibleTags = [
					{ tag: 'automaticTag', type: 0 },
					{ tag: 'manualTag', type: 1 }
				];
				for (let i=0; i<possibleTags.length; i++) {
					let match = false;
					for (let j=0; j<translatorNote.tags.length; j++) {
						if (possibleTags[i].tag == translatorNote.tags[j].tag) {
							let type = possibleTags[i].type;
							if (!legacy && type == 0) type = undefined;
							
							assert.equal(translatorNote.tags[j].type, type, possibleTags[i].tag + ' tag is correct' + suffix);
							match = true;
							break;
						}
					}
					assert.isTrue(match, 'has ' + possibleTags[i].tag + ' tag ' + suffix);
				}
				
				// Relations
				assert.isObject(translatorNote.relations, 'has relations as object' + suffix);
				assert.lengthOf(translatorNote.relations['dc:relation'], 1, 'has one relation' + suffix);
				assert.equal(translatorNote.relations['dc:relation'][0], Zotero.URI.getItemURI(relatedItem), 'relation is correct' + suffix);
				
				if (!legacy) {
					// Collections
					assert.isUndefined(translatorNote.collections, 'has no collections array' + suffix);
				}
			}
		});
		
		it('should return stored/linked file and URI attachments in expected format', async function () {
			this.timeout(60000);
			let file = getTestPDF();
			let item, relatedItem;
			
			await Zotero.DB.executeTransaction(async function () {
				item = new Zotero.Item('journalArticle');
				await item.save();
				relatedItem = new Zotero.Item('journalArticle');
				await relatedItem.save();
			});

			// Attachment items
			let attachments = [
				await Zotero.Attachments.importFromFile({"file":file}), // Standalone stored file
				await Zotero.Attachments.linkFromFile({"file":file}), // Standalone link to file
				await Zotero.Attachments.importFromFile({"file":file, "parentItemID":item.id}), // Attached stored file
				await Zotero.Attachments.linkFromFile({"file":file, "parentItemID":item.id}), // Attached link to file
				await Zotero.Attachments.linkFromURL({"url":'http://example.com', "parentItemID":item.id, "contentType":'application/pdf', "title":'empty'}) // Attached link to URL
			];
			
			await Zotero.DB.executeTransaction(async function () {
				// Make sure all fields are populated
				for (let i=0; i<attachments.length; i++) {
					let attachment = attachments[i];
					attachment.setField('accessDate', '2001-02-03 12:13:14');
					attachment.attachmentCharset = 'utf-8';
					attachment.setField('url', 'http://example.com');
					attachment.setNote('note');
				
					attachment.addTag('automaticTag', 0);
					attachment.addTag('manualTag', 1);
					
					attachment.addRelatedItem(relatedItem);
					
					await attachment.save();
					
					relatedItem.addRelatedItem(attachment);
				}
				
				await relatedItem.save();
			});
			
			let items = [ attachments[0], attachments[1], item ]; // Standalone attachments and item with child attachments
			
			// Run tests
			let legacyMode = [false, true];
			for (let i=0; i<legacyMode.length; i++) {
				let getter = new Zotero.Translate.ItemGetter();
				getter._itemsLeft = items.slice();
				
				let exportDir = await getTempDirectory();
				getter._exportFileDirectory = Zotero.File.pathToFile(exportDir);
				
				let legacy = getter.legacy = legacyMode[i];
				let suffix = legacy ? ' in legacy mode' : '';
				
				// Gather all standalone and child attachments into a single array,
				// since tests are mostly the same
				let translatorAttachments = [], translatorItem;
				let itemsLeft = items.length, attachmentsLeft = attachments.length;
				while (translatorItem = getter.nextItem()) {
					assert.isString(translatorItem.itemType, 'itemType is set' + suffix);
					
					// Standalone attachments
					if (translatorItem.itemType == 'attachment') {
						translatorAttachments.push({
							child: false,
							attachment: translatorItem
						});
						attachmentsLeft--;
					
					// Child attachments
					} else if (translatorItem.itemType == 'journalArticle') {
						assert.isArray(translatorItem.attachments, 'item contains attachment array' + suffix);
						assert.equal(translatorItem.attachments.length, 3, 'attachment array contains all items' + suffix);
						
						for (let i=0; i<translatorItem.attachments.length; i++) {
							let attachment = translatorItem.attachments[i];
							assert.equal(attachment.itemType, 'attachment', 'item attachment is of itemType "attachment"' + suffix);
							
							translatorAttachments.push({
								child: true,
								attachment: attachment
							});
							
							attachmentsLeft--;
						}
					
					// Unexpected
					} else {
						assert.fail(translatorItem.itemType, 'attachment or journalArticle', 'expected itemType returned');
					}
					
					itemsLeft--;
				}
				
				assert.equal(itemsLeft, 0, 'all items returned by getter');
				assert.equal(attachmentsLeft, 0, 'all attachments returned by getter');
				
				// Since we make no guarantees on the order of child attachments,
				// we have to rely on URI as the identifier
				let uriMap = {};
				for (let i=0; i<attachments.length; i++) {
					uriMap[Zotero.URI.getItemURI(attachments[i])] = attachments[i];
				}
				
				for (let j=0; j<translatorAttachments.length; j++) {
					let childAttachment = translatorAttachments[j].child;
					let attachment = translatorAttachments[j].attachment;
					assert.isString(attachment.uri, 'uri is set' + suffix);
					
					let zoteroItem = uriMap[attachment.uri];
					assert.isDefined(zoteroItem, 'uri is correct' + suffix);
					delete uriMap[attachment.uri];
					
					let storedFile = zoteroItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE
						|| zoteroItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL;
					let linkToURL = zoteroItem.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL;
					
					let prefix = (childAttachment ? 'attached ' : '')
						+ (storedFile ? 'stored ' : 'link to ')
						+ (linkToURL ? 'URL ' : 'file ');
					
					// Set fields
					assert.equal(attachment.itemType, 'attachment', prefix + 'itemType is correct' + suffix);
					assert.include([Zotero.getString('file-type-pdf'), 'empty'], attachment.title, prefix + 'title is correct' + suffix);
					assert.equal(attachment.url, 'http://example.com', prefix + 'url is correct' + suffix);
					assert.equal(attachment.note, 'note', prefix + 'note is correct' + suffix);
					
					// Automatically set fields
					assert.isString(attachment.dateAdded, prefix + 'dateAdded is set' + suffix);
					assert.isString(attachment.dateModified, prefix + 'dateModified is set' + suffix);
					
					// Legacy mode fields
					if (legacy) {
						assert.isNumber(attachment.itemID, prefix + 'itemID is set' + suffix);
						assert.isString(attachment.key, prefix + 'key is set' + suffix);
						assert.equal(attachment.mimeType, 'application/pdf', prefix + 'mimeType is correct' + suffix);
						
						assert.equal(attachment.accessDate, '2001-02-03 12:13:14', prefix + 'accessDate is correct' + suffix);
						
						assert.isTrue(sqlDateTimeRe.test(attachment.dateAdded), prefix + 'dateAdded matches SQL format' + suffix);
						assert.isTrue(sqlDateTimeRe.test(attachment.dateModified), prefix + 'dateModified matches SQL format' + suffix);
					} else {
						assert.equal(attachment.contentType, 'application/pdf', prefix + 'contentType is correct' + suffix);
						
						assert.equal(attachment.accessDate, '2001-02-03T12:13:14Z', prefix + 'accessDate is correct' + suffix);
						
						assert.isTrue(isoDateTimeRe.test(attachment.dateAdded), prefix + 'dateAdded matches ISO-8601 format' + suffix);
						assert.isTrue(isoDateTimeRe.test(attachment.dateModified), prefix + 'dateModified matches ISO-8601 format' + suffix);
					}
					
					if (!linkToURL) {
						// localPath
						assert.isString(attachment.localPath, prefix + 'localPath is set' + suffix);
						let attachmentFile = Zotero.File.pathToFile(attachment.localPath);
						assert.isTrue(attachmentFile.exists(), prefix + 'localPath points to a file' + suffix);
						assert.equal(attachmentFile.spec, zoteroItem.getFile().spec, prefix + 'localPath points to the correct file' + suffix);
						
						assert.equal(attachment.filename, 'empty.pdf', prefix + 'filename is correct' + suffix);
						assert.equal(attachment.defaultPath, 'files/' + zoteroItem.id + '/' + attachment.filename, prefix + 'defaultPath is correct' + suffix);
						
						// saveFile function
						assert.isFunction(attachment.saveFile, prefix + 'has saveFile function' + suffix);
						await attachment.saveFile(attachment.defaultPath);
						assert.equal(attachment.path, OS.Path.join(exportDir, OS.Path.normalize(attachment.defaultPath)), prefix + 'path is set correctly after saveFile call' + suffix);
						
						let fileExists = await OS.File.exists(attachment.path);
						assert.isTrue(fileExists, prefix + 'file was copied to the correct path by saveFile function' + suffix);
						fileExists = await OS.File.exists(attachment.localPath);
						assert.isTrue(fileExists, prefix + 'file was not removed from original location' + suffix);
						
						let e;
						e = await getPromiseError(attachment.saveFile(attachment.defaultPath));
						assert.match(e.message, /^ERROR_FILE_EXISTS /, prefix + 'saveFile does not overwrite existing file by default' + suffix);
						e = await getPromiseError(attachment.saveFile('file/../../'));
						assert.match(e.message, /./, prefix + 'saveFile does not allow exporting outside export directory' + suffix);
						/** TODO: check if overwriting existing file works **/
					}
					
					// Tags
					assert.isArray(attachment.tags, prefix + 'contains tags as array' + suffix);
					assert.equal(attachment.tags.length, 2, prefix + 'contains correct number of tags' + suffix);
					let possibleTags = [
						{ tag: 'automaticTag', type: 0 },
						{ tag: 'manualTag', type: 1 }
					];
					for (let i=0; i<possibleTags.length; i++) {
						let match = false;
						for (let j=0; j<attachment.tags.length; j++) {
							if (possibleTags[i].tag == attachment.tags[j].tag) {
								let type = possibleTags[i].type;
								if (!legacy && type == 0) type = undefined;
								
								assert.equal(attachment.tags[j].type, type, prefix + possibleTags[i].tag + ' tag is correct' + suffix);
								match = true;
								break;
							}
						}
						assert.isTrue(match, prefix + ' has ' + possibleTags[i].tag + ' tag ' + suffix);
					}
					
					// Relations
					assert.isObject(attachment.relations, prefix + 'has relations as object' + suffix);
					assert.lengthOf(attachment.relations['dc:relation'], 1, prefix + 'has one relation' + suffix);
					assert.equal(attachment.relations['dc:relation'][0], Zotero.URI.getItemURI(relatedItem), prefix + 'relation is correct' + suffix);
					/** TODO: test other relations and multiple relations per predicate (should be an array) **/
				}
			}
		});
	});
	
	describe("#setCollection()", function () {
		it("should add collection items", async function () {
			var col = await createDataObject('collection');
			var item1 = await createDataObject('item', { collections: [col.id] });
			var item2 = await createDataObject('item', { collections: [col.id] });
			var item3 = await createDataObject('item');
			
			let getter = new Zotero.Translate.ItemGetter();
			getter.setCollection(col);
			
			assert.equal(getter.numItems, 2);
		});
	});
	
	describe("#setAll()", function () {
		it("should exclude annotations", async function () {
			var attachment = await importFileAttachment('test.pdf');
			var annotation = await createAnnotation('highlight', attachment);
			
			var getter = new Zotero.Translate.ItemGetter();
			await getter.setAll(attachment.libraryID, false);
			
			var item;
			while (item = getter.nextItem()) {
				if (item.itemType == 'annotation') {
					assert.fail("Annotation item should not be returned from nextItem()");
					break;
				}
			}
		});
	});
	
	describe("#_attachmentToArray()", function () {
		it("should handle missing attachment files", async function () {
			var item = await importFileAttachment('test.png');
			var path = item.getFilePath();
			// Delete attachment file
			await OS.File.remove(path);
			
			var translation = new Zotero.Translate.Export();
			var tmpDir = await getTempDirectory();
			var exportDir = OS.Path.join(tmpDir, 'export');
			translation.setLocation(Zotero.File.pathToFile(exportDir));
			translation.setItems([item]);
			translation.setTranslator('14763d24-8ba0-45df-8f52-b8d1108e7ac9'); // Zotero RDF
			translation.setDisplayOptions({
				exportFileData: true
			});
			await translation.translate();
			
			var exportFile = OS.Path.join(exportDir, 'export.rdf');
			assert.isAbove(((await OS.File.stat(exportFile))).size, 0);
		});
		
		it("should handle empty attachment path", async function () {
			var item = await importFileAttachment('test.png');
			item._attachmentPath = '';
			assert.equal(item.attachmentPath, '');
			
			var translation = new Zotero.Translate.Export();
			var tmpDir = await getTempDirectory();
			var exportDir = OS.Path.join(tmpDir, 'export');
			translation.setLocation(Zotero.File.pathToFile(exportDir));
			translation.setItems([item]);
			translation.setTranslator('14763d24-8ba0-45df-8f52-b8d1108e7ac9'); // Zotero RDF
			translation.setDisplayOptions({
				exportFileData: true
			});
			await translation.translate();
			
			var exportFile = OS.Path.join(exportDir, 'export.rdf');
			assert.isAbove(((await OS.File.stat(exportFile))).size, 0);
		});
		
		it("should handle UNC paths", async function () {
			var path = '\\\\SHARE\\test.png';
			var attachment = await Zotero.Attachments.linkFromFile({
				file: OS.Path.join(getTestDataDirectory().path, 'test.png')
			});
			attachment._attachmentPath = path;
			assert.equal(attachment.attachmentPath, path);
			
			var translation = new Zotero.Translate.Export();
			var tmpDir = await getTempDirectory();
			var exportDir = OS.Path.join(tmpDir, 'export');
			translation.setLocation(Zotero.File.pathToFile(exportDir));
			translation.setItems([attachment]);
			translation.setTranslator('14763d24-8ba0-45df-8f52-b8d1108e7ac9'); // Zotero RDF
			translation.setDisplayOptions({
				exportFileData: true
			});
			await translation.translate();
			
			var exportFile = OS.Path.join(exportDir, 'export.rdf');
			assert.isAbove((await OS.File.stat(exportFile)).size, 0);
			var rdf = Zotero.File.getContents(exportFile);
			var dp = new DOMParser();
			var doc = dp.parseFromString(rdf, 'text/xml');
			assert.equal(doc.querySelector('path').getAttribute('rdf:resource'), path);
		});
	});
});
}
