zotero/test/tests/fileTest.js
Dan Stillman bec42fe2a5 Handle multibyte characters in Zotero.File.truncateFileName()
Filesystems care about byte length, not character length, so treat
maxLength as the byte length limit and truncate accordingly.

This will also now remove entire emoji characters without corrupting
them.
2021-05-20 19:25:57 -04:00

432 lines
15 KiB
JavaScript

describe("Zotero.File", function () {
describe("#getContentsAsync()", function () {
it("should handle an empty file", function* () {
var path = OS.Path.join(getTestDataDirectory().path, "empty");
assert.equal((yield Zotero.File.getContentsAsync(path)), "");
})
it("should handle an extended character", function* () {
var contents = yield Zotero.File.getContentsAsync(
OS.Path.join(getTestDataDirectory().path, "charsets", "utf8.txt")
);
assert.lengthOf(contents, 3);
assert.equal(contents, "A\u72acB");
})
it("should handle an extended Windows-1252 character", function* () {
var contents = yield Zotero.File.getContentsAsync(
OS.Path.join(getTestDataDirectory().path, "charsets", "windows1252.txt"),
"windows-1252"
);
assert.lengthOf(contents, 1);
assert.equal(contents, "\u00E9");
})
it("should handle a GBK character", function* () {
var contents = yield Zotero.File.getContentsAsync(
OS.Path.join(getTestDataDirectory().path, "charsets", "gbk.txt"),
"gbk"
);
assert.lengthOf(contents, 1);
assert.equal(contents, "\u4e02");
})
it("should handle an invalid character", function* () {
var contents = yield Zotero.File.getContentsAsync(
OS.Path.join(getTestDataDirectory().path, "charsets", "invalid.txt")
);
assert.lengthOf(contents, 3);
assert.equal(contents, "A\uFFFDB");
})
it("should respect maxLength", function* () {
var contents = yield Zotero.File.getContentsAsync(
OS.Path.join(getTestDataDirectory().path, "test.txt"),
false,
6
);
assert.lengthOf(contents, 6);
assert.equal(contents, "Zotero");
});
it("should get a file from a file: URI", function* () {
var contents = yield Zotero.File.getContentsAsync(
OS.Path.toFileURI(OS.Path.join(getTestDataDirectory().path, "test.txt"))
);
assert.isTrue(contents.startsWith('Zotero'));
});
})
describe("#getBinaryContentsAsync()", function () {
var magicPNG = ["89", "50", "4e", "47", "0d", "0a", "1a", "0a"].map(x => parseInt(x, 16));
it("should return a binary string", function* () {
var contents = yield Zotero.File.getBinaryContentsAsync(
OS.Path.join(getTestDataDirectory().path, "test.png")
);
assert.isAbove(contents.length, magicPNG.length);
for (let i = 0; i < magicPNG.length; i++) {
assert.equal(magicPNG[i], contents.charCodeAt(i));
}
});
it("should respect maxLength", function* () {
var contents = yield Zotero.File.getBinaryContentsAsync(
OS.Path.join(getTestDataDirectory().path, "test.png"),
magicPNG.length
);
assert.lengthOf(contents, magicPNG.length)
for (let i = 0; i < contents.length; i++) {
assert.equal(magicPNG[i], contents.charCodeAt(i));
}
});
});
describe("#putContentsAsync()", function () {
it("should save a text string", async function () {
var tmpDir = await getTempDirectory();
var destFile = OS.Path.join(tmpDir, 'test');
var str = 'A';
await Zotero.File.putContentsAsync(destFile, str);
assert.equal(await Zotero.File.getContentsAsync(destFile), str);
});
it("should save a Blob", async function () {
var srcFile = OS.Path.join(getTestDataDirectory().path, 'test.pdf');
var tmpDir = await getTempDirectory();
var destFile = OS.Path.join(tmpDir, 'test.pdf');
var blob = await File.createFromFileName(srcFile);
await Zotero.File.putContentsAsync(destFile, blob);
var destContents = await Zotero.File.getBinaryContentsAsync(destFile);
assert.equal(
await Zotero.File.getBinaryContentsAsync(srcFile),
destContents
);
assert.equal(destContents.substr(0, 4), '%PDF');
});
it("should save via .tmp file", function* () {
var tmpDir = yield getTempDirectory();
var destFile = OS.Path.join(tmpDir, 'test.txt')
var tmpFile = destFile + ".tmp";
yield Zotero.File.putContentsAsync(tmpFile, 'A');
assert.isTrue(yield OS.File.exists(tmpFile));
yield Zotero.File.putContentsAsync(destFile, 'B');
assert.isFalse(yield OS.File.exists(tmpFile));
// Make sure .tmp file was deleted
assert.isFalse(yield OS.File.exists(tmpFile + '.tmp'));
});
});
describe("#rename()", function () {
it("should rename a file", async function () {
var tmpDir = await getTempDirectory();
var sourceFile = OS.Path.join(tmpDir, 'a');
var destFile = OS.Path.join(tmpDir, 'b');
await Zotero.File.putContentsAsync(sourceFile, '');
await Zotero.File.rename(sourceFile, 'b');
assert.isTrue(await OS.File.exists(destFile));
});
// Only relevant on a case-insensitive filesystem
it("should rename a file with a case-only change (Mac)", async function () {
var tmpDir = await getTempDirectory();
var sourceFile = OS.Path.join(tmpDir, 'a');
var destFile = OS.Path.join(tmpDir, 'A');
await Zotero.File.putContentsAsync(sourceFile, 'foo');
var newFilename = await Zotero.File.rename(sourceFile, 'A');
assert.equal(newFilename, 'A');
assert.equal(await Zotero.File.getContentsAsync(destFile), 'foo');
});
it("should overwrite an existing file if `overwrite` is true", async function () {
var tmpDir = await getTempDirectory();
var sourceFile = OS.Path.join(tmpDir, 'a');
var destFile = OS.Path.join(tmpDir, 'b');
await Zotero.File.putContentsAsync(sourceFile, 'a');
await Zotero.File.putContentsAsync(destFile, 'b');
await Zotero.File.rename(sourceFile, 'b', { overwrite: true });
assert.isTrue(await OS.File.exists(destFile));
assert.equal(await Zotero.File.getContentsAsync(destFile), 'a');
});
it("should get a unique name if target file exists and `unique` is true", async function () {
var tmpDir = await getTempDirectory();
var sourceFile = OS.Path.join(tmpDir, 'a');
var destFile = OS.Path.join(tmpDir, 'b');
await Zotero.File.putContentsAsync(sourceFile, 'a');
await Zotero.File.putContentsAsync(destFile, 'b');
var newFilename = await Zotero.File.rename(sourceFile, 'b', { unique: true });
var realDestFile = OS.Path.join(tmpDir, newFilename);
assert.equal(newFilename, 'b 2');
assert.isTrue(await OS.File.exists(realDestFile));
assert.equal(await Zotero.File.getContentsAsync(realDestFile), 'a');
});
});
describe("#getClosestDirectory()", function () {
it("should return directory for file that exists", function* () {
var tmpDir = yield getTempDirectory();
var closest = yield Zotero.File.getClosestDirectory(tmpDir);
assert.equal(closest, tmpDir);
});
it("should return parent directory for missing file", function* () {
var tmpDir = yield getTempDirectory();
var closest = yield Zotero.File.getClosestDirectory(OS.Path.join(tmpDir, 'a'));
assert.equal(closest, tmpDir);
});
it("should find an existing directory three levels up from a missing file", function* () {
var tmpDir = yield getTempDirectory();
var closest = yield Zotero.File.getClosestDirectory(OS.Path.join(tmpDir, 'a', 'b', 'c'));
assert.equal(closest, tmpDir);
});
it("should return false for a path that doesn't exist at all", function* () {
assert.isFalse(yield Zotero.File.getClosestDirectory('/a/b/c'));
});
});
describe("#moveToUnique", function () {
it("should move a file to a unique filename", async function () {
var tmpDir = Zotero.getTempDirectory().path;
var sourceFile = OS.Path.join(tmpDir, "1");
var tmpTargetDir = OS.Path.join(tmpDir, "targetDirectory")
var targetFile = OS.Path.join(tmpTargetDir, "file.txt");
await OS.File.makeDir(tmpTargetDir);
await Zotero.File.putContentsAsync(sourceFile, "");
await Zotero.File.putContentsAsync(targetFile, "");
var newFile = await Zotero.File.moveToUnique(sourceFile, targetFile);
assert.equal(OS.Path.join(tmpTargetDir, 'file-1.txt'), newFile);
});
});
describe("#copyDirectory()", function () {
it("should copy all files within a directory", function* () {
var tmpDir = Zotero.getTempDirectory().path;
var tmpCopyDir = OS.Path.join(tmpDir, "copyDirectory")
var source = OS.Path.join(tmpCopyDir, "1");
var target = OS.Path.join(tmpCopyDir, "2");
yield OS.File.makeDir(source, {
from: tmpDir
});
yield Zotero.File.putContentsAsync(OS.Path.join(source, "A"), "Test 1");
yield Zotero.File.putContentsAsync(OS.Path.join(source, "B"), "Test 2");
yield OS.File.removeDir(target, {
ignoreAbsent: true
});
yield Zotero.File.copyDirectory(source, target);
assert.equal(
(yield Zotero.File.getContentsAsync(OS.Path.join(target, "A"))),
"Test 1"
);
assert.equal(
(yield Zotero.File.getContentsAsync(OS.Path.join(target, "B"))),
"Test 2"
);
})
it("should copy subfolders", async function () {
// file1
// subdir/file2
var tmpDir = await getTempDirectory()
var source = OS.Path.join(tmpDir, "1");
await OS.File.makeDir(OS.Path.join(source, 'subdir'), {
from: tmpDir
});
Zotero.File.putContents(Zotero.File.pathToFile(OS.Path.join(source, 'file1')), 'abc');
Zotero.File.putContents(Zotero.File.pathToFile(OS.Path.join(source, 'subdir', 'file2')), 'def');
var target = OS.Path.join(tmpDir, "2");
await OS.File.makeDir(target);
await Zotero.File.copyDirectory(source, target);
var targetFile1 = OS.Path.join(target, 'file1');
var targetFile2 = OS.Path.join(target, 'subdir', 'file2');
assert.isTrue(await OS.File.exists(targetFile1));
assert.isTrue(await OS.File.exists(targetFile2));
assert.equal(Zotero.File.getContents(targetFile1), 'abc');
assert.equal(Zotero.File.getContents(targetFile2), 'def');
});
})
describe("#createDirectoryIfMissing()", function () {
it("should throw error on broken symlink", async function () {
if (Zotero.isWin) {
this.skip();
};
var tmpPath = await getTempDirectory();
var destPath = OS.Path.join(tmpPath, 'missing');
var linkPath = OS.Path.join(tmpPath, 'link');
await OS.File.unixSymLink(destPath, linkPath);
assert.throws(() => Zotero.File.createDirectoryIfMissing(linkPath), /^Broken symlink/);
});
});
describe("#createDirectoryIfMissingAsync()", function () {
it("should throw error on broken symlink", async function () {
if (Zotero.isWin) {
this.skip();
};
var tmpPath = await getTempDirectory();
var destPath = OS.Path.join(tmpPath, 'missing');
var linkPath = OS.Path.join(tmpPath, 'link');
await OS.File.unixSymLink(destPath, linkPath);
var e = await getPromiseError(Zotero.File.createDirectoryIfMissingAsync(linkPath));
assert.ok(e);
assert.match(e.message, /^Broken symlink/);
});
it("should handle 'from' in options", async function () {
var tmpPath = await getTempDirectory();
var path = OS.Path.join(tmpPath, 'a', 'b');
await Zotero.File.createDirectoryIfMissingAsync(path, { from: tmpPath });
assert.isTrue(await OS.File.exists(path));
});
});
describe("#zipDirectory()", function () {
it("should compress a directory recursively", function* () {
var tmpPath = Zotero.getTempDirectory().path;
var path = OS.Path.join(tmpPath, Zotero.Utilities.randomString());
yield OS.File.makeDir(path, { unixMode: 0o755 });
yield Zotero.File.putContentsAsync(OS.Path.join(path, '.zotero-ft-cache'), '');
yield Zotero.File.putContentsAsync(OS.Path.join(path, 'a.txt'), 'A');
// Create subdirectory
var subPath = OS.Path.join(path, 'sub');
yield OS.File.makeDir(subPath, { unixMode: 0o755 });
yield Zotero.File.putContentsAsync(OS.Path.join(subPath, 'b.txt'), 'B');
var zipFile = OS.Path.join(tmpPath, 'test.zip');
yield Zotero.File.zipDirectory(path, zipFile);
var zr = Components.classes["@mozilla.org/libjar/zip-reader;1"]
.createInstance(Components.interfaces.nsIZipReader);
zr.open(Zotero.File.pathToFile(zipFile));
var entries = zr.findEntries('*');
var files = {};
var is = Components.classes['@mozilla.org/scriptableinputstream;1']
.createInstance(Components.interfaces.nsIScriptableInputStream);
while (entries.hasMore()) {
let entryPointer = entries.getNext();
let entry = zr.getEntry(entryPointer);
let inputStream = zr.getInputStream(entryPointer);
is.init(inputStream);
files[entryPointer] = is.read(entry.realSize);
}
zr.close();
assert.notProperty(files, '.zotero-ft-cache');
assert.propertyVal(files, 'a.txt', 'A');
assert.propertyVal(files, 'sub/b.txt', 'B');
});
});
describe("#truncateFileName()", function () {
it("should drop extension if longer than limit", function () {
var filename = "lorem.json";
var shortened = Zotero.File.truncateFileName(filename, 5);
assert.equal(shortened, "lorem");
});
it("should use byte length rather than character length", function () {
var filename = "\uD83E\uDD92abcdefgh.pdf";
var shortened = Zotero.File.truncateFileName(filename, 10);
assert.equal(shortened, "\uD83E\uDD92ab.pdf");
});
it("should remove characters, not bytes", function () {
// Emoji would put length over limit, so it should be removed completely
var filename = "abcé\uD83E\uDD92.pdf";
var shortened = Zotero.File.truncateFileName(filename, 10);
assert.equal(shortened, "abcé.pdf");
});
it("should replace single multi-byte character with underscore if longer than maxLength", function () {
// Emoji would put length over limit, so it should be replaced with _
var filename = "\uD83E\uDD92.pdf";
var shortened = Zotero.File.truncateFileName(filename, 5);
assert.equal(shortened, "_.pdf");
});
// The optimal behavior would probably be to remove the entire character sequence, but I'm
// not sure we can do that without an emoji library, so just make sure we're removing whole
// characters without corrupting anything.
it("should cruelly break apart families", function () {
var family = [
"\uD83D\uDC69", // woman (4)
"\uD83C\uDFFE", // skin tone (4)
"\u200D", // zero-width joiner (3)
"\uD83D\uDC68", // man (4)
"\uD83C\uDFFE", // skin tone (4)
"\u200D", // zero-width joiner (3)
"\uD83D\uDC67", // girl (4)
"\uD83C\uDFFE", // skin tone (4)
"\u200D", // zero-width joiner (3)
"\uD83D\uDC66", // boy (4)
"\uD83C\uDFFE" // skin tone (4)
].join("");
var filename = "abc" + family + ".pdf";
var limit = 3 // 'abc'
+ 4 + 4 + 3
+ 4 + 4 + 3
+ 4; // ext
// Add some extra bytes to make sure we don't corrupt an emoji character
limit += 2;
var shortened = Zotero.File.truncateFileName(filename, limit);
assert.equal(
shortened,
"abc"
+ "\uD83D\uDC69"
+ "\uD83C\uDFFE"
+ "\u200D"
+ "\uD83D\uDC68"
+ "\uD83C\uDFFE"
+ "\u200D"
+ ".pdf"
);
});
});
describe("#checkFileAccessError()", function () {
it("should catch OS.File access-denied errors", function* () {
// We can't modify a real OS.File.Error, but we also don't do an instanceof check in
// checkFileAccessError, so just set the expected properties.
var e = {
operation: 'open',
becauseAccessDenied: true,
path: '/tmp/test'
};
try {
Zotero.File.checkFileAccessError(e, e.path, 'create');
}
catch (e) {
if (e instanceof Zotero.Error) {
return;
}
throw e;
}
throw new Error("Error not thrown");
});
});
})