From 2c4661a250467019967f2ddd2c5a0584808e1569 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 7 Feb 2025 13:22:01 +0800 Subject: [PATCH] Add a new unittest build step Preserves all existing behavior (i.e. make test and zig build test are not changed in any way). The new 'unittest' only runs unit tests and is fast to build. It takes ~1.7 to build unittest, vs ~11.09 to build test. This is really the main goal, and hopefully any unit test which are (a) fast and (b) don't impact build times will be run here. The test runner is based on: https://gist.github.com/karlseguin/c6bea5b35e4e8d26af6f81c22cb5d76b It allow filtering, i.e. `make unittest F="parse query dup"`. 'unittest' does memory leak detection when tests use std.testing.allocator. Fixed a memory leak in url/query which was detected/reported with by the new 'unittest'. In order to avoid having 3 src/test_xyx.zig files, I merged the existing test_runner.zig and run_tests.zig into a single main_tests.zig. (this change is superfluous, but I thought it was cleaner this way. Happy to revert this). --- Makefile | 7 +- build.zig | 25 +- src/{run_tests.zig => main_tests.zig} | 0 src/test_runner.zig | 29 --- src/unit_tests.zig | 337 ++++++++++++++++++++++++++ src/url/query.zig | 1 + 6 files changed, 367 insertions(+), 32 deletions(-) rename src/{run_tests.zig => main_tests.zig} (100%) delete mode 100644 src/test_runner.zig create mode 100644 src/unit_tests.zig 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/run_tests.zig b/src/main_tests.zig similarity index 100% rename from src/run_tests.zig rename to src/main_tests.zig 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..3111b1d3 --- /dev/null +++ b/src/unit_tests.zig @@ -0,0 +1,337 @@ +// Changed Jan 29, 2025 to accomodate latest Zig changes +// See history if you're using an older version of Zig. + +// in your build.zig, you can specify a custom test runner: +// const tests = b.addTest(.{ +// .target = target, +// .optimize = optimize, +// .test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple }, // add this line +// .root_source_file = b.path("src/main.zig"), +// }); + +const std = @import("std"); +const builtin = @import("builtin"); + +const Allocator = std.mem.Allocator; + +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 printer = Printer.init(); + printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line + + for (builtin.test_functions) |t| { + if (isSetup(t)) { + t.func() catch |err| { + printer.status(.fail, "\nsetup \"{s}\" failed: {}\n", .{ t.name, err }); + return err; + }; + } + } + + 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; + } + + if (isSetup(t) or isTeardown(t)) { + 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, ".", .{}); + } + } + + for (builtin.test_functions) |t| { + if (isTeardown(t)) { + t.func() catch |err| { + printer.status(.fail, "\nteardown \"{s}\" failed: {}\n", .{ t.name, err }); + return err; + }; + } + } + + 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 isSetup(t: std.builtin.TestFn) bool { + return std.mem.endsWith(u8, t.name, "tests:beforeAll"); +} + +fn isTeardown(t: std.builtin.TestFn) bool { + return std.mem.endsWith(u8, t.name, "tests:afterAll"); +} + +test { + const msgTest = @import("msg.zig"); + std.testing.refAllDecls(msgTest); + + const dumpTest = @import("browser/dump.zig"); + std.testing.refAllDecls(dumpTest); + + const mimeTest = @import("browser/mime.zig"); + std.testing.refAllDecls(mimeTest); + + const cssTest = @import("css/css.zig"); + std.testing.refAllDecls(cssTest); + + const cssParserTest = @import("css/parser.zig"); + std.testing.refAllDecls(cssParserTest); + + const cssMatchTest = @import("css/match_test.zig"); + std.testing.refAllDecls(cssMatchTest); + + const cssLibdomTest = @import("css/libdom_test.zig"); + std.testing.refAllDecls(cssLibdomTest); + + const queryTest = @import("url/query.zig"); + std.testing.refAllDecls(queryTest); + + std.testing.refAllDecls(@import("generate.zig")); + std.testing.refAllDecls(@import("cdp/msg.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); }