From a46bc0ed191991c6543e77508c145f69052f9ab9 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Tue, 28 May 2024 17:44:13 +0100 Subject: [PATCH] Test helpers Add `jetzig test` command which runs build step `jetzig:test`. Add `jetzig.testing` namespace which provides test helpers and a test app. Add tests to view generator (i.e. include tests for generated routes). --- .github/workflows/CI.yml | 5 + .gitignore | 1 + README.md | 4 +- build.zig | 38 +++-- build.zig.zon | 4 +- cli/cli.zig | 12 ++ cli/commands/generate/view.zig | 42 +++++ cli/commands/tests.zig | 43 ++++++ cli/util.zig | 27 ++++ demo/src/app/views/cache.zig | 12 ++ demo/src/app/views/iguanas.zig | 23 ++- demo/src/app/views/kvstore.zig | 16 ++ demo/src/app/views/markdown.zig | 7 + demo/src/app/views/quotes.zig | 8 + demo/src/app/views/root.zig | 63 ++++++++ demo/src/app/views/static.zig | 66 +++++++- src/Routes.zig | 37 ++++- src/jetzig.zig | 1 + src/jetzig/App.zig | 55 ++++--- src/jetzig/Environment.zig | 3 +- src/jetzig/http/Request.zig | 12 +- src/jetzig/http/Response.zig | 8 +- src/jetzig/http/Server.zig | 30 ++-- src/jetzig/http/StatusCode.zig | 18 --- src/jetzig/jobs.zig | 1 - src/jetzig/loggers.zig | 2 + src/jetzig/loggers/TestLogger.zig | 57 +++++++ src/jetzig/testing.zig | 249 ++++++++++++++++++++++++++++++ src/jetzig/testing/App.zig | 234 ++++++++++++++++++++++++++++ src/jetzig/util.zig | 15 ++ src/test_runner.zig | 202 ++++++++++++++++++++++++ 31 files changed, 1191 insertions(+), 104 deletions(-) create mode 100644 cli/commands/tests.zig create mode 100644 src/jetzig/loggers/TestLogger.zig create mode 100644 src/jetzig/testing.zig create mode 100644 src/jetzig/testing/App.zig create mode 100644 src/test_runner.zig 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); + } +}