chore: implement testing framework pw by frktest

This commit is contained in:
Erica Marigold 2025-01-06 05:46:08 +00:00
parent 06b1f1a640
commit 0687c87951
Signed by: DevComp
GPG key ID: 429EF1C337871656
9 changed files with 599 additions and 0 deletions

84
.lune/tests/init.luau Normal file
View file

@ -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()))

69
.lune/tests/reporter.luau Normal file
View file

@ -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 })

48
.lune/util/channel.luau Normal file
View file

@ -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<T> = {
value: T?,
receivers: { thread },
}
--- Creates a new `Watch` channel, returning its send and receive handles.
local function chan<T>(_phantom: T): ((T) -> (), () -> T?)
local watch: Watch<T> = {
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

View file

@ -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.

Binary file not shown.

81
tests/extract.luau Normal file
View file

@ -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

75
tests/list.luau Normal file
View file

@ -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

131
tests/metadata.luau Normal file
View file

@ -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

88
tests/walk.luau Normal file
View file

@ -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