diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index e045110..c6632b0 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -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: |
diff --git a/.gitignore b/.gitignore
index cbcae4c..638a023 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ zig-cache/
*.core
static/
.jetzig
+.zig-cache/
diff --git a/README.md b/README.md
index ae11499..1326864 100644
--- a/README.md
+++ b/README.md
@@ -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.)
diff --git a/build.zig b/build.zig
index 1afc7e5..509db41 100644
--- a/build.zig
+++ b/build.zig
@@ -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 {
diff --git a/build.zig.zon b/build.zig.zon
index 84b2c3d..3d74cb5 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -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",
diff --git a/cli/cli.zig b/cli/cli.zig
index 3e9908a..beb85fb 100644
--- a/cli/cli.zig
+++ b/cli/cli.zig
@@ -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 },
+ ),
};
}
}
diff --git a/cli/commands/generate/view.zig b/cli/commands/generate/view.zig
index 0ca1447..66c79fe 100644
--- a/cli/commands/generate/view.zig
+++ b/cli/commands/generate/view.zig
@@ -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(
diff --git a/cli/commands/tests.zig b/cli/commands/tests.zig
new file mode 100644
index 0000000..2ad0717
--- /dev/null
+++ b/cli/commands/tests.zig
@@ -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",
+ });
+}
diff --git a/cli/util.zig b/cli/util.zig
index 002f0b7..b5c6d7d 100644
--- a/cli/util.zig
+++ b/cli/util.zig
@@ -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 });
diff --git a/demo/src/app/views/cache.zig b/demo/src/app/views/cache.zig
index c966b17..a931056 100644
--- a/demo/src/app/views/cache.zig
+++ b/demo/src/app/views/cache.zig
@@ -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(
+ \\ Cached value: test message
+ );
+}
diff --git a/demo/src/app/views/iguanas.zig b/demo/src/app/views/iguanas.zig
index 3229685..85b9ccf 100644
--- a/demo/src/app/views/iguanas.zig
+++ b/demo/src/app/views/iguanas.zig
@@ -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");
+}
diff --git a/demo/src/app/views/kvstore.zig b/demo/src/app/views/kvstore.zig
index cf9d6cd..40ff79a 100644
--- a/demo/src/app/views/kvstore.zig
+++ b/demo/src/app/views/kvstore.zig
@@ -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");
+}
diff --git a/demo/src/app/views/markdown.zig b/demo/src/app/views/markdown.zig
index 746c9f8..0a929d6 100644
--- a/demo/src/app/views/markdown.zig
+++ b/demo/src/app/views/markdown.zig
@@ -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 Zmpl references, modes, and partials.");
+}
diff --git a/demo/src/app/views/quotes.zig b/demo/src/app/views/quotes.zig
index c02be02..48e6610 100644
--- a/demo/src/app/views/quotes.zig
+++ b/demo/src/app/views/quotes.zig
@@ -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.");
+}
diff --git a/demo/src/app/views/root.zig b/demo/src/app/views/root.zig
index 721a390..be8af82 100644
--- a/demo/src/app/views/root.zig
+++ b/demo/src/app/views/root.zig
@@ -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);
+}
diff --git a/demo/src/app/views/static.zig b/demo/src/app/views/static.zig
index 1acd8ba..fa29d85 100644
--- a/demo/src/app/views/static.zig
+++ b/demo/src/app/views/static.zig
@@ -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);
+}
diff --git a/src/Routes.zig b/src/Routes.zig
index 10a5b2e..43b87ed 100644
--- a/src/Routes.zig
+++ b/src/Routes.zig
@@ -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 => {
diff --git a/src/jetzig.zig b/src/jetzig.zig
index c3c811c..b08e569 100644
--- a/src/jetzig.zig
+++ b/src/jetzig.zig
@@ -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.
diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig
index f1d01c2..b10d78a 100644
--- a/src/jetzig/App.zig
+++ b/src/jetzig/App.zig
@@ -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();
+}
diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig
index ce458a8..fb2fd63 100644
--- a/src/jetzig/Environment.zig
+++ b/src/jetzig/Environment.zig
@@ -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,
};
diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig
index d0f80cc..683f68c 100644
--- a/src/jetzig/http/Request.zig
+++ b/src/jetzig/http/Request.zig
@@ -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);
}
diff --git a/src/jetzig/http/Response.zig b/src/jetzig/http/Response.zig
index c848e0a..f2c26b5 100644
--- a/src/jetzig/http/Response.zig
+++ b/src/jetzig/http/Response.zig
@@ -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();
-}
diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig
index 6415358..c341dc1 100644
--- a/src/jetzig/http/Server.zig
+++ b/src/jetzig/http/Server.zig
@@ -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;
diff --git a/src/jetzig/http/StatusCode.zig b/src/jetzig/http/StatusCode.zig
index 1b0e047..72c94e9 100644
--- a/src/jetzig/http/StatusCode.zig
+++ b/src/jetzig/http/StatusCode.zig
@@ -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(),
- };
- }
-};
diff --git a/src/jetzig/jobs.zig b/src/jetzig/jobs.zig
index 2df8087..a49fd1a 100644
--- a/src/jetzig/jobs.zig
+++ b/src/jetzig/jobs.zig
@@ -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");
diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig
index e98f3c8..95fc583 100644
--- a/src/jetzig/loggers.zig
+++ b/src/jetzig/loggers.zig
@@ -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 {
diff --git a/src/jetzig/loggers/TestLogger.zig b/src/jetzig/loggers/TestLogger.zig
new file mode 100644
index 0000000..65cae9a
--- /dev/null
+++ b/src/jetzig/loggers/TestLogger.zig
@@ -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);
+ }
+}
diff --git a/src/jetzig/testing.zig b/src/jetzig/testing.zig
new file mode 100644
index 0000000..ff6482d
--- /dev/null
+++ b/src/jetzig/testing.zig
@@ -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);
+}
diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig
new file mode 100644
index 0000000..d902e86
--- /dev/null
+++ b/src/jetzig/testing/App.zig
@@ -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,
+ };
+}
diff --git a/src/jetzig/util.zig b/src/jetzig/util.zig
index 6b05d9f..1211780 100644
--- a/src/jetzig/util.zig
+++ b/src/jetzig/util.zig
@@ -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];
+}
diff --git a/src/test_runner.zig b/src/test_runner.zig
new file mode 100644
index 0000000..086762c
--- /dev/null
+++ b/src/test_runner.zig
@@ -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);
+ }
+}