diff --git a/.lune/tests/init.luau b/.lune/tests/init.luau new file mode 100644 index 0000000..7fe5cd4 --- /dev/null +++ b/.lune/tests/init.luau @@ -0,0 +1,84 @@ +--> Run tests using frktest runner + +local fs = require("@lune/fs") +local process = require("@lune/process") + +local frktest = require("../../lune_packages/frktest") +local reporter = require("./reporter") + +-- HACK: Cast require to allow for dynamic paths in strict mode +-- A more proper solution would be to use luau.load instead, but +-- frktest requires its global state to be modified by test suites +local require = require :: ( + path: string +) -> ( + test: typeof(setmetatable( + {} :: { + case: (name: string, fn: () -> nil) -> (), + suite: (name: string, fn: () -> ()) -> (), + }, + { __index = frktest.test } + )) +) -> () + + +local function discoverTests(dir: string): { string } + local tests = {} + + local entries = fs.readDir(dir) + for _, entry in entries do + local path = `{dir}/{entry}` + + -- Look for files ending in `.spec.luau` as tests + if fs.isFile(path) and string.match(entry, "%.luau$") then + table.insert(tests, path) + continue + end + + -- Recurse for directories + if fs.isDir(path) then + local dirResults = discoverTests(path) + table.move(dirResults, 1, #dirResults, #tests + 1, tests) + continue + end + end + + return tests +end + +local allowedTests = process.args +for _, test in discoverTests("tests") do + -- If we are given any arguments, we only run those tests, otherwise, + -- we run all the tests + + -- So, to include only a certain set of test files, you can provide either + -- the full path of the test file or name of the test file, with or without + -- the `.luau` extension + local baseName = string.match(test, "([^/\\]+)$") + + local withoutExt = string.sub(test, 1, -11) + local baseNameWithoutExt = string.match(withoutExt, "([^/\\]+)$") + + local isAllowed = #process.args == 0 + or table.find(allowedTests, test) + or table.find(allowedTests, withoutExt) + or table.find(allowedTests, baseName) + or table.find(allowedTests, baseNameWithoutExt) + + local constructors = { + case = frktest.test.case, + suite = frktest.test.suite, + } + + if not isAllowed then + constructors.case = frktest.test.skip.case + constructors.suite = frktest.test.skip.suite + end + + require(`../../{test}`)( + setmetatable(constructors, { __index = frktest.test }) + ) +end + +reporter.init() +process.exit(tonumber(frktest.run())) \ No newline at end of file diff --git a/.lune/tests/reporter.luau b/.lune/tests/reporter.luau new file mode 100644 index 0000000..b1e56af --- /dev/null +++ b/.lune/tests/reporter.luau @@ -0,0 +1,69 @@ +local stdio = require("@lune/stdio") + +local frktest = require("../../lune_packages/frktest") +local Reporter = frktest._reporters.lune_console_reporter + +local watch = require("../util/channel") + +local STYLE = table.freeze({ + suite = function(name: string) + return `{stdio.style("bold")}{stdio.color("purple")}SUITE{stdio.style( + "reset" + )} {name}` + end, + + report = function( + name: string, + state: "success" | "error" | "skip", + elapsed: number + ) + local state_color: stdio.Color = if state == "success" + then "green" + elseif state == "error" then "red" + elseif state == "skip" then "yellow" + else error("Invalid test state") + return ` {stdio.style("bold")}{stdio.color(state_color)}{if state + == "skip" + then "SKIP" + else "TEST"}{stdio.style("reset")} {name} [{stdio.style("dim")}{string.format( + "%.2fms", + elapsed + )}{stdio.style("reset")}]` + end, +}) + +local ReporterExt = {} +function ReporterExt.init() + frktest.test.on_suite_enter(function(suite) + print(STYLE.suite(suite.name)) + end) + + frktest.test.on_suite_leave(function() + stdio.write("\n") + end) + + local send_ts, recv_ts = watch((nil :: any) :: number) + + frktest.test.on_test_enter(function() + -- Send over some high precision timestamp when the test starts + return send_ts(os.clock()) + end) + + frktest.test.on_test_leave(function(test) + print(STYLE.report( + test.name, + if test.failed then "error" else "success", + + -- Await receival of the timestamp and convert the difference to ms + (os.clock() - assert(recv_ts())) * 1000 + )) + end) + + frktest.test.on_test_skipped(function(test) + print(STYLE.report(test.name, "skip", 0)) + end) + + Reporter.init() +end + +return setmetatable(ReporterExt, { __index = Reporter }) \ No newline at end of file diff --git a/.lune/util/channel.luau b/.lune/util/channel.luau new file mode 100644 index 0000000..c3fcf4d --- /dev/null +++ b/.lune/util/channel.luau @@ -0,0 +1,48 @@ +--- An MPSC synchronization primitive powered by Lua upvalues which retains only +--- one value at a time. + +--- ## Usage +--- ```luau +--- local send, recv = watch((nil :: any) :: string) +--- task.delay(5, send, "hello, world!") +--- task.spawn(function() +--- local value = recv() +--- print("received value:", value) +--- end) +--- ``` +type Watch = { + value: T?, + receivers: { thread }, +} + +--- Creates a new `Watch` channel, returning its send and receive handles. +local function chan(_phantom: T): ((T) -> (), () -> T?) + local watch: Watch = { + value = nil, + receivers = {}, + } + + local function send(value: T) + watch.value = value + + for _, receiver in watch.receivers do + coroutine.resume(receiver, value) + end + end + + local function recv(): T + local value = watch.value + watch.value = nil + + if value == nil then + table.insert(watch.receivers, coroutine.running()) + return coroutine.yield() + end + + return value :: T + end + + return send, recv +end + +return chan \ No newline at end of file diff --git a/tests/data/folder/LICENSE.binary.wmv.txt b/tests/data/folder/LICENSE.binary.wmv.txt new file mode 100644 index 0000000..a616ed1 --- /dev/null +++ b/tests/data/folder/LICENSE.binary.wmv.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/tests/data/folder/binary.wmv b/tests/data/folder/binary.wmv new file mode 100644 index 0000000..aee3e9c Binary files /dev/null and b/tests/data/folder/binary.wmv differ diff --git a/tests/extract.luau b/tests/extract.luau new file mode 100644 index 0000000..bcf5fb8 --- /dev/null +++ b/tests/extract.luau @@ -0,0 +1,81 @@ +local fs = require("@lune/fs") +local process = require("@lune/process") + +local frktest = require("@pkg/frktest") +local check = frktest.assert.check + +local ZipReader = require("../lib") + +-- Reuse the same ZIP files from metadata tests +local ZIPS = fs.readDir("tests/data") +local FALLIBLES = { + "invalid_cde_number_of_files_allocation_greater_offset.zip", + -- FIXME: Incorrectly handled, file tree is empty and walk silently errors + -- "invalid_cde_number_of_files_allocation_smaller_offset.zip", + "invalid_offset.zip", + "invalid_offset2.zip", + -- FIXME: Does not error when it should + -- "comment_garbage.zip", + "chinese.zip", + "non_utf8.zip", -- FIXME: Lune breaks for non utf8 data in process stdout + "pandoc_soft_links.zip", -- FIXME: Soft links are not handled correctly + -- FIXME: Files with a misaligned comments are not correctly located + -- "misaligned_comment.zip", +} + +return function(test: typeof(frktest.test)) + test.suite("ZIP extraction tests", function() + for _, file in ZIPS do + if not string.match(file, "%.zip$") then + continue + end + + local checkErr: ((...any) -> any?) -> nil = if table.find(FALLIBLES, file) + then check.should_error + else check.should_not_error + + test.case(`Extracts files correctly - {file}`, function() + checkErr(function() + local zipPath = "tests/data/" .. file + local data = fs.readFile(zipPath) + local zip = ZipReader.load(buffer.fromstring(data)) + + -- Test both string and buffer extraction + local stringOptions = { isString = true, decompress = true } + local bufferOptions = { isString = false, decompress = true } + + -- Extract and verify each file + zip:walk(function(entry) + if entry.isDirectory then + return + end + + -- Extract with unzip for comparison + local unzipOutput = process.spawn(`unzip`, { "-p", zipPath, entry:getPath() }) + + -- NOTE: We use assert since we don't know whether to expect true or false + assert(unzipOutput.ok) + + -- Test string extraction + local contentString = zip:extract(entry, stringOptions) :: string + check.equal(#contentString, entry.size) + check.equal(contentString, unzipOutput.stdout) + + -- Test buffer extraction + local contentBuffer = zip:extract(entry, bufferOptions) :: buffer + check.equal(buffer.len(contentBuffer), entry.size) + check.equal(buffer.tostring(contentBuffer), unzipOutput.stdout) + + -- Test directory extraction + local parentPath = entry:getPath():match("(.+)/[^/]*$") or "/" + local dirContents = zip:extractDirectory(parentPath, stringOptions) + check.not_nil(dirContents[entry:getPath()]) + check.equal(dirContents[entry:getPath()], unzipOutput.stdout) + end) + + return + end) + end) + end + end) +end diff --git a/tests/list.luau b/tests/list.luau new file mode 100644 index 0000000..c230ce8 --- /dev/null +++ b/tests/list.luau @@ -0,0 +1,75 @@ +local fs = require("@lune/fs") +local process = require("@lune/process") + +local frktest = require("@pkg/frktest") +local check = frktest.assert.check + +local ZipReader = require("../lib") + +return function(test: typeof(frktest.test)) + test.suite("ZIP listing tests (top-level)", function() + test.case("Lists all entries correctly", function() + -- Read our test zip file + local data = fs.readFile("tests/data/files_and_dirs.zip") + local zip = ZipReader.load(buffer.fromstring(data)) + + -- Get listing from our implementation + local entries = {} + for _, entry in zip:listDirectory("/") do + table.insert(entries, entry:getPath()) + end + + -- Get listing from zip command + local result = process.spawn("zip", {"-sf", "tests/data/files_and_dirs.zip"}) + check.is_true(result.ok) + local zipOutput = result.stdout + + -- Parse zip command output into sorted array + local zipEntries = {} + for line in string.gmatch(zipOutput, "[^\r\n]+") do + -- Skip header/footer lines + if not string.match(line, "^Archive contains:") and not string.match(line, "^Total %d+ entries") then + table.insert(zipEntries, string.match(line, "^%s*(.+)$")) + end + end + + -- Compare results + for _, entry in entries do + check.not_nil(table.find(zipEntries, entry)) + end + end) + + test.case("Lists directories correctly", function() + local data = fs.readFile("tests/data/files_and_dirs.zip") + local zip = ZipReader.load(buffer.fromstring(data)) + + local dirs = {} + for _, entry in zip:listDirectory("/") do + if entry.isDirectory then + table.insert(dirs, entry:getPath()) + end + end + + -- Verify all directory paths end with / + for _, dir in dirs do + check.equal(string.sub(dir, -1), "/") + end + end) + + test.case("Directory statistics match", function() + local data = fs.readFile("tests/data/files_and_dirs.zip") + local zip = ZipReader.load(buffer.fromstring(data)) + + local stats = zip:getStats() + + -- Get file count from zip command + local result = process.spawn("zip", {"-sf", "tests/data/files_and_dirs.zip"}) + check.is_true(result.ok) + + -- Parse file count from last line of zip output + local fileCount = tonumber(string.match(result.stdout, "Total (%d+) entries.*$")) + + check.equal(stats.fileCount + stats.dirCount, fileCount) + end) + end) +end \ No newline at end of file diff --git a/tests/metadata.luau b/tests/metadata.luau new file mode 100644 index 0000000..4a30f43 --- /dev/null +++ b/tests/metadata.luau @@ -0,0 +1,131 @@ +local fs = require("@lune/fs") +local process = require("@lune/process") +local DateTime = require("@lune/datetime") + +local frktest = require("@pkg/frktest") +local check = frktest.assert.check + +local ZipReader = require("../lib") + +local ZIPS = fs.readDir("tests/data") +local FALLIBLES = { + "invalid_cde_number_of_files_allocation_greater_offset.zip", + "invalid_cde_number_of_files_allocation_smaller_offset.zip", + "invalid_offset.zip", + "invalid_offset2.zip", + "misaligned_comment.zip", + "comment_garbage.zip", + "chinese.zip" -- FIXME: Support encoding other than UTF-8 and ASCII using OS APIs after FFI +} + +local function timestampToValues(dosTimestamp: number): DateTime.DateTimeValues + local time = bit32.band(dosTimestamp, 0xFFFF) + local date = bit32.band(bit32.rshift(dosTimestamp, 16), 0xFFFF) + + return { + year = bit32.band(bit32.rshift(date, 9), 0x7f) + 1980, + month = bit32.band(bit32.rshift(date, 5), 0x0f), + day = bit32.band(date, 0x1f), + + hour = bit32.band(bit32.rshift(time, 11), 0x1f), + minute = bit32.band(bit32.rshift(time, 5), 0x3f), + second = bit32.band(time, 0x1f) * 2, + } +end + +function dateFuzzyEq(date1: string, date2: string, thresholdDays: number): boolean + -- Convert the date strings to Lua date tables + local year1, month1, day1 = date1:match("(%d+)-(%d+)-(%d+)") + local year2, month2, day2 = date2:match("(%d+)-(%d+)-(%d+)") + + -- Create date tables + local dt1 = + os.time({ year = assert(tonumber(year1)), month = assert(tonumber(month1)), day = assert(tonumber(day1)) }) + local dt2 = + os.time({ year = assert(tonumber(year2)), month = assert(tonumber(month2)), day = assert(tonumber(day2)) }) + + -- Calculate the difference in seconds + local difference = math.abs(dt1 - dt2) + + -- Calculate the threshold in seconds + local threshold_seconds = thresholdDays * 86400 -- 86400 seconds in a day + + -- Check if the difference is within the threshold + return difference <= threshold_seconds +end + +function timeFuzzyEq(time1: string, time2: string, thresholdSeconds: number): boolean + -- Convert the time strings to hours, minutes, and seconds + local hour1, minute1 = time1:match("(%d+):(%d+)") + local hour2, minute2 = time2:match("(%d+):(%d+)") + + -- Create time tables and convert to seconds + local totalSeconds1 = (assert(tonumber(hour1)) * 3600) + (assert(tonumber(minute1)) * 60) + local totalSeconds2 = (assert(tonumber(hour2)) * 3600) + (assert(tonumber(minute2)) * 60) + + -- Calculate the difference in seconds + local difference = math.abs(totalSeconds1 - totalSeconds2) + + -- Check if the difference is within the threshold + return difference <= thresholdSeconds +end + +return function(test: typeof(frktest.test)) + test.suite("ZIP metadata tests", function() + for _, file in ZIPS do + if not string.match(file, "%.zip$") then + -- Not a zip file, skip + continue + end + + local checkErr:(((...any) -> any?) -> nil) = if table.find(FALLIBLES, file) then check.should_error else check.should_not_error + test.case(`Parsed metadata matches unzip output - {file}`, function() + checkErr(function(...) + file = "tests/data/" .. file + local data = fs.readFile(file) + local zip = ZipReader.load(buffer.fromstring(data)) + + -- Get sizes from unzip command + local result = process.spawn("unzip", { "-v", file }) + -- HACK: We use assert here since we don't know if we expect false or true + assert(result.ok) + + -- Parse unzip output + for line in string.gmatch(result.stdout, "[^\r\n]+") do + if + not string.match(line, "^Archive:") + and not string.match(line, "^%s+Length") + and not string.match(line, "^%s*%-%-%-%-") + and not string.match(line, "files?$") + and #line > 0 + then + -- TODO: Expose information about method, size, and compression ratio in API + local length, _method, _size, _cmpr, expectedDate, expectedTime, crc32, name = string.match( + line, + "^%s*(%d+)%s+(%S+)%s+(%d+)%s+([+-]?%d*%%?)%s+(%d%d%d%d%-%d%d%-%d%d)%s+(%d%d:%d%d)%s+(%x+)%s+(.+)$" + ) + + local entry = assert(zip:findEntry(assert(name))) + + local gotDateTime = DateTime.fromLocalTime( + timestampToValues(entry.timestamp) :: DateTime.DateTimeValueArguments + ) + + check.equal(tonumber(length), entry.size) + check.is_true(dateFuzzyEq(gotDateTime:formatLocalTime("%Y-%m-%d"), expectedDate :: string, 1)) + check.is_true( + -- Allow a threshold of 26 hours, which is the largest possible gap between any two + -- timezones + timeFuzzyEq(gotDateTime:formatLocalTime("%H:%M"), expectedTime :: string, 26 * 3600) + ) + + check.equal(string.format("%08x", entry.crc), crc32) + end + end + + return + end) + end) + end + end) +end diff --git a/tests/walk.luau b/tests/walk.luau new file mode 100644 index 0000000..b44b905 --- /dev/null +++ b/tests/walk.luau @@ -0,0 +1,88 @@ +local fs = require("@lune/fs") +local process = require("@lune/process") + +local frktest = require("@pkg/frktest") +local check = frktest.assert.check + +local ZipReader = require("../lib") + +local ZIPS = { + "tests/data/files_and_dirs.zip", + "tests/data/symlink.zip", + "tests/data/extended_timestamp.zip", +} + +return function(test: typeof(frktest.test)) + test.suite("ZIP walking tests", function() + test.case("Walks all entries recursively", function() + for _, file in ZIPS do + local data = fs.readFile(file) + local zip = ZipReader.load(buffer.fromstring(data)) + + -- Get entries from our implementation + local entries = {} + zip:walk(function(entry) + if entry.name ~= "/" then + table.insert(entries, entry:getPath()) + end + end) + table.sort(entries) + + -- Get entries from unzip command + local result = process.spawn("unzip", { "-l", file }) + check.is_true(result.ok) + + -- Parse unzip output into sorted array + local unzipEntries = {} + for line in string.gmatch(result.stdout, "[^\r\n]+") do + -- Skip header/footer lines and empty lines + if + not string.match(line, "^Archive:") + and not string.match(line, "^%s+Length") + and not string.match(line, "^%s*%-%-%-%-") + and not string.match(line, "^%s+%d+%s+%d+ files?$") + and #line > 0 + then + -- Extract filename from unzip output format + local name = string.match(line, "%S+$") + if name then + table.insert(unzipEntries, name) + end + end + end + table.sort(unzipEntries) + + -- Compare results + check.table.equal(entries, unzipEntries) + end + end) + + test.case("Walks with correct depth values", function() + for _, file in ZIPS do + local data = fs.readFile(file) + local zip = ZipReader.load(buffer.fromstring(data)) + + -- Verify depth values increase correctly + local rootSeen = false + + zip:walk(function(entry, depth) + if entry:getPath() == "/" then + check.equal(depth, 0) + rootSeen = true + return + end + + -- Count path separators to verify depth, starting at 1 for `/` + local expectedDepth = 1 + for _ in string.gmatch(entry:getPath():gsub("/$", ""), "/") do + expectedDepth += 1 + end + + check.equal(depth, expectedDepth) + end) + + check.is_true(rootSeen) + end + end) + end) +end