Merge pull request #83 from jetzig-framework/test-helpers

Test helpers
This commit is contained in:
bobf 2024-06-03 21:58:20 +01:00 committed by GitHub
commit 072fc713c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1191 additions and 104 deletions

View File

@ -42,6 +42,11 @@ jobs:
- name: Run Tests
run: zig build test
- name: Run App Tests
with:
path: demo
run: zig build jetzig:test
- name: Build artifacts
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ zig-cache/
*.core
static/
.jetzig
.zig-cache/

View File

@ -40,9 +40,9 @@ If you are interested in _Jetzig_ you will probably find these tools interesting
* :white_check_mark: Background jobs.
* :white_check_mark: General-purpose cache.
* :white_check_mark: Development server auto-reload.
* :white_check_mark: Testing helpers for testing HTTP requests/responses.
* :white_check_mark: Custom/non-conventional routes.
* :x: Environment configurations (development/production/etc.)
* :x: Custom/non-conventional routes.
* :x: Testing helpers for testing HTTP requests/responses.
* :x: Database integration.
* :x: Email receipt (via SendGrid/AWS SES/etc.)

View File

@ -33,7 +33,6 @@ pub fn build(b: *std.Build) !void {
const mime_module = try GenerateMimeTypes.generateMimeModule(b);
const zig_args_dep = b.dependency("args", .{ .target = target, .optimize = optimize });
const jetzig_module = b.addModule("jetzig", .{ .root_source_file = b.path("src/jetzig.zig") });
jetzig_module.addImport("mime_types", mime_module);
lib.root_module.addImport("jetzig", jetzig_module);
@ -165,8 +164,10 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
mailers_path,
);
try generate_routes.generateRoutes();
const write_files = b.addWriteFiles();
const routes_file = write_files.add("routes.zig", generate_routes.buffer.items);
const routes_write_files = b.addWriteFiles();
const routes_file = routes_write_files.add("routes.zig", generate_routes.buffer.items);
const tests_write_files = b.addWriteFiles();
const tests_file = tests_write_files.add("tests.zig", generate_routes.buffer.items);
const routes_module = b.createModule(.{ .root_source_file = routes_file });
var src_dir = try std.fs.openDirAbsolute(b.pathFromRoot("src"), .{ .iterate = true });
@ -183,7 +184,8 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
const relpath = try std.fs.path.join(b.allocator, &[_][]const u8{ "src", entry.path });
defer b.allocator.free(relpath);
_ = write_files.add(relpath, src_data);
_ = routes_write_files.add(relpath, src_data);
_ = tests_write_files.add(relpath, src_data);
}
}
@ -196,12 +198,6 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
exe.root_module.addImport("routes", routes_module);
var it = exe.root_module.import_table.iterator();
while (it.next()) |import| {
routes_module.addImport(import.key_ptr.*, import.value_ptr.*);
exe_static_routes.root_module.addImport(import.key_ptr.*, import.value_ptr.*);
}
exe_static_routes.root_module.addImport("routes", routes_module);
exe_static_routes.root_module.addImport("jetzig", jetzig_module);
exe_static_routes.root_module.addImport("zmpl", zmpl_module);
@ -210,6 +206,28 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
const run_static_routes_cmd = b.addRunArtifact(exe_static_routes);
run_static_routes_cmd.expectExitCode(0);
exe.step.dependOn(&run_static_routes_cmd.step);
const exe_unit_tests = b.addTest(.{
.root_source_file = tests_file,
.target = target,
.optimize = optimize,
.test_runner = jetzig_dep.path("src/test_runner.zig"),
});
exe_unit_tests.root_module.addImport("jetzig", jetzig_module);
exe_unit_tests.root_module.addImport("__jetzig_project", &exe.root_module);
var it = exe.root_module.import_table.iterator();
while (it.next()) |import| {
routes_module.addImport(import.key_ptr.*, import.value_ptr.*);
exe_static_routes.root_module.addImport(import.key_ptr.*, import.value_ptr.*);
exe_unit_tests.root_module.addImport(import.key_ptr.*, import.value_ptr.*);
}
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
const test_step = b.step("jetzig:test", "Run tests");
test_step.dependOn(&run_exe_unit_tests.step);
exe_unit_tests.root_module.addImport("routes", routes_module);
}
fn generateMarkdownFragments(b: *std.Build) ![]const u8 {

View File

@ -7,8 +7,8 @@
.hash = "12203b56c2e17a2fd62ea3d3d9be466f43921a3aef88b381cf58f41251815205fdb5",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/aa4d8ad5b63976d96e3b2c187f5b0b2c693905a1.tar.gz",
.hash = "1220b6dfedaf6ad2b464ebcec2aafdc01ba593a07a53885033d56c50a5a04334b517",
.url = "https://github.com/jetzig-framework/zmpl/archive/7dac63a9470cf12a2cbaf8e6e3fc3aeb9ea27658.tar.gz",
.hash = "1220215354adcea94d36d25c300e291981d3f48c543f7da6e48bb2793a5aed85e768",
},
.jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/78bcdcc6b0cbd3ca808685c64554a15701f13250.tar.gz",

View File

@ -5,6 +5,7 @@ const update = @import("commands/update.zig");
const generate = @import("commands/generate.zig");
const server = @import("commands/server.zig");
const bundle = @import("commands/bundle.zig");
const tests = @import("commands/tests.zig");
const Options = struct {
help: bool = false,
@ -21,6 +22,7 @@ const Options = struct {
.generate = "Generate scaffolding",
.server = "Run a development server",
.bundle = "Create a deployment bundle",
.@"test" = "Run app tests",
.help = "Print help and exit",
},
};
@ -32,9 +34,11 @@ const Verb = union(enum) {
generate: generate.Options,
server: server.Options,
bundle: bundle.Options,
@"test": tests.Options,
g: generate.Options,
s: server.Options,
b: bundle.Options,
t: tests.Options,
};
/// Main entrypoint for `jetzig` executable. Parses command line args and generates a new
@ -67,6 +71,7 @@ pub fn main() !void {
\\ generate Generate scaffolding.
\\ server Run a development server.
\\ bundle Create a deployment bundle.
\\ test Run app tests.
\\
\\ Pass --help to any command for more information, e.g. `jetzig init --help`
\\
@ -112,6 +117,13 @@ fn run(allocator: std.mem.Allocator, options: args.ParseArgsResult(Options, Verb
options.positionals,
.{ .help = options.options.help },
),
.t, .@"test" => |opts| tests.run(
allocator,
opts,
writer,
options.positionals,
.{ .help = options.options.help },
),
};
}
}

View File

@ -68,6 +68,10 @@ pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, he
try writeTemplate(allocator, cwd, args[0], action);
}
for (actions.items) |action| {
try writeTest(allocator, writer, args[0], action);
}
var dir = try cwd.openDir("src/app/views", .{});
defer dir.close();
@ -141,6 +145,44 @@ fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !v
try writer.writeAll(function);
}
// Write a view function to the output buffer.
fn writeTest(allocator: std.mem.Allocator, writer: anytype, name: []const u8, action: Action) !void {
const action_upper = try std.ascii.allocUpperString(allocator, @tagName(action.method));
defer allocator.free(action_upper);
const test_body = try std.fmt.allocPrint(
allocator,
\\
\\test "{s}" {{
\\ var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
\\ defer app.deinit();
\\
\\ const response = try app.request(.{s}, "/{s}{s}", .{{}});
\\ try response.expectStatus({s});
\\}}
\\
,
.{
@tagName(action.method),
switch (action.method) {
.index, .get => "GET",
.put, .patch, .delete, .post => action_upper,
},
name,
switch (action.method) {
.index, .post => "",
.get, .put, .patch, .delete => "/example-id",
},
switch (action.method) {
.index, .get => ".ok",
.post => ".created",
.put, .patch, .delete => ".ok",
},
},
);
defer allocator.free(test_body);
try writer.writeAll(test_body);
}
// Output static params example. Only invoked if one or more static routes are created.
fn writeStaticParams(allocator: std.mem.Allocator, actions: []Action, writer: anytype) !void {
try writer.writeAll(

43
cli/commands/tests.zig Normal file
View File

@ -0,0 +1,43 @@
const std = @import("std");
const util = @import("../util.zig");
/// Command line options for the `generate` command.
pub const Options = struct {
@"fail-fast": bool = false,
pub const shorthands = .{
.f = "fail-fast",
};
pub const meta = .{
.usage_summary = "[--fail-fast]",
.full_text =
\\Run app tests.
\\
\\Execute all tests found in `src/main.zig`
\\
,
.option_docs = .{
.path = "Set the output path relative to the current directory (default: current directory)",
},
};
};
/// Run the job generator. Create a job in `src/app/jobs/`
pub fn run(
allocator: std.mem.Allocator,
options: Options,
writer: anytype,
positionals: [][]const u8,
other_options: struct { help: bool },
) !void {
_ = options;
_ = writer;
_ = positionals;
_ = other_options;
try util.execCommand(allocator, &.{
"zig",
"build",
"jetzig:test",
});
}

View File

@ -96,6 +96,33 @@ pub fn isCamelCase(input: []const u8) bool {
return true;
}
pub fn execCommand(allocator: std.mem.Allocator, argv: []const []const u8) !void {
std.debug.print("[exec]", .{});
for (argv) |arg| std.debug.print(" {s}", .{arg});
std.debug.print("\n", .{});
if (std.process.can_execv) {
return std.process.execv(allocator, argv);
} else {
var dir = try detectJetzigProjectDir();
defer dir.close();
const path = try dir.realpathAlloc(allocator, ".");
defer allocator.free(path);
try runCommandStreaming(allocator, path, argv);
}
}
pub fn runCommandStreaming(allocator: std.mem.Allocator, install_path: []const u8, argv: []const []const u8) !void {
var child = std.process.Child.init(argv, allocator);
child.stdin_behavior = .Ignore;
child.stdout_behavior = .Inherit;
child.stderr_behavior = .Inherit;
child.cwd = install_path;
try child.spawn();
_ = try child.wait();
}
/// Runs a command as a child process and verifies successful exit code.
pub fn runCommand(allocator: std.mem.Allocator, install_path: []const u8, argv: []const []const u8) !void {
const result = try std.process.Child.run(.{ .allocator = allocator, .argv = argv, .cwd = install_path });

View File

@ -22,3 +22,15 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
return request.render(.ok);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
_ = try app.request(.POST, "/cache", .{ .params = .{ .message = "test message" } });
const response = try app.request(.GET, "/cache", .{});
try response.expectBodyContains(
\\ <span>Cached value: test message</span>
);
}

View File

@ -17,12 +17,9 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
const params = try request.params();
const count = if (params.get("iguanas")) |param|
try std.fmt.parseInt(usize, param.string.value, 10)
else
10;
const count = params.getT(.integer, "iguanas") orelse 10;
const iguanas_slice = try iguanas.iguanas(request.allocator, count);
const iguanas_slice = try iguanas.iguanas(request.allocator, @intCast(count));
for (iguanas_slice) |iguana| {
try root.append(data.string(iguana));
@ -30,3 +27,19 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
return request.render(.ok);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/iguanas", .{ .json = .{ .iguanas = 10 } });
try response.expectJson(".1", "iguana");
try response.expectJson(".2", "iguana");
try response.expectJson(".3", "iguana");
try response.expectJson(".4", "iguana");
try response.expectJson(".5", "iguana");
try response.expectJson(".6", "iguana");
try response.expectJson(".7", "iguana");
try response.expectJson(".8", "iguana");
try response.expectJson(".9", "iguana");
}

View File

@ -1,3 +1,4 @@
const std = @import("std");
const jetzig = @import("jetzig");
/// This example demonstrates usage of Jetzig's KV store.
@ -29,3 +30,18 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
return request.render(.ok);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response1 = try app.request(.GET, "/kvstore.json", .{});
try response1.expectStatus(.ok);
try response1.expectJson(".stored_string", null);
const response2 = try app.request(.GET, "/kvstore.json", .{});
try response2.expectJson(".stored_string", "example-value");
try response2.expectJson(".popped", "hello");
try (try app.request(.GET, "/kvstore.json", .{})).expectJson(".popped", "goodbye");
try (try app.request(.GET, "/kvstore.json", .{})).expectJson(".popped", "hello again");
}

View File

@ -7,3 +7,10 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/markdown", .{});
try response.expectBodyContains("You can still use <i>Zmpl</i> references, modes, and partials.");
}

View File

@ -40,3 +40,11 @@ fn randomQuote(allocator: std.mem.Allocator) !Quote {
const quotes = try std.json.parseFromSlice([]Quote, allocator, json, .{});
return quotes.value[std.crypto.random.intRangeLessThan(usize, 0, quotes.value.len)];
}
test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/quotes/initial", .{});
try response.expectBodyContains("If you can dream it, you can achieve it.");
}

View File

@ -1,3 +1,4 @@
const std = @import("std");
const jetzig = @import("jetzig");
const importedFunction = @import("../lib/example.zig").exampleFunction;
@ -18,3 +19,65 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
fn customFunction(a: i32, b: i32, c: i32) i32 {
return a + b + c;
}
test "404 Not Found" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/foobar", .{});
try response.expectStatus(.not_found);
}
test "200 OK" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/", .{});
try response.expectStatus(.ok);
}
test "response body" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/", .{});
try response.expectBodyContains("Welcome to Jetzig!");
}
test "header" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/", .{});
try response.expectHeader("content-type", "text/html");
}
test "json" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/.json", .{});
try response.expectJson(".message", "Welcome to Jetzig!");
try response.expectJson(".custom_number", 600);
}
test "json from header" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(
.GET,
"/",
.{ .headers = &.{.{ .name = "accept", .value = "application/json" }} },
);
try response.expectJson(".message", "Welcome to Jetzig!");
try response.expectJson(".custom_number", 600);
}
test "public file" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/jetzig.png", .{});
try response.expectStatus(.ok);
}

View File

@ -16,7 +16,7 @@
///
/// ```console
/// curl -H "Accept: application/json" \
/// --data-bin '{"foo":"hello", "bar":"goodbye"}' \
/// --data-binary '{"foo":"hello", "bar":"goodbye"}' \
/// --request GET \
/// 'http://localhost:8080/static'
/// ```
@ -43,18 +43,18 @@ pub const static_params = .{
};
pub fn index(request: *jetzig.StaticRequest, data: *jetzig.Data) !jetzig.View {
var root = try data.object();
var root = try data.root(.object);
const params = try request.params();
if (params.get("foo")) |foo| try root.put("foo", foo);
if (params.get("bar")) |foo| try root.put("bar", foo);
try root.put("foo", params.get("foo"));
try root.put("bar", params.get("bar"));
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.StaticRequest, data: *jetzig.Data) !jetzig.View {
var root = try data.object();
var root = try data.root(.object);
const params = try request.params();
@ -67,3 +67,59 @@ pub fn get(id: []const u8, request: *jetzig.StaticRequest, data: *jetzig.Data) !
return request.render(.created);
}
test "index json" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(
.GET,
"/static.json",
.{ .json = .{ .foo = "hello", .bar = "goodbye" } },
);
try response.expectStatus(.ok);
try response.expectJson(".foo", "hello");
try response.expectJson(".bar", "goodbye");
}
test "get json" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(
.GET,
"/static/1.json",
.{ .json = .{ .foo = "hi", .bar = "bye" } },
);
try response.expectStatus(.ok);
try response.expectJson(".id", "id is '1'");
}
test "index html" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(
.GET,
"/static.html",
.{ .params = .{ .foo = "hello", .bar = "goodbye" } },
);
try response.expectStatus(.ok);
try response.expectBodyContains("hello");
}
test "get html" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(
.GET,
"/static/1.html",
.{ .params = .{ .foo = "hi", .bar = "bye" } },
);
try response.expectStatus(.ok);
}

View File

@ -11,6 +11,7 @@ mailers_path: []const u8,
buffer: std.ArrayList(u8),
dynamic_routes: std.ArrayList(Function),
static_routes: std.ArrayList(Function),
module_paths: std.ArrayList([]const u8),
data: *jetzig.data.Data,
const Routes = @This();
@ -107,6 +108,7 @@ pub fn init(
.buffer = std.ArrayList(u8).init(allocator),
.static_routes = std.ArrayList(Function).init(allocator),
.dynamic_routes = std.ArrayList(Function).init(allocator),
.module_paths = std.ArrayList([]const u8).init(allocator),
.data = data,
};
}
@ -157,6 +159,24 @@ pub fn generateRoutes(self: *Routes) !void {
\\
);
try writer.writeAll(
\\test {
\\
);
for (self.module_paths.items) |module_path| {
try writer.print(
\\ _ = @import("{s}");
\\
, .{module_path});
}
try writer.writeAll(
\\ @import("std").testing.refAllDeclsRecursive(@This());
\\}
\\
);
// std.debug.print("routes.zig\n{s}\n", .{self.buffer.items});
}
@ -272,6 +292,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
);
std.mem.replaceScalar(u8, module_path, '\\', '/');
try self.module_paths.append(try self.allocator.dupe(u8, module_path));
const output = try std.fmt.allocPrint(self.allocator, output_template, .{
full_name,
@ -325,7 +346,7 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
const decl = self.ast.simpleVarDecl(asNodeIndex(index));
if (self.isStaticParamsDecl(decl)) {
self.data.reset();
const params = try self.data.object();
const params = try self.data.root(.object);
try self.parseStaticParamsDecl(decl, params);
static_params = self.data.value;
}
@ -340,7 +361,7 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
if (static_params) |capture| {
if (capture.get(static_route.name)) |params| {
for (params.array.array.items) |item| { // XXX: Use public interface for Data.Array here ?
for (params.items(.array)) |item| {
const json = try item.toJson();
var encoded_buf = std.ArrayList(u8).init(self.allocator);
defer encoded_buf.deinit();
@ -399,19 +420,19 @@ fn parseArray(self: *Routes, node: std.zig.Ast.Node.Index, params: *jetzig.data.
const main_token = self.ast.nodes.items(.main_token)[node];
const field_name = self.ast.tokenSlice(main_token - 3);
const params_array = try self.data.createArray();
const params_array = try self.data.array();
try params.put(field_name, params_array);
for (array.ast.elements) |element| {
const elem = self.ast.nodes.items(.tag)[element];
switch (elem) {
.struct_init_dot, .struct_init_dot_two, .struct_init_dot_two_comma => {
const route_params = try self.data.createObject();
const route_params = try self.data.object();
try params_array.append(route_params);
try self.parseStruct(element, route_params);
},
.array_init_dot, .array_init_dot_two, .array_init_dot_comma, .array_init_dot_two_comma => {
const route_params = try self.data.createObject();
const route_params = try self.data.object();
try params_array.append(route_params);
try self.parseField(element, route_params);
},
@ -420,7 +441,7 @@ fn parseArray(self: *Routes, node: std.zig.Ast.Node.Index, params: *jetzig.data.
const string_value = self.ast.tokenSlice(string_token);
// Strip quotes: `"foo"` -> `foo`
try params_array.append(self.data.string(string_value[1 .. string_value.len - 1]));
try params_array.append(string_value[1 .. string_value.len - 1]);
},
.number_literal => {
const number_token = self.ast.nodes.items(.main_token)[element];
@ -445,7 +466,7 @@ fn parseField(self: *Routes, node: std.zig.Ast.Node.Index, params: *jetzig.data.
try self.parseArray(node, params);
},
.struct_init_dot, .struct_init_dot_two, .struct_init_dot_two_comma => {
const nested_params = try self.data.createObject();
const nested_params = try self.data.object();
const main_token = self.ast.nodes.items(.main_token)[node];
const field_name = self.ast.tokenSlice(main_token - 3);
try params.put(field_name, nested_params);
@ -460,7 +481,7 @@ fn parseField(self: *Routes, node: std.zig.Ast.Node.Index, params: *jetzig.data.
try params.put(
field_name,
// strip outer quotes
self.data.string(field_value[1 .. field_value.len - 1]),
field_value[1 .. field_value.len - 1],
);
},
.number_literal => {

View File

@ -16,6 +16,7 @@ pub const markdown = @import("jetzig/markdown.zig");
pub const jobs = @import("jetzig/jobs.zig");
pub const mail = @import("jetzig/mail.zig");
pub const kv = @import("jetzig/kv.zig");
pub const testing = @import("jetzig/testing.zig");
/// The primary interface for a Jetzig application. Create an `App` in your application's
/// `src/main.zig` and call `start` to launch the application.

View File

@ -29,28 +29,8 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
defer mime_map.deinit();
try mime_map.build();
var routes = std.ArrayList(*jetzig.views.Route).init(self.allocator);
for (routes_module.routes) |const_route| {
var var_route = try self.allocator.create(jetzig.views.Route);
var_route.* = .{
.name = const_route.name,
.action = const_route.action,
.view_name = const_route.view_name,
.uri_path = const_route.uri_path,
.view = const_route.view,
.static = const_route.static,
.layout = const_route.layout,
.template = const_route.template,
.json_params = const_route.json_params,
};
try var_route.initParams(self.allocator);
try routes.append(var_route);
}
defer routes.deinit();
defer for (routes.items) |var_route| {
const routes = try createRoutes(self.allocator, &routes_module.routes);
defer for (routes) |var_route| {
var_route.deinitParams();
self.allocator.destroy(var_route);
};
@ -107,7 +87,7 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
var server = jetzig.http.Server.init(
self.allocator,
server_options,
routes.items,
routes,
self.custom_routes.items,
&routes_module.jobs,
&routes_module.mailers,
@ -124,7 +104,7 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
.{
.logger = server.logger,
.environment = server.options.environment,
.routes = routes.items,
.routes = routes,
.jobs = &routes_module.jobs,
.mailers = &routes_module.mailers,
.store = &store,
@ -203,3 +183,30 @@ inline fn viewType(path: []const u8) enum { with_id, without_id, with_args } {
return .without_id;
}
pub fn createRoutes(
allocator: std.mem.Allocator,
comptime_routes: []const jetzig.views.Route,
) ![]*jetzig.views.Route {
var routes = std.ArrayList(*jetzig.views.Route).init(allocator);
for (comptime_routes) |const_route| {
var var_route = try allocator.create(jetzig.views.Route);
var_route.* = .{
.name = const_route.name,
.action = const_route.action,
.view_name = const_route.view_name,
.uri_path = const_route.uri_path,
.view = const_route.view,
.static = const_route.static,
.layout = const_route.layout,
.template = const_route.template,
.json_params = const_route.json_params,
};
try var_route.initParams(allocator);
try routes.append(var_route);
}
return try routes.toOwnedSlice();
}

View File

@ -8,7 +8,7 @@ const Environment = @This();
allocator: std.mem.Allocator,
pub const EnvironmentName = enum { development, production };
pub const EnvironmentName = enum { development, production, testing };
const Options = struct {
help: bool = false,
@ -170,6 +170,7 @@ fn getSecret(self: Environment, logger: *jetzig.loggers.Logger, comptime len: u1
fn resolveLogLevel(level: ?jetzig.loggers.LogLevel, environment: EnvironmentName) jetzig.loggers.LogLevel {
return level orelse switch (environment) {
.testing => .DEBUG,
.development => .DEBUG,
.production => .INFO,
};

View File

@ -132,10 +132,14 @@ pub fn init(
}
pub fn deinit(self: *Request) void {
self.session.deinit();
self.cookies.deinit();
self.allocator.destroy(self.cookies);
self.allocator.destroy(self.session);
if (self._session) |*capture| {
capture.*.deinit();
self.allocator.destroy(capture.*);
}
if (self._cookies) |*capture| {
capture.*.deinit();
self.allocator.destroy(capture.*);
}
if (self.state != .initial) self.allocator.free(self.body);
}

View File

@ -12,6 +12,7 @@ headers: jetzig.http.Headers,
content: []const u8,
status_code: http.status_codes.StatusCode,
content_type: []const u8,
httpz_response: *httpz.Response,
pub fn init(
allocator: std.mem.Allocator,
@ -19,15 +20,10 @@ pub fn init(
) !Self {
return .{
.allocator = allocator,
.httpz_response = httpz_response,
.status_code = .no_content,
.content_type = "application/octet-stream",
.content = "",
.headers = jetzig.http.Headers.init(allocator, &httpz_response.headers),
};
}
pub fn deinit(self: *const Self) void {
self.headers.deinit();
self.allocator.destroy(self.headers);
self.std_response.deinit();
}

View File

@ -111,12 +111,12 @@ pub fn errorHandlerFn(self: *Server, request: *httpz.Request, response: *httpz.R
self.logger.ERROR("Encountered error: {s} {s}", .{ @errorName(err), request.url.raw }) catch {};
const stack = @errorReturnTrace();
if (stack) |capture| self.logStackTrace(capture, .{ .httpz = request }) catch {};
if (stack) |capture| self.logStackTrace(capture, request.arena) catch {};
response.body = "500 Internal Server Error";
}
fn processNextRequest(
pub fn processNextRequest(
self: *Server,
httpz_request: *httpz.Request,
httpz_response: *httpz.Response,
@ -442,6 +442,12 @@ fn renderErrorView(
_ = route.render(route.*, request) catch |err| {
if (isUnhandledError(err)) return err;
try self.logger.logError(err);
try self.logger.ERROR(
"Unexepected error occurred while rendering error page: {s}",
.{@errorName(err)},
);
const stack = @errorReturnTrace();
if (stack) |capture| try self.logStackTrace(capture, request.allocator);
return try renderDefaultError(request, status_code);
};
@ -508,13 +514,8 @@ fn renderDefaultError(
fn logStackTrace(
self: Server,
stack: *std.builtin.StackTrace,
request: union(enum) { jetzig: *const jetzig.http.Request, httpz: *const httpz.Request },
allocator: std.mem.Allocator,
) !void {
const allocator = switch (request) {
.jetzig => |capture| capture.allocator,
.httpz => |capture| capture.arena,
};
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const writer = buf.writer();
@ -657,19 +658,12 @@ fn staticPath(request: *jetzig.http.Request, route: jetzig.views.Route) !?[]cons
};
for (route.params.items, 0..) |static_params, index| {
const expected_params = try static_params.getValue("params");
const expected_params = static_params.get("params");
switch (route.action) {
.index, .post => {},
inline else => {
const id = try static_params.getValue("id");
if (id == null) return error.JetzigRouteError; // `routes.zig` is incoherent.
switch (id.?.*) {
.string => |capture| {
if (!std.mem.eql(u8, capture.value, request.path.resource_id)) continue;
},
// `routes.zig` is incoherent.
inline else => return error.JetzigRouteError,
}
const id = static_params.getT(.string, "id") orelse return error.JetzigRouteError;
if (!std.mem.eql(u8, id, request.path.resource_id)) continue;
},
}
if (expected_params != null and !expected_params.?.eql(params)) continue;

View File

@ -2,11 +2,6 @@ const std = @import("std");
const jetzig = @import("../jetzig.zig");
pub const StatusCode = enum {
ok,
not_found,
};
pub fn StatusCodeType(comptime code: []const u8, comptime message: []const u8) type {
return struct {
code: []const u8 = code,
@ -33,16 +28,3 @@ pub fn StatusCodeType(comptime code: []const u8, comptime message: []const u8) t
}
};
}
pub const StatusCodeUnion = union(StatusCode) {
ok: StatusCode("200", "OK"),
not_found: StatusCode("404", "Not Found"),
const Self = @This();
pub fn format(self: Self) []const u8 {
return switch (self) {
inline else => |case| case.format(),
};
}
};

View File

@ -1,6 +1,5 @@
pub const Job = @import("jobs/Job.zig");
pub const JobEnv = Job.JobEnv;
pub const JobConfig = Job.JobConfig;
pub const JobDefinition = Job.JobDefinition;
pub const Pool = @import("jobs/Pool.zig");
pub const Worker = @import("jobs/Worker.zig");

View File

@ -6,6 +6,7 @@ const Self = @This();
pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig");
pub const JsonLogger = @import("loggers/JsonLogger.zig");
pub const TestLogger = @import("loggers/TestLogger.zig");
pub const LogQueue = @import("loggers/LogQueue.zig");
pub const LogLevel = enum(u4) { TRACE, DEBUG, INFO, WARN, ERROR, FATAL };
@ -21,6 +22,7 @@ pub inline fn logTarget(comptime level: LogLevel) LogQueue.Target {
pub const Logger = union(enum) {
development_logger: DevelopmentLogger,
json_logger: JsonLogger,
test_logger: TestLogger,
/// Log a TRACE level message to the configured logger.
pub fn TRACE(self: *const Logger, comptime message: []const u8, args: anytype) !void {

View File

@ -0,0 +1,57 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const TestLogger = @This();
enabled: bool = false,
pub fn TRACE(self: TestLogger, comptime message: []const u8, args: anytype) !void {
try self.log(.TRACE, message, args);
}
pub fn DEBUG(self: TestLogger, comptime message: []const u8, args: anytype) !void {
try self.log(.DEBUG, message, args);
}
pub fn INFO(self: TestLogger, comptime message: []const u8, args: anytype) !void {
try self.log(.INFO, message, args);
}
pub fn WARN(self: TestLogger, comptime message: []const u8, args: anytype) !void {
try self.log(.WARN, message, args);
}
pub fn ERROR(self: TestLogger, comptime message: []const u8, args: anytype) !void {
try self.log(.ERROR, message, args);
}
pub fn FATAL(self: TestLogger, comptime message: []const u8, args: anytype) !void {
try self.log(.FATAL, message, args);
}
pub fn logRequest(self: TestLogger, request: *const jetzig.http.Request) !void {
const status = jetzig.http.status_codes.get(request.response.status_code);
var buf: [256]u8 = undefined;
try self.log(.INFO, "[{s}|{s}|{s}] {s}", .{
request.fmtMethod(true),
try jetzig.colors.duration(&buf, jetzig.util.duration(request.start_time), true),
status.getFormatted(.{ .colorized = true }),
request.path.path,
});
}
pub fn logError(self: TestLogger, err: anyerror) !void {
try self.log(.ERROR, "Encountered error: {s}", .{@errorName(err)});
}
pub fn log(
self: TestLogger,
comptime level: jetzig.loggers.LogLevel,
comptime message: []const u8,
args: anytype,
) !void {
if (self.enabled) {
std.debug.print("-- test logger: " ++ @tagName(level) ++ " " ++ message ++ "\n", args);
}
}

249
src/jetzig/testing.zig Normal file
View File

@ -0,0 +1,249 @@
const std = @import("std");
const jetzig = @import("../jetzig.zig");
const zmpl = @import("zmpl");
const httpz = @import("httpz");
/// An app used for testing. Processes requests and renders responses.
pub const App = @import("testing/App.zig");
const testing = @This();
/// Pre-built mime map, assigned by Jetzig test runner.
pub var mime_map: *jetzig.http.mime.MimeMap = undefined;
pub var state: enum { initial, ready } = .initial;
pub const secret = "secret-bytes-for-use-in-test-environment-only";
pub const app = App.init;
pub const TestResponse = struct {
allocator: std.mem.Allocator,
status: u16,
body: []const u8,
headers: []const Header,
jobs: []Job,
pub const Header = struct { name: []const u8, value: []const u8 };
pub const Job = struct { name: []const u8, params: ?[]const u8 = null };
pub fn expectStatus(self: TestResponse, comptime expected: jetzig.http.status_codes.StatusCode) !void {
try testing.expectStatus(expected, self);
}
pub fn expectBodyContains(self: TestResponse, comptime expected: []const u8) !void {
try testing.expectBodyContains(expected, self);
}
pub fn expectJson(self: TestResponse, expected_path: []const u8, expected_value: anytype) !void {
try testing.expectJson(expected_path, expected_value, self);
}
pub fn expectHeader(self: TestResponse, expected_name: []const u8, expected_value: ?[]const u8) !void {
try testing.expectHeader(expected_name, expected_value, self);
}
pub fn expectRedirect(self: TestResponse, path: []const u8) !void {
try testing.expectRedirect(path, self);
}
pub fn expectJob(self: TestResponse, job_name: []const u8, job_params: anytype) !void {
try testing.expectJob(job_name, job_params, self);
}
};
pub fn expectStatus(comptime expected: jetzig.http.status_codes.StatusCode, response: TestResponse) !void {
const expected_code = try jetzig.http.status_codes.get(expected).getCodeInt();
if (response.status != expected_code) {
log("Expected status: `{}`, actual status: `{}`", .{ expected_code, response.status });
return error.JetzigExpectStatusError;
}
}
pub fn expectBodyContains(expected: []const u8, response: TestResponse) !void {
if (!std.mem.containsAtLeast(u8, response.body, 1, expected)) {
log(
"Expected content:\n========\n{s}\n========\n\nActual content:\n========\n{s}\n========",
.{ expected, response.body },
);
return error.JetzigExpectBodyContainsError;
}
}
pub fn expectHeader(expected_name: []const u8, expected_value: ?[]const u8, response: TestResponse) !void {
for (response.headers) |header| {
if (!std.ascii.eqlIgnoreCase(header.name, expected_name)) continue;
if (expected_value) |value| {
if (std.mem.eql(u8, header.value, value)) return;
} else {
return;
}
}
return error.JetzigExpectHeaderError;
}
pub fn expectRedirect(path: []const u8, response: TestResponse) !void {
if (response.status != 301 or response.status != 302) return error.JetzigExpectRedirectError;
try expectHeader("location", path, response);
}
pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: TestResponse) !void {
var data = zmpl.Data.init(response.allocator);
data.fromJson(response.body) catch |err| {
switch (err) {
error.SyntaxError => {
log("Expected JSON, encountered parser error.", .{});
return error.JetzigExpectJsonError;
},
else => return err,
}
};
const json_banner = "\n======|json|======\n{s}\n======|/json|=====\n";
if (try data.getValue(std.mem.trimLeft(u8, expected_path, &.{'.'}))) |value| {
switch (value.*) {
.string => |string| switch (@typeInfo(@TypeOf(expected_value))) {
.Pointer, .Array => {
if (std.mem.eql(u8, string.value, expected_value)) return;
},
.Null => {
log(
"Expected null/non-existent value for `{s}`, found: `{s}`",
.{ expected_path, string.value },
);
return error.JetzigExpectJsonError;
},
else => unreachable,
},
.integer => |integer| switch (@typeInfo(@TypeOf(expected_value))) {
.Int, .ComptimeInt => {
if (integer.value == expected_value) return;
},
.Null => {
log(
"Expected null/non-existent value for `{s}`, found: `{}`",
.{ expected_path, integer.value },
);
return error.JetzigExpectJsonError;
},
else => {},
},
.float => |float| switch (@typeInfo(@TypeOf(expected_value))) {
.Float, .ComptimeFloat => {
if (float.value == expected_value) return;
},
.Null => {
log(
"Expected null/non-existent value for `{s}`, found: `{}`",
.{ expected_path, float.value },
);
return error.JetzigExpectJsonError;
},
else => {},
},
.boolean => |boolean| switch (@typeInfo(@TypeOf(expected_value))) {
.Bool => {
if (boolean.value == expected_value) return;
},
.Null => {
log(
"Expected null/non-existent value for `{s}`, found: `{}`",
.{ expected_path, boolean.value },
);
return error.JetzigExpectJsonError;
},
else => {},
},
.Null => switch (@typeInfo(@TypeOf(expected_value))) {
.Optional => {
if (expected_value == null) return;
},
.Null => {
return;
},
else => {},
},
else => {},
}
switch (value.*) {
.string => |string| {
switch (@typeInfo(@TypeOf(expected_value))) {
.Pointer, .Array => {
log(
"Expected `{s}` in `{s}`, found `{s}` in JSON:" ++ json_banner,
.{ expected_value, expected_path, string.value, response.body },
);
},
else => unreachable,
}
},
.integer,
=> |integer| {
switch (@typeInfo(@TypeOf(expected_value))) {
.Int, .ComptimeInt => {
log(
"Expected `{}` in `{s}`, found `{}` in JSON:" ++ json_banner,
.{ expected_value, expected_path, integer.value, response.body },
);
},
else => unreachable,
}
},
.float => |float| {
switch (@typeInfo(@TypeOf(expected_value))) {
.Float, .ComptimeFloat => {
log(
"Expected `{}` in `{s}`, found `{}` in JSON:" ++ json_banner,
.{ expected_value, expected_path, float.value, response.body },
);
},
else => unreachable,
}
},
.boolean => |boolean| {
switch (@typeInfo(@TypeOf(expected_value))) {
.Bool => {
log(
"Expected `{}` in `{s}`, found `{}` in JSON:" ++ json_banner,
.{ expected_value, expected_path, boolean.value, response.body },
);
},
else => unreachable,
}
},
.Null => {
log(
"Expected value in `{s}`, found `null` in JSON:" ++ json_banner,
.{ expected_path, response.body },
);
},
else => unreachable,
}
} else {
log(
"Path not found: `{s}` in JSON: " ++ json_banner,
.{ expected_path, response.body },
);
}
return error.JetzigExpectJsonError;
}
pub fn expectJob(job_name: []const u8, job_params: anytype, response: TestResponse) !void {
for (response.jobs) |job| {
comptime var has_args = false;
inline for (@typeInfo(@TypeOf(job_params)).Struct.fields) |field| {
has_args = true;
_ = field;
}
if (!has_args and std.mem.eql(u8, job_name, job.name)) return;
}
return error.JetzigExpectJobError;
}
fn log(comptime message: []const u8, args: anytype) void {
std.debug.print("[jetzig.testing] " ++ message ++ "\n", args);
}

234
src/jetzig/testing/App.zig Normal file
View File

@ -0,0 +1,234 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const httpz = @import("httpz");
const App = @This();
allocator: std.mem.Allocator,
routes: []const jetzig.views.Route,
arena: *std.heap.ArenaAllocator,
store: *jetzig.kv.Store,
cache: *jetzig.kv.Store,
job_queue: *jetzig.kv.Store,
pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
switch (jetzig.testing.state) {
.ready => {},
.initial => {
std.log.err(
"Unexpected state. Use Jetzig test runner: `zig build jetzig:test` or `jetzig test`",
.{},
);
std.process.exit(1);
},
}
const arena = try allocator.create(std.heap.ArenaAllocator);
arena.* = std.heap.ArenaAllocator.init(allocator);
return .{
.arena = arena,
.allocator = allocator,
.routes = &routes_module.routes,
.store = try createStore(arena.allocator()),
.cache = try createStore(arena.allocator()),
.job_queue = try createStore(arena.allocator()),
};
}
pub fn deinit(self: *App) void {
self.arena.deinit();
self.allocator.destroy(self.arena);
}
const RequestOptions = struct {
headers: []const jetzig.testing.TestResponse.Header = &.{},
json: ?[]const u8 = null,
params: ?[]Param = null,
};
const Param = struct {
key: []const u8,
value: ?[]const u8,
};
pub fn request(
self: *App,
comptime method: jetzig.http.Request.Method,
comptime path: []const u8,
args: anytype,
) !jetzig.testing.TestResponse {
const options = buildOptions(self, args);
const allocator = self.arena.allocator();
const routes = try jetzig.App.createRoutes(allocator, self.routes);
const logger = jetzig.loggers.Logger{ .test_logger = jetzig.loggers.TestLogger{} };
var log_queue = jetzig.loggers.LogQueue.init(allocator);
var server = jetzig.http.Server{
.allocator = allocator,
.logger = logger,
.options = .{
.logger = logger,
.bind = undefined,
.port = undefined,
.detach = false,
.environment = .testing,
.log_queue = &log_queue,
.secret = jetzig.testing.secret,
},
.routes = routes,
.custom_routes = &.{},
.mailer_definitions = &.{},
.job_definitions = &.{},
.mime_map = jetzig.testing.mime_map,
.store = self.store,
.cache = self.cache,
.job_queue = self.job_queue,
};
var buf: [1024]u8 = undefined;
var httpz_request = try stubbedRequest(allocator, &buf, method, path, options);
var httpz_response = try stubbedResponse(allocator);
try server.processNextRequest(&httpz_request, &httpz_response);
var headers = std.ArrayList(jetzig.testing.TestResponse.Header).init(self.arena.allocator());
for (0..httpz_response.headers.len) |index| {
try headers.append(.{
.name = try self.arena.allocator().dupe(u8, httpz_response.headers.keys[index]),
.value = try self.arena.allocator().dupe(u8, httpz_response.headers.values[index]),
});
}
var data = jetzig.data.Data.init(allocator);
defer data.deinit();
var jobs = std.ArrayList(jetzig.testing.TestResponse.Job).init(self.arena.allocator());
while (try self.job_queue.popFirst(&data, "__jetzig_jobs")) |value| {
if (value.getT(.string, "__jetzig_job_name")) |job_name| try jobs.append(.{
.name = try self.arena.allocator().dupe(u8, job_name),
});
}
return .{
.allocator = self.arena.allocator(),
.status = httpz_response.status,
.body = try self.arena.allocator().dupe(u8, httpz_response.body orelse ""),
.headers = try headers.toOwnedSlice(),
.jobs = try jobs.toOwnedSlice(),
};
}
pub fn params(self: App, args: anytype) []Param {
const allocator = self.arena.allocator();
var array = std.ArrayList(Param).init(allocator);
inline for (@typeInfo(@TypeOf(args)).Struct.fields) |field| {
array.append(.{ .key = field.name, .value = @field(args, field.name) }) catch @panic("OOM");
}
return array.toOwnedSlice() catch @panic("OOM");
}
pub fn json(self: App, args: anytype) []const u8 {
const allocator = self.arena.allocator();
return std.json.stringifyAlloc(allocator, args, .{}) catch @panic("OOM");
}
fn stubbedRequest(
allocator: std.mem.Allocator,
buf: []u8,
comptime method: jetzig.http.Request.Method,
comptime path: []const u8,
options: RequestOptions,
) !httpz.Request {
var request_headers = try keyValue(allocator, 32);
for (options.headers) |header| request_headers.add(header.name, header.value);
if (options.json != null) {
request_headers.add("accept", "application/json");
request_headers.add("content-type", "application/json");
}
var params_buf = std.ArrayList([]const u8).init(allocator);
if (options.params) |array| {
for (array) |param| {
try params_buf.append(
try std.fmt.allocPrint(allocator, "{s}{s}{s}", .{
param.key,
if (param.value != null) "=" else "",
param.value orelse "",
}),
);
}
}
const query = try std.mem.join(allocator, "&", try params_buf.toOwnedSlice());
return .{
.url = .{
.raw = try std.mem.concat(allocator, u8, &.{ path, if (query.len > 0) "?" else "", query }),
.path = path,
.query = query,
},
.address = undefined,
.method = std.enums.nameCast(httpz.Method, @tagName(method)),
.protocol = .HTTP11,
.params = undefined,
.headers = request_headers,
.body_buffer = if (options.json) |capture| .{ .data = @constCast(capture), .type = .static } else null,
.body_len = if (options.json) |capture| capture.len else 0,
.qs = try keyValue(allocator, 32),
.fd = try keyValue(allocator, 32),
.mfd = try multiFormKeyValue(allocator, 32),
.spare = buf,
.arena = allocator,
};
}
fn stubbedResponse(allocator: std.mem.Allocator) !httpz.Response {
return .{
.conn = undefined,
.pos = 0,
.status = 200,
.headers = try keyValue(allocator, 32),
.content_type = null,
.arena = allocator,
.written = false,
.chunked = false,
.disowned = false,
.keepalive = false,
.body = null,
};
}
fn keyValue(allocator: std.mem.Allocator, max: usize) !httpz.key_value.KeyValue {
return try httpz.key_value.KeyValue.init(allocator, max);
}
fn multiFormKeyValue(allocator: std.mem.Allocator, max: usize) !httpz.key_value.MultiFormKeyValue {
return try httpz.key_value.MultiFormKeyValue.init(allocator, max);
}
fn createStore(allocator: std.mem.Allocator) !*jetzig.kv.Store {
const store = try allocator.create(jetzig.kv.Store);
store.* = try jetzig.kv.Store.init(allocator, .{});
return store;
}
fn buildOptions(app: *const App, args: anytype) RequestOptions {
const fields = switch (@typeInfo(@TypeOf(args))) {
.Struct => |info| info.fields,
else => @compileError("Expected struct, found `" ++ @tagName(@typeInfo(@TypeOf(args))) ++ "`"),
};
inline for (fields) |field| {
comptime {
if (std.mem.eql(u8, field.name, "headers")) continue;
if (std.mem.eql(u8, field.name, "json")) continue;
if (std.mem.eql(u8, field.name, "params")) continue;
}
@compileError("Unrecognized request option: " ++ field.name);
}
return .{
.headers = if (@hasField(@TypeOf(args), "headers")) args.headers else &.{},
.json = if (@hasField(@TypeOf(args), "json")) app.json(args.json) else null,
.params = if (@hasField(@TypeOf(args), "params")) app.params(args.params) else null,
};
}

View File

@ -70,3 +70,18 @@ pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const
pub fn duration(start_time: i128) i64 {
return @intCast(std.time.nanoTimestamp() - start_time);
}
/// Generate a random variable name with enough entropy to be considered unique.
pub fn generateVariableName(buf: *[32]u8) []const u8 {
const first_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const any_chars = "0123456789" ++ first_chars;
for (0..3) |index| {
buf[index] = first_chars[std.crypto.random.intRangeAtMost(u8, 0, first_chars.len - 1)];
}
for (3..32) |index| {
buf[index] = any_chars[std.crypto.random.intRangeAtMost(u8, 0, any_chars.len - 1)];
}
return buf[0..32];
}

202
src/test_runner.zig Normal file
View File

@ -0,0 +1,202 @@
const std = @import("std");
const builtin = @import("builtin");
const jetzig = @import("jetzig");
const Test = struct {
name: []const u8,
function: TestFn,
module: ?[]const u8 = null,
leaked: bool = false,
result: Result = .success,
stack_trace_buf: [4096]u8 = undefined,
duration: usize = 0,
pub const TestFn = *const fn () anyerror!void;
pub const Result = union(enum) {
success: void,
failure: Failure,
skipped: void,
};
const Failure = struct {
err: anyerror,
trace: ?[]const u8,
};
const name_template = jetzig.colors.blue("{s}") ++ ":" ++ jetzig.colors.cyan("{s}") ++ " ";
pub fn init(test_fn: std.builtin.TestFn) Test {
return if (std.mem.indexOf(u8, test_fn.name, ".test.")) |index|
.{
.function = test_fn.func,
.module = test_fn.name[0..index],
.name = test_fn.name[index + ".test.".len ..],
}
else
.{ .function = test_fn.func, .name = test_fn.name };
}
pub fn run(self: *Test) !void {
std.testing.allocator_instance = .{};
const start = std.time.nanoTimestamp();
self.function() catch |err| {
switch (err) {
error.SkipZigTest => self.result = .skipped,
else => self.result = .{ .failure = .{
.err = err,
.trace = try self.formatStackTrace(@errorReturnTrace()),
} },
}
};
self.duration = @intCast(std.time.nanoTimestamp() - start);
if (std.testing.allocator_instance.deinit() == .leak) self.leaked = true;
}
fn formatStackTrace(self: *Test, maybe_trace: ?*std.builtin.StackTrace) !?[]const u8 {
return if (maybe_trace) |trace| blk: {
var stream = std.io.fixedBufferStream(&self.stack_trace_buf);
const writer = stream.writer();
try trace.format("", .{}, writer);
break :blk stream.getWritten();
} else null;
}
pub fn print(self: Test, stream: anytype) !void {
const writer = stream.writer();
switch (self.result) {
.success => try self.printPassed(writer),
.failure => |failure| try self.printFailure(failure, writer),
.skipped => try self.printSkipped(writer),
}
try self.printDuration(writer);
if (self.leaked) try self.printLeaked(writer);
try writer.writeByte('\n');
}
fn printPassed(self: Test, writer: anytype) !void {
try writer.print(
jetzig.colors.green("[PASS] ") ++ name_template,
.{ self.module orelse "tests", self.name },
);
}
fn printFailure(self: Test, failure: Failure, writer: anytype) !void {
try writer.print(
jetzig.colors.red("[FAIL] ") ++ name_template ++ jetzig.colors.yellow("({s})"),
.{ self.module orelse "tests", self.name, @errorName(failure.err) },
);
if (failure.trace) |trace| {
try writer.print("{s}", .{trace});
}
}
fn printSkipped(self: Test, writer: anytype) !void {
try writer.print(
jetzig.colors.yellow("[SKIP]") ++ name_template,
.{ self.module orelse "tests", self.name },
);
}
fn printLeaked(self: Test, writer: anytype) !void {
_ = self;
try writer.print(jetzig.colors.red(" [LEAKED]"), .{});
}
fn printDuration(self: Test, writer: anytype) !void {
var buf: [256]u8 = undefined;
try writer.print(
"[" ++ jetzig.colors.cyan("{s}") ++ "]",
.{try jetzig.colors.duration(&buf, @intCast(self.duration), true)},
);
}
};
pub fn main() !void {
const start = std.time.nanoTimestamp();
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var tests = std.ArrayList(Test).init(allocator);
defer tests.deinit();
var mime_map = jetzig.http.mime.MimeMap.init(allocator);
try mime_map.build();
jetzig.testing.mime_map = &mime_map;
try std.io.getStdErr().writer().writeAll("\n[jetzig] Launching Test Runner...\n\n");
jetzig.testing.state = .ready;
for (builtin.test_functions) |test_function| {
var t = Test.init(test_function);
try t.run();
try t.print(std.io.getStdErr());
try tests.append(t);
}
try printSummary(tests.items, start);
}
fn printSummary(tests: []const Test, start: i128) !void {
var success: usize = 0;
var failure: usize = 0;
var leaked: usize = 0;
var skipped: usize = 0;
for (tests) |t| {
switch (t.result) {
.success => success += 1,
.failure => failure += 1,
.skipped => skipped += 1,
}
if (t.leaked) leaked += 1;
}
const tick = jetzig.colors.green("");
const cross = jetzig.colors.red("");
const writer = std.io.getStdErr().writer();
var total_duration_buf: [256]u8 = undefined;
const total_duration = try jetzig.colors.duration(
&total_duration_buf,
@intCast(std.time.nanoTimestamp() - start),
false,
);
try writer.print(
"\n {s}{s}{}" ++
"\n {s}{s}{}" ++
"\n {s}{}" ++
"\n " ++ jetzig.colors.cyan(" tests ") ++ "{}" ++
"\n " ++ jetzig.colors.cyan(" duration ") ++ "{s}" ++ "\n\n",
.{
if (failure == 0) tick else cross,
if (failure == 0) jetzig.colors.blue(" failed ") else jetzig.colors.red(" failed "),
failure,
if (leaked == 0) tick else cross,
if (leaked == 0) jetzig.colors.blue(" leaked ") else jetzig.colors.red(" leaked "),
leaked,
if (skipped == 0) jetzig.colors.blue(" skipped ") else jetzig.colors.yellow(" skipped "),
skipped,
success + failure,
total_duration,
},
);
if (failure == 0 and leaked == 0) {
try writer.print(jetzig.colors.green(" PASS ") ++ "\n", .{});
try writer.print(jetzig.colors.green(" ▔▔▔▔") ++ "\n", .{});
std.process.exit(0);
} else {
try writer.print(jetzig.colors.red(" FAIL ") ++ "\n", .{});
try writer.print(jetzig.colors.red(" ▔▔▔▔") ++ "\n", .{});
std.process.exit(1);
}
}