diff --git a/Makefile b/Makefile index 6dff85e5..4b824f11 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ ZIG := zig BC := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +# option test filter make unittest F="server" +F= # OS and ARCH kernel = $(shell uname -ms) @@ -42,7 +44,7 @@ help: # $(ZIG) commands # ------------ -.PHONY: build build-dev run run-release shell test bench download-zig wpt +.PHONY: build build-dev run run-release shell test bench download-zig wpt unittest zig_version = $(shell grep 'recommended_zig_version = "' "vendor/zig-js-runtime/build.zig" | cut -d'"' -f2) @@ -91,6 +93,9 @@ test: @$(ZIG) build test -Dengine=v8 || (printf "\e[33mTest ERROR\e[0m\n"; exit 1;) @printf "\e[33mTest OK\e[0m\n" +unittest: + @TEST_FILTER='${F}' $(ZIG) build unittest -freference-trace --summary all + # Install and build required dependencies commands # ------------ .PHONY: install-submodule diff --git a/build.zig b/build.zig index b937252a..44e99222 100644 --- a/build.zig +++ b/build.zig @@ -98,8 +98,8 @@ pub fn build(b: *std.Build) !void { // compile const tests = b.addTest(.{ - .root_source_file = b.path("src/run_tests.zig"), - .test_runner = b.path("src/test_runner.zig"), + .root_source_file = b.path("src/main_tests.zig"), + .test_runner = b.path("src/main_tests.zig"), .target = target, .optimize = mode, }); @@ -119,6 +119,27 @@ pub fn build(b: *std.Build) !void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_tests.step); + // unittest + // ---- + + // compile + const unit_tests = b.addTest(.{ + .root_source_file = b.path("src/unit_tests.zig"), + .test_runner = b.path("src/unit_tests.zig"), + .target = target, + .optimize = mode, + }); + try common(b, unit_tests, options); + + const run_unit_tests = b.addRunArtifact(unit_tests); + if (b.args) |args| { + run_unit_tests.addArgs(args); + } + + // step + const unit_test_step = b.step("unittest", "Run unit tests"); + unit_test_step.dependOn(&run_unit_tests.step); + // wpt // ----- diff --git a/src/browser/loader.zig b/src/browser/loader.zig index 480ec4c1..6df6d9ad 100644 --- a/src/browser/loader.zig +++ b/src/browser/loader.zig @@ -79,13 +79,19 @@ pub const Loader = struct { } }; -test "basic url get" { +test "loader: get" { const alloc = std.testing.allocator; var loader = Loader.init(alloc); defer loader.deinit(); - var result = try loader.get(alloc, "https://en.wikipedia.org/wiki/Main_Page"); + const uri = try std.Uri.parse("http://localhost:9582/loader"); + var result = try loader.get(alloc, uri); defer result.deinit(); - try std.testing.expect(result.req.response.status == std.http.Status.ok); + try std.testing.expectEqual(.ok, result.req.response.status); + + var res: [128]u8 = undefined; + const size = try result.req.readAll(&res); + try std.testing.expectEqual(6, size); + try std.testing.expectEqualStrings("Hello!", res[0..6]); } diff --git a/src/iterator/iterator.zig b/src/iterator/iterator.zig index 803cd978..d582be11 100644 --- a/src/iterator/iterator.zig +++ b/src/iterator/iterator.zig @@ -15,19 +15,39 @@ pub const U32Iterator = struct { done: bool, }; - pub fn _next(self: *U32Iterator) !Return { + pub fn _next(self: *U32Iterator) Return { const i = self.index; if (i >= self.length) { - return Return{ + return .{ .value = 0, .done = true, }; } - self.index += 1; - return Return{ + self.index = i + 1; + return .{ .value = i, .done = false, }; } }; + +const testing = std.testing; +test "U32Iterator" { + const Return = U32Iterator.Return; + + { + var it = U32Iterator{ .length = 0 }; + try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); + try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); + } + + { + var it = U32Iterator{ .length = 3 }; + try testing.expectEqual(Return{ .value = 0, .done = false }, it._next()); + try testing.expectEqual(Return{ .value = 1, .done = false }, it._next()); + try testing.expectEqual(Return{ .value = 2, .done = false }, it._next()); + try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); + try testing.expectEqual(Return{ .value = 0, .done = true }, it._next()); + } +} diff --git a/src/run_tests.zig b/src/main_tests.zig similarity index 97% rename from src/run_tests.zig rename to src/main_tests.zig index 9c0c98b9..3544cc6a 100644 --- a/src/run_tests.zig +++ b/src/main_tests.zig @@ -223,8 +223,14 @@ pub fn main() !void { try parser.init(); defer parser.deinit(); + std.testing.allocator_instance = .{}; try test_fn.func(); - std.debug.print("{s}\tOK\n", .{test_fn.name}); + + if (std.testing.allocator_instance.deinit() == .leak) { + std.debug.print("======Memory Leak: {s}======\n", .{test_fn.name}); + } else { + std.debug.print("{s}\tOK\n", .{test_fn.name}); + } } } } diff --git a/src/storage/storage.zig b/src/storage/storage.zig index 205a3895..53b5e42b 100644 --- a/src/storage/storage.zig +++ b/src/storage/storage.zig @@ -149,20 +149,22 @@ pub const Bottle = struct { } pub fn _setItem(self: *Bottle, k: []const u8, v: []const u8) !void { - const old = self.map.get(k); - if (old != null and std.mem.eql(u8, v, old.?)) return; - - // owns k and v by copying them. - const kk = try self.alloc.dupe(u8, k); - errdefer self.alloc.free(kk); - const vv = try self.alloc.dupe(u8, v); - errdefer self.alloc.free(vv); - - self.map.put(self.alloc, kk, vv) catch |e| { + const gop = self.map.getOrPut(self.alloc, k) catch |e| { log.debug("set item: {any}", .{e}); return DOMError.QuotaExceeded; }; + if (gop.found_existing == false) { + gop.key_ptr.* = try self.alloc.dupe(u8, k); + gop.value_ptr.* = try self.alloc.dupe(u8, v); + return; + } + + if (std.mem.eql(u8, v, gop.value_ptr.*) == false) { + self.alloc.free(gop.value_ptr.*); + gop.value_ptr.* = try self.alloc.dupe(u8, v); + } + // > Broadcast this with key, oldValue, and value. // https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface // @@ -175,8 +177,10 @@ pub const Bottle = struct { } pub fn _removeItem(self: *Bottle, k: []const u8) !void { - const old = self.map.fetchRemove(k); - if (old == null) return; + if (self.map.fetchRemove(k)) |kv| { + self.alloc.free(kv.key); + self.alloc.free(kv.value); + } // > Broadcast this with key, oldValue, and null. // https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface @@ -235,14 +239,17 @@ test "storage bottle" { var bottle = Bottle.init(std.testing.allocator); defer bottle.deinit(); - try std.testing.expect(0 == bottle.get_length()); - try std.testing.expect(null == bottle._getItem("foo")); + try std.testing.expectEqual(0, bottle.get_length()); + try std.testing.expectEqual(null, bottle._getItem("foo")); try bottle._setItem("foo", "bar"); - try std.testing.expect(std.mem.eql(u8, "bar", bottle._getItem("foo").?)); + try std.testing.expectEqualStrings("bar", bottle._getItem("foo").?); + + try bottle._setItem("foo", "other"); + try std.testing.expectEqualStrings("other", bottle._getItem("foo").?); try bottle._removeItem("foo"); - try std.testing.expect(0 == bottle.get_length()); - try std.testing.expect(null == bottle._getItem("foo")); + try std.testing.expectEqual(0, bottle.get_length()); + try std.testing.expectEqual(null, bottle._getItem("foo")); } diff --git a/src/test_runner.zig b/src/test_runner.zig deleted file mode 100644 index 8358b66c..00000000 --- a/src/test_runner.zig +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -const std = @import("std"); - -const tests = @import("run_tests.zig"); - -pub const Types = tests.Types; -pub const UserContext = tests.UserContext; -pub const IO = tests.IO; - -pub fn main() !void { - try tests.main(); -} diff --git a/src/unit_tests.zig b/src/unit_tests.zig new file mode 100644 index 00000000..2ab87f9a --- /dev/null +++ b/src/unit_tests.zig @@ -0,0 +1,347 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const builtin = @import("builtin"); + +const Allocator = std.mem.Allocator; + +pub const std_options = std.Options{ + .http_disable_tls = true, +}; + +const BORDER = "=" ** 80; + +// use in custom panic handler +var current_test: ?[]const u8 = null; + +pub fn main() !void { + var mem: [8192]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&mem); + + const allocator = fba.allocator(); + + const env = Env.init(allocator); + defer env.deinit(allocator); + + var slowest = SlowTracker.init(allocator, 5); + defer slowest.deinit(); + + var pass: usize = 0; + var fail: usize = 0; + var skip: usize = 0; + var leak: usize = 0; + + const address = try std.net.Address.parseIp("127.0.0.1", 9582); + var listener = try address.listen(.{ .reuse_address = true }); + defer listener.deinit(); + const http_thread = try std.Thread.spawn(.{}, serverHTTP, .{&listener}); + defer http_thread.join(); + + const printer = Printer.init(); + printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line + + for (builtin.test_functions) |t| { + if (std.mem.eql(u8, t.name, "unit_tests.test_0")) { + // don't display anything for this test + try t.func(); + continue; + } + + var status = Status.pass; + slowest.startTiming(); + + const is_unnamed_test = isUnnamed(t); + if (env.filter) |f| { + if (!is_unnamed_test and std.mem.indexOf(u8, t.name, f) == null) { + continue; + } + } + + const friendly_name = blk: { + const name = t.name; + var it = std.mem.splitScalar(u8, name, '.'); + while (it.next()) |value| { + if (std.mem.eql(u8, value, "test")) { + const rest = it.rest(); + break :blk if (rest.len > 0) rest else name; + } + } + break :blk name; + }; + + current_test = friendly_name; + std.testing.allocator_instance = .{}; + const result = t.func(); + current_test = null; + + const ns_taken = slowest.endTiming(friendly_name); + + if (std.testing.allocator_instance.deinit() == .leak) { + leak += 1; + printer.status(.fail, "\n{s}\n\"{s}\" - Memory Leak\n{s}\n", .{ BORDER, friendly_name, BORDER }); + } + + if (result) |_| { + pass += 1; + } else |err| switch (err) { + error.SkipZigTest => { + skip += 1; + status = .skip; + }, + else => { + status = .fail; + fail += 1; + printer.status(.fail, "\n{s}\n\"{s}\" - {s}\n{s}\n", .{ BORDER, friendly_name, @errorName(err), BORDER }); + if (@errorReturnTrace()) |trace| { + std.debug.dumpStackTrace(trace.*); + } + if (env.fail_first) { + break; + } + }, + } + + if (env.verbose) { + const ms = @as(f64, @floatFromInt(ns_taken)) / 1_000_000.0; + printer.status(status, "{s} ({d:.2}ms)\n", .{ friendly_name, ms }); + } else { + printer.status(status, ".", .{}); + } + } + + const total_tests = pass + fail; + const status = if (fail == 0) Status.pass else Status.fail; + printer.status(status, "\n{d} of {d} test{s} passed\n", .{ pass, total_tests, if (total_tests != 1) "s" else "" }); + if (skip > 0) { + printer.status(.skip, "{d} test{s} skipped\n", .{ skip, if (skip != 1) "s" else "" }); + } + if (leak > 0) { + printer.status(.fail, "{d} test{s} leaked\n", .{ leak, if (leak != 1) "s" else "" }); + } + printer.fmt("\n", .{}); + try slowest.display(printer); + printer.fmt("\n", .{}); + std.posix.exit(if (fail == 0) 0 else 1); +} + +const Printer = struct { + out: std.fs.File.Writer, + + fn init() Printer { + return .{ + .out = std.io.getStdErr().writer(), + }; + } + + fn fmt(self: Printer, comptime format: []const u8, args: anytype) void { + std.fmt.format(self.out, format, args) catch unreachable; + } + + fn status(self: Printer, s: Status, comptime format: []const u8, args: anytype) void { + const color = switch (s) { + .pass => "\x1b[32m", + .fail => "\x1b[31m", + .skip => "\x1b[33m", + else => "", + }; + const out = self.out; + out.writeAll(color) catch @panic("writeAll failed?!"); + std.fmt.format(out, format, args) catch @panic("std.fmt.format failed?!"); + self.fmt("\x1b[0m", .{}); + } +}; + +const Status = enum { + pass, + fail, + skip, + text, +}; + +const SlowTracker = struct { + const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming); + max: usize, + slowest: SlowestQueue, + timer: std.time.Timer, + + fn init(allocator: Allocator, count: u32) SlowTracker { + const timer = std.time.Timer.start() catch @panic("failed to start timer"); + var slowest = SlowestQueue.init(allocator, {}); + slowest.ensureTotalCapacity(count) catch @panic("OOM"); + return .{ + .max = count, + .timer = timer, + .slowest = slowest, + }; + } + + const TestInfo = struct { + ns: u64, + name: []const u8, + }; + + fn deinit(self: SlowTracker) void { + self.slowest.deinit(); + } + + fn startTiming(self: *SlowTracker) void { + self.timer.reset(); + } + + fn endTiming(self: *SlowTracker, test_name: []const u8) u64 { + var timer = self.timer; + const ns = timer.lap(); + + var slowest = &self.slowest; + + if (slowest.count() < self.max) { + // Capacity is fixed to the # of slow tests we want to track + // If we've tracked fewer tests than this capacity, than always add + slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); + return ns; + } + + { + // Optimization to avoid shifting the dequeue for the common case + // where the test isn't one of our slowest. + const fastest_of_the_slow = slowest.peekMin() orelse unreachable; + if (fastest_of_the_slow.ns > ns) { + // the test was faster than our fastest slow test, don't add + return ns; + } + } + + // the previous fastest of our slow tests, has been pushed off. + _ = slowest.removeMin(); + slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); + return ns; + } + + fn display(self: *SlowTracker, printer: Printer) !void { + var slowest = self.slowest; + const count = slowest.count(); + printer.fmt("Slowest {d} test{s}: \n", .{ count, if (count != 1) "s" else "" }); + while (slowest.removeMinOrNull()) |info| { + const ms = @as(f64, @floatFromInt(info.ns)) / 1_000_000.0; + printer.fmt(" {d:.2}ms\t{s}\n", .{ ms, info.name }); + } + } + + fn compareTiming(context: void, a: TestInfo, b: TestInfo) std.math.Order { + _ = context; + return std.math.order(a.ns, b.ns); + } +}; + +const Env = struct { + verbose: bool, + fail_first: bool, + filter: ?[]const u8, + + fn init(allocator: Allocator) Env { + return .{ + .verbose = readEnvBool(allocator, "TEST_VERBOSE", true), + .fail_first = readEnvBool(allocator, "TEST_FAIL_FIRST", false), + .filter = readEnv(allocator, "TEST_FILTER"), + }; + } + + fn deinit(self: Env, allocator: Allocator) void { + if (self.filter) |f| { + allocator.free(f); + } + } + + fn readEnv(allocator: Allocator, key: []const u8) ?[]const u8 { + const v = std.process.getEnvVarOwned(allocator, key) catch |err| { + if (err == error.EnvironmentVariableNotFound) { + return null; + } + std.log.warn("failed to get env var {s} due to err {}", .{ key, err }); + return null; + }; + return v; + } + + fn readEnvBool(allocator: Allocator, key: []const u8, deflt: bool) bool { + const value = readEnv(allocator, key) orelse return deflt; + defer allocator.free(value); + return std.ascii.eqlIgnoreCase(value, "true"); + } +}; + +fn isUnnamed(t: std.builtin.TestFn) bool { + const marker = ".test_"; + const test_name = t.name; + const index = std.mem.indexOf(u8, test_name, marker) orelse return false; + _ = std.fmt.parseInt(u32, test_name[index + marker.len ..], 10) catch return false; + return true; +} + +fn serverHTTP(listener: *std.net.Server) !void { + var read_buffer: [1024]u8 = undefined; + ACCEPT: while (true) { + var conn = try listener.accept(); + defer conn.stream.close(); + var server = std.http.Server.init(conn, &read_buffer); + + while (server.state == .ready) { + var request = server.receiveHead() catch |err| switch (err) { + error.HttpConnectionClosing => continue :ACCEPT, + else => { + std.debug.print("Test HTTP Server error: {}\n", .{err}); + return err; + }, + }; + + const path = request.head.target; + if (std.mem.eql(u8, path, "/loader")) { + try writeResponse(&request, .{ + .body = "Hello!", + }); + } + } + } +} + +const Response = struct { + body: []const u8 = "", + status: std.http.Status = .ok, +}; + +fn writeResponse(req: *std.http.Server.Request, res: Response) !void { + try req.respond(res.body, .{ .status = res.status }); +} + +test { + std.testing.refAllDecls(@import("url/query.zig")); + std.testing.refAllDecls(@import("browser/dump.zig")); + std.testing.refAllDecls(@import("browser/loader.zig")); + std.testing.refAllDecls(@import("browser/mime.zig")); + std.testing.refAllDecls(@import("cdp/msg.zig")); + std.testing.refAllDecls(@import("css/css.zig")); + std.testing.refAllDecls(@import("css/libdom_test.zig")); + std.testing.refAllDecls(@import("css/match_test.zig")); + std.testing.refAllDecls(@import("css/parser.zig")); + std.testing.refAllDecls(@import("generate.zig")); + std.testing.refAllDecls(@import("http/Client.zig")); + std.testing.refAllDecls(@import("msg.zig")); + std.testing.refAllDecls(@import("storage/storage.zig")); + std.testing.refAllDecls(@import("iterator/iterator.zig")); +} diff --git a/src/url/query.zig b/src/url/query.zig index 3b1f877f..5defb288 100644 --- a/src/url/query.zig +++ b/src/url/query.zig @@ -62,6 +62,7 @@ pub const Values = struct { // append by taking the ownership of the key and the value fn appendOwned(self: *Values, k: []const u8, v: []const u8) !void { if (self.map.getPtr(k)) |list| { + self.alloc.free(k); return try list.append(self.alloc, v); }