diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93d18d8..706f3b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - version: [0.14.0, 0.15.1] + version: [0.14.0, 0.15.1, 0.16.0] fail-fast: false runs-on: ${{ matrix.os }} steps: @@ -27,7 +27,7 @@ jobs: with: xcode-version: latest-stable - name: Setup Zig - uses: goto-bus-stop/setup-zig@v2 + uses: mlugg/setup-zig@v2 with: version: ${{ matrix.version }} - uses: actions/checkout@v4 @@ -36,10 +36,12 @@ jobs: - name: Build examples dynamic if: runner.os != 'Windows' run: zig build examples -Dis_static=false + - name: Run unit tests + run: zig build test --summary all lint: runs-on: ubuntu-latest steps: - name: Setup Zig - uses: goto-bus-stop/setup-zig@v2 + uses: mlugg/setup-zig@v2 - name: Verify formatting run: zig fmt . diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index e90dc2b..4f80219 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -34,9 +34,9 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 # Not needed if lastUpdated is not enabled - - uses: goto-bus-stop/setup-zig@v2 + - uses: mlugg/setup-zig@v2 with: - version: 0.15.1 + version: 0.16.0 - name: remove ./src/examples run: rm -rf ./src/examples - name: Generate Docs diff --git a/.gitignore b/.gitignore index 5b9b133..a1a3b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ zig-cache zig-out +zig-pkg .direnv .zig-cache examples/comprehensive/examples diff --git a/README.md b/README.md index 1a5a409..8bdf3ca 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,13 @@ Like `zig build run_minimal`, this will build and run the `minimal` example. ## Installation -> note: for `0.13.0` and previous version, please use tag `2.5.0-beta.2` +> note: for `0.13.0` and previous versions, please use tag `2.5.0-beta.2` -### Zig `0.14.0` \ `0.15.1` +### Zig `0.14.0` / `0.15.1` / `0.16.0` -> To be honest, I don’t recommend using the nightly version because the API of the build system is not yet stable, which means that there may be problems with not being able to build after nightly is updated. +The same package builds against every supported stable Zig release. Nightly is +still not recommended — the build-system API can change between dev builds +and break the binding without warning. 1. Add to `build.zig.zon` diff --git a/build.zig b/build.zig index df695fb..5386411 100644 --- a/build.zig +++ b/build.zig @@ -43,6 +43,18 @@ pub fn build(b: *Build) !void { const flags_module = flags_options.createModule(); + // Pick the tuple-synthesis helper that matches the running Zig version. + // 0.16 removed `@Type` and rejects it at parse time, so the legacy + // implementation must live in a sibling file that is never compiled + // on 0.16. See src/compat_tuple_{old,new}.zig. + const compat_tuple_path = if (current_zig.minor >= 16) + b.pathJoin(&.{ "src", "compat_tuple_new.zig" }) + else + b.pathJoin(&.{ "src", "compat_tuple_old.zig" }); + const compat_tuple_module = b.addModule("compat_tuple", .{ + .root_source_file = b.path(compat_tuple_path), + }); + const webui = b.dependency("webui", .{ .target = target, .optimize = optimize, @@ -53,10 +65,10 @@ pub fn build(b: *Build) !void { }); const webui_module = b.addModule("webui", .{ .root_source_file = b.path(b.pathJoin(&.{ "src", "webui.zig" })), - .imports = &.{.{ - .name = "flags", - .module = flags_module, - }}, + .imports = &.{ + .{ .name = "flags", .module = flags_module }, + .{ .name = "compat_tuple", .module = compat_tuple_module }, + }, }); webui_module.linkLibrary(webui.artifact("webui")); @@ -78,6 +90,16 @@ pub fn build(b: *Build) !void { .optimize = optimize, .target = target, .flags_module = flags_module, + .compat_tuple_module = compat_tuple_module, + }); + + buildTests(b, .{ + .optimize = optimize, + .target = target, + .webui_module = webui_module, + .webui_artifact = webui.artifact("webui"), + .flags_module = flags_module, + .compat_tuple_module = compat_tuple_module, }); } @@ -94,6 +116,16 @@ const GenerateDocsOptions = struct { optimize: OptimizeMode, target: Build.ResolvedTarget, flags_module: *Module, + compat_tuple_module: *Module, +}; + +const BuildTestsOptions = struct { + optimize: OptimizeMode, + target: Build.ResolvedTarget, + webui_module: *Module, + webui_artifact: *Compile, + flags_module: *Module, + compat_tuple_module: *Module, }; // ========== Helper Functions ========== @@ -152,6 +184,38 @@ fn createExecutable( } } +// ========== Tests ========== + +fn buildTests(b: *Build, options: BuildTestsOptions) void { + const tests_path = b.path(b.pathJoin(&.{ "src", "tests.zig" })); + + const tests = if (builtin.zig_version.minor == 14) b.addTest(.{ + .name = "webui-tests", + .root_source_file = tests_path, + .target = options.target, + .optimize = options.optimize, + }) else b.addTest(.{ + .name = "webui-tests", + .root_module = b.createModule(.{ + .root_source_file = tests_path, + .target = options.target, + .optimize = options.optimize, + }), + }); + + tests.root_module.addImport("webui", options.webui_module); + tests.root_module.addImport("flags", options.flags_module); + tests.root_module.addImport("compat_tuple", options.compat_tuple_module); + // `linkLibrary` lives on Compile in 0.14/0.15 but was moved entirely to + // Module in 0.16 — go through `root_module` which exists on every + // supported version. + tests.root_module.linkLibrary(options.webui_artifact); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_tests.step); +} + // ========== Documentation Generation ========== fn generateDocs(b: *Build, options: GenerateDocsOptions) void { @@ -164,6 +228,7 @@ fn generateDocs(b: *Build, options: GenerateDocsOptions) void { ); webui_lib.root_module.addImport("flags", options.flags_module); + webui_lib.root_module.addImport("compat_tuple", options.compat_tuple_module); const docs_step = b.step("docs", "Generate docs"); const docs_install = b.addInstallDirectory(.{ @@ -182,21 +247,37 @@ fn buildExamples(b: *Build, options: BuildExamplesOptions) !void { const build_all_step = b.step("examples", "build all examples"); const examples_path = lazy_path.getPath(b); - var examples_dir = b.build_root.handle.openDir(examples_path, .{ .iterate = true }) catch |err| { - switch (err) { - error.FileNotFound => return, - else => return err, + if (comptime builtin.zig_version.minor >= 16) { + // Zig 0.16+: build_root.handle is std.Io.Dir and requires an `io`. + const io = b.graph.io; + var examples_dir = b.build_root.handle.openDir(io, examples_path, .{ .iterate = true }) catch |err| { + switch (err) { + error.FileNotFound => return, + else => return err, + } + }; + defer examples_dir.close(io); + + var iter = examples_dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind != .directory) continue; + try buildExample(b, entry.name, options, build_all_step); } - }; - defer examples_dir.close(); - - var iter = examples_dir.iterate(); - while (try iter.next()) |entry| { - if (entry.kind != .directory) { - continue; + } else { + // Zig 0.14/0.15: build_root.handle is std.fs.Dir. + var examples_dir = b.build_root.handle.openDir(examples_path, .{ .iterate = true }) catch |err| { + switch (err) { + error.FileNotFound => return, + else => return err, + } + }; + defer examples_dir.close(); + + var iter = examples_dir.iterate(); + while (try iter.next()) |entry| { + if (entry.kind != .directory) continue; + try buildExample(b, entry.name, options, build_all_step); } - - try buildExample(b, entry.name, options, build_all_step); } } diff --git a/build.zig.zon b/build.zig.zon index 5f56efc..b3a9fba 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.14.0", .dependencies = .{ .webui = .{ - .hash = "webui-2.5.0-beta.4-pxqD5a53OADNensyGsjVC_i_ksZ_G1enGSSsVD6p2dgg", - .url = "https://github.com/webui-dev/webui/archive/62bed203df456cb150bc088ed4f1b173526e9776.tar.gz", + .hash = "webui-2.5.0-beta.4-pxqD5TCWQABe1AyvdqsBEmQyFjbMMFb7yJHmMVAq_ZBZ", + .url = "https://github.com/webui-dev/webui/archive/dadf4175d6f2c4060b7a27a32e6e9e64e647116f.tar.gz", }, }, .paths = .{ diff --git a/examples/compat.zig b/examples/compat.zig index e3cd59c..d69eeb1 100644 --- a/examples/compat.zig +++ b/examples/compat.zig @@ -1,4 +1,9 @@ -//! Compatibility layer for Zig 0.15 and 0.16 +//! Compatibility layer for Zig 0.14 / 0.15 / 0.16. +//! +//! Several stdlib APIs the examples rely on were renamed or removed in +//! Zig 0.16 (Writergate, `std.fs` -> `std.Io.Dir`, `GeneralPurposeAllocator` +//! -> `DebugAllocator`, etc). This module hides the version differences +//! so each example can stay short and readable. const std = @import("std"); const builtin = @import("builtin"); @@ -44,8 +49,7 @@ pub fn fixedBufferStream(buffer: []u8) FixedBufferStream { } /// Type alias for FixedBufferStream that works in both versions -pub const FixedBufferStream = if (is_zig_0_16_or_later) -blk: { +pub const FixedBufferStream = if (is_zig_0_16_or_later) blk: { // Zig 0.16: Define our own FixedBufferStream with custom Writer break :blk struct { buffer: []u8, @@ -91,3 +95,115 @@ blk: { // Zig 0.15: Use standard library type break :blk @TypeOf(std.io.fixedBufferStream(@as([]u8, undefined))); }; + +// ===== Allocator compat ====================================================== + +/// `std.heap.GeneralPurposeAllocator` was renamed to `std.heap.DebugAllocator` +/// in Zig 0.16. Use this alias to write code that works on all supported +/// versions. +pub const GeneralPurposeAllocator = if (is_zig_0_16_or_later) + std.heap.DebugAllocator +else + std.heap.GeneralPurposeAllocator; + +// ===== Filesystem compat ===================================================== +// +// `std.fs.cwd()` and the synchronous `std.fs.File` API were removed in 0.16. +// The new API lives under `std.Io.Dir` / `std.Io.File` and threads an `io` +// instance through every call. The helpers below give the examples a small, +// uniform surface that hides this difference. + +/// Create directories as needed up to (and including) `path`. Equivalent to +/// `mkdir -p` on POSIX. +pub fn makePath(path: []const u8) !void { + if (comptime is_zig_0_16_or_later) { + // Zig 0.16 renamed `makePath` to `createDirPath`. + const io = ioInstance(); + try std.Io.Dir.cwd().createDirPath(io, path); + } else { + try std.fs.cwd().makePath(path); + } +} + +/// Create a single directory. Returns an error if `path` already exists. +pub fn makeDir(path: []const u8) !void { + if (comptime is_zig_0_16_or_later) { + // Zig 0.16 renamed `makeDir` to `createDir`; pass the platform default + // permissions so callers don't need to know the new shape. + const io = ioInstance(); + try std.Io.Dir.cwd().createDir(io, path, .default_dir); + } else { + try std.fs.cwd().makeDir(path); + } +} + +/// Delete a regular file relative to the current working directory. +pub fn deleteFile(path: []const u8) !void { + if (comptime is_zig_0_16_or_later) { + const io = ioInstance(); + try std.Io.Dir.cwd().deleteFile(io, path); + } else { + try std.fs.cwd().deleteFile(path); + } +} + +/// One-shot "create file and write everything to it" helper. Hides the +/// reader/writer plumbing differences between 0.15 and 0.16. +pub fn writeFile(path: []const u8, content: []const u8) !void { + if (comptime is_zig_0_16_or_later) { + const io = ioInstance(); + var file = try std.Io.Dir.cwd().createFile(io, path, .{}); + defer file.close(io); + try file.writeStreamingAll(io, content); + } else { + const file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + try file.writeAll(content); + } +} + +/// Get a usable `std.Io` instance on 0.16. Cheap to call: returns the +/// process-global single-threaded implementation. +fn ioInstance() std.Io { + if (comptime !is_zig_0_16_or_later) @compileError("ioInstance is 0.16+ only"); + return std.Io.Threaded.global_single_threaded.io(); +} + +// ===== Child process compat ================================================== +// +// Zig 0.16 removed `std.process.Child.init(argv, allocator)` and reworked +// child-process spawning around `std.process.spawn(io, options)`. The wrapper +// below is intentionally narrow — it exposes only what the examples need: +// spawn-with-argv, get the OS pid, and kill. + +pub const ChildProcess = struct { + /// OS-level pid. Optional because 0.16 stores it as `?i32` (the value is + /// `null` after `kill`/`wait`); on 0.14/0.15 it is always populated. + pid: ?std.process.Child.Id, + child: std.process.Child, + + /// Spawn a process with the given argv. `allocator` is used for argv + /// translation on 0.14/0.15; ignored on 0.16 (which routes through Io). + pub fn spawn(argv: []const []const u8, allocator: std.mem.Allocator) !ChildProcess { + if (comptime is_zig_0_16_or_later) { + const io = ioInstance(); + const child = try std.process.spawn(io, .{ .argv = argv }); + return .{ .pid = child.id, .child = child }; + } else { + // The 0.14/0.15 Child.init signature requires an allocator; suppress + // the "unused parameter" warning by referencing it explicitly here. + var child = std.process.Child.init(argv, allocator); + try child.spawn(); + return .{ .pid = child.id, .child = child }; + } + } + + pub fn kill(self: *ChildProcess) !void { + if (comptime is_zig_0_16_or_later) { + const io = ioInstance(); + self.child.kill(io); + } else { + _ = try self.child.kill(); + } + } +}; diff --git a/examples/comprehensive/main.zig b/examples/comprehensive/main.zig index 5cf0671..8396cb0 100644 --- a/examples/comprehensive/main.zig +++ b/examples/comprehensive/main.zig @@ -85,7 +85,7 @@ pub fn main() !void { main_window.setRuntime(.NodeJS); // Create public directory - std.fs.cwd().makeDir("examples/comprehensive/public") catch {}; + compat.makeDir("examples/comprehensive/public") catch {}; // Show window try main_window.show(html); @@ -388,7 +388,7 @@ fn uploadFile(e: *webui.Event, filename: [:0]const u8, content: [:0]const u8) vo } // Ensure public directory exists - std.fs.cwd().makePath("examples/comprehensive/public") catch |err| { + compat.makePath("examples/comprehensive/public") catch |err| { std.debug.print("Warning: Failed to create public directory: {}\n", .{err}); // Continue anyway, maybe directory already exists }; @@ -458,19 +458,10 @@ fn uploadFile(e: *webui.Event, filename: [:0]const u8, content: [:0]const u8) vo std.debug.print("Creating file at: {s}\n", .{file_path}); - // Create file - const file = std.fs.cwd().createFile(file_path, .{}) catch |err| { - std.debug.print("Failed to create file {s}: {}\n", .{ file_path, err }); - result = std.fmt.bufPrintZ(response[0..], "Error: Failed to create file '{s}' ({s})", .{ filename, @errorName(err) }) catch "Error"; - e.returnString(result); - return; - }; - defer file.close(); - // Write content to file - file.writeAll(content) catch |err| { - std.debug.print("Failed to write to file {s}: {}\n", .{ file_path, err }); - result = std.fmt.bufPrintZ(response[0..], "Error: Failed to write to file '{s}' ({s})", .{ filename, @errorName(err) }) catch "Error"; + compat.writeFile(file_path, content) catch |err| { + std.debug.print("Failed to write file {s}: {}\n", .{ file_path, err }); + result = std.fmt.bufPrintZ(response[0..], "Error: Failed to write file '{s}' ({s})", .{ filename, @errorName(err) }) catch "Error"; e.returnString(result); return; }; diff --git a/examples/custom_spa_server_on_free_port/main.zig b/examples/custom_spa_server_on_free_port/main.zig index 1709fc3..3749152 100644 --- a/examples/custom_spa_server_on_free_port/main.zig +++ b/examples/custom_spa_server_on_free_port/main.zig @@ -2,8 +2,9 @@ // Note: if you want to run this example, you nedd a python, zig will wrap a child process to launch python server const std = @import("std"); const webui = @import("webui"); +const compat = @import("compat"); -var python_server_proc: std.process.Child = undefined; +var python_server_proc: compat.ChildProcess = undefined; var python_running: bool = false; var home_url: [:0]u8 = undefined; @@ -38,10 +39,9 @@ pub fn main() !void { const port_argument1: []u8 = try std.fmt.bufPrintZ(&buf1, "{d}", .{backend_port}); const port_argument2: []u8 = try std.fmt.bufPrintZ(&buf2, "{d}", .{webui_port}); const argv = [_][]const u8{ "python", "./free_port_web_server.py", port_argument1, port_argument2 }; - python_server_proc = std.process.Child.init(&argv, std.heap.page_allocator); // start the SPA web server: - startPythonWebServer(); + startPythonWebServer(&argv); // Show a new window served by our custom web server (spawned above): var buf: [64]u8 = undefined; @@ -58,11 +58,12 @@ pub fn main() !void { killPythonWebServer(); } -fn startPythonWebServer() void { +fn startPythonWebServer(argv: []const []const u8) void { if (python_running == false) { // a better check would be a test for the process itself - if (python_server_proc.spawn()) |_| { + if (compat.ChildProcess.spawn(argv, std.heap.page_allocator)) |child| { + python_server_proc = child; python_running = true; - std.debug.print("Spawned python server process PID={}\n", .{python_server_proc.id}); + std.debug.print("Spawned python server process PID={?}\n", .{python_server_proc.pid}); } else |err| { std.debug.print("NOT Starting python server: {}\n", .{err}); } diff --git a/examples/event_handling/main.zig b/examples/event_handling/main.zig index 318927c..b3bc686 100644 --- a/examples/event_handling/main.zig +++ b/examples/event_handling/main.zig @@ -27,12 +27,11 @@ fn ensureContextsInitialized() void { global_user_contexts = std.AutoHashMap(usize, *UserContext).init(allocator); } if (online_users == null) { - // Version compatibility: Zig 0.14/0.15 use managed ArrayList, 0.16+ use unmanaged + // Version compatibility: Zig 0.14 uses managed ArrayList; 0.15+ unmanaged. + // On 0.16 the unmanaged default-init (`{}`) was dropped — use `.empty`. if (comptime builtin.zig_version.minor >= 15) { - // Zig 0.16+ - ArrayList is unmanaged by default - online_users = std.ArrayList(OnlineUser){}; + online_users = std.ArrayList(OnlineUser).empty; } else { - // Zig 0.14/0.15 - ArrayList is managed online_users = std.ArrayList(OnlineUser).init(allocator); } } @@ -108,17 +107,11 @@ pub fn main() !void { // If that fails, try alternative approach std.debug.print("Warning: Failed to show embedded HTML ({}), trying alternative method...\n", .{err}); - // Write HTML to temporary file and use startServer + // Write HTML to a temporary file and use startServer. const temp_file = "temp_index.html"; - const file = std.fs.cwd().createFile(temp_file, .{}) catch |file_err| { - std.debug.print("Error: Could not create temporary HTML file: {}\n", .{file_err}); - return; - }; - defer file.close(); - defer std.fs.cwd().deleteFile(temp_file) catch {}; - - file.writeAll(html) catch |write_err| { - std.debug.print("Error: Could not write HTML content: {}\n", .{write_err}); + defer compat.deleteFile(temp_file) catch {}; + compat.writeFile(temp_file, html) catch |write_err| { + std.debug.print("Error: Could not write temporary HTML file: {}\n", .{write_err}); return; }; diff --git a/examples/web_app_multi_client/main.zig b/examples/web_app_multi_client/main.zig index d92d386..aa147f2 100644 --- a/examples/web_app_multi_client/main.zig +++ b/examples/web_app_multi_client/main.zig @@ -1,9 +1,10 @@ const std = @import("std"); const webui = @import("webui"); const builtin = @import("builtin"); +const compat = @import("compat"); -// general purpose allocator -var gpa = std.heap.GeneralPurposeAllocator(.{}){}; +// general purpose allocator (renamed `DebugAllocator` in Zig 0.16) +var gpa = compat.GeneralPurposeAllocator(.{}){}; // allocator const allocator = gpa.allocator(); diff --git a/src/compat_tuple_new.zig b/src/compat_tuple_new.zig new file mode 100644 index 0000000..9ea7956 --- /dev/null +++ b/src/compat_tuple_new.zig @@ -0,0 +1,16 @@ +//! Tuple synthesis helper for Zig 0.16+. +//! +//! Zig 0.16 removed `@Type` and introduced `@Tuple` as a dedicated builtin +//! for constructing tuple types. The 0.16 parser rejects `@Type` outright +//! (even in unreachable / unselected branches), so the legacy +//! implementation must live in a sibling file (`compat_tuple_old.zig`) +//! that is never compiled on 0.16. +const std = @import("std"); + +pub fn fnParamsToTuple(comptime params: []const std.builtin.Type.Fn.Param) type { + var types: [params.len]type = undefined; + for (params, 0..) |param, i| { + types[i] = param.type orelse @compileError("param must have type"); + } + return @Tuple(&types); +} diff --git a/src/compat_tuple_old.zig b/src/compat_tuple_old.zig new file mode 100644 index 0000000..e93d948 --- /dev/null +++ b/src/compat_tuple_old.zig @@ -0,0 +1,32 @@ +//! Tuple synthesis helper for Zig 0.14 / 0.15. +//! +//! Uses `@Type(.{ .@"struct" = ... })` which was removed in 0.16. This file +//! must NOT be parsed on 0.16 — `build.zig` selects the right sibling +//! (`compat_tuple_new.zig`) for the active Zig version. +const std = @import("std"); + +pub fn fnParamsToTuple(comptime params: []const std.builtin.Type.Fn.Param) type { + const Type = std.builtin.Type; + const fields: [params.len]Type.StructField = blk: { + var res: [params.len]Type.StructField = undefined; + + for (params, 0..params.len) |param, i| { + res[i] = Type.StructField{ + .type = param.type.?, + .alignment = @alignOf(param.type.?), + .default_value_ptr = null, + .is_comptime = false, + .name = std.fmt.comptimePrint("{}", .{i}), + }; + } + break :blk res; + }; + return @Type(.{ + .@"struct" = std.builtin.Type.Struct{ + .layout = .auto, + .is_tuple = true, + .decls = &.{}, + .fields = &fields, + }, + }); +} diff --git a/src/tests.zig b/src/tests.zig new file mode 100644 index 0000000..5690781 --- /dev/null +++ b/src/tests.zig @@ -0,0 +1,238 @@ +//! Unit tests for the zig-webui bindings. +//! +//! The tests are split into two groups: +//! +//! 1. Pure-Zig checks that don't touch the C library at all (enum integer +//! values, error sets, Event layout, comptime helpers, version constants). +//! 2. Smoke tests that exercise C entry points which do *not* spin up a +//! browser or block on a server (window create/destroy, free-port, +//! mime-type lookup, base64 encode/decode, malloc/free, config setters). +//! +//! Run with `zig build test`. + +const std = @import("std"); +const builtin = @import("builtin"); +const webui = @import("webui"); +const compat_tuple = @import("compat_tuple"); + +// ============================================================================= +// Pure-Zig tests (no C calls) +// ============================================================================= + +test "WEBUI_VERSION matches build.zig.zon" { + try std.testing.expectEqual(@as(u64, 2), webui.WEBUI_VERSION.major); + try std.testing.expectEqual(@as(u64, 5), webui.WEBUI_VERSION.minor); + try std.testing.expectEqual(@as(u64, 0), webui.WEBUI_VERSION.patch); + try std.testing.expectEqualStrings("beta.4", webui.WEBUI_VERSION.pre.?); +} + +test "constants" { + try std.testing.expectEqual(@as(usize, 65535), webui.WEBUI_MAX_IDS); + try std.testing.expectEqual(@as(usize, 16), webui.WEBUI_MAX_ARG); +} + +test "Browser enum integer values match C ABI" { + try std.testing.expectEqual(@as(usize, 0), @intFromEnum(webui.Browser.NoBrowser)); + try std.testing.expectEqual(@as(usize, 1), @intFromEnum(webui.Browser.AnyBrowser)); + try std.testing.expectEqual(@as(usize, 2), @intFromEnum(webui.Browser.Chrome)); + try std.testing.expectEqual(@as(usize, 13), @intFromEnum(webui.Browser.Webview)); +} + +test "Runtime enum integer values match C ABI" { + try std.testing.expectEqual(@as(usize, 0), @intFromEnum(webui.Runtime.None)); + try std.testing.expectEqual(@as(usize, 1), @intFromEnum(webui.Runtime.Deno)); + try std.testing.expectEqual(@as(usize, 2), @intFromEnum(webui.Runtime.NodeJS)); + try std.testing.expectEqual(@as(usize, 3), @intFromEnum(webui.Runtime.Bun)); +} + +test "LoggerLevel enum integer values match C ABI" { + try std.testing.expectEqual(@as(usize, 0), @intFromEnum(webui.LoggerLevel.Debug)); + try std.testing.expectEqual(@as(usize, 1), @intFromEnum(webui.LoggerLevel.Info)); + try std.testing.expectEqual(@as(usize, 2), @intFromEnum(webui.LoggerLevel.Error)); +} + +test "EventKind enum integer values match C ABI" { + try std.testing.expectEqual(@as(usize, 0), @intFromEnum(webui.EventKind.EVENT_DISCONNECTED)); + try std.testing.expectEqual(@as(usize, 1), @intFromEnum(webui.EventKind.EVENT_CONNECTED)); + try std.testing.expectEqual(@as(usize, 2), @intFromEnum(webui.EventKind.EVENT_MOUSE_CLICK)); + try std.testing.expectEqual(@as(usize, 3), @intFromEnum(webui.EventKind.EVENT_NAVIGATION)); + try std.testing.expectEqual(@as(usize, 4), @intFromEnum(webui.EventKind.EVENT_CALLBACK)); +} + +test "Config enum starts at 0" { + try std.testing.expectEqual(@as(c_int, 0), @intFromEnum(webui.Config.show_wait_connection)); + try std.testing.expectEqual(@as(c_int, 1), @intFromEnum(webui.Config.ui_event_blocking)); +} + +test "Event is extern struct with stable layout" { + try std.testing.expect(@typeInfo(webui.Event) == .@"struct"); + try std.testing.expectEqual(.@"extern", @typeInfo(webui.Event).@"struct".layout); + + // The C side relies on this exact field ordering. + try std.testing.expectEqual(@as(usize, 0), @offsetOf(webui.Event, "window")); + const event_type_offset = @offsetOf(webui.Event, "event_type"); + try std.testing.expect(event_type_offset > 0); + try std.testing.expect(@offsetOf(webui.Event, "element") > event_type_offset); + try std.testing.expect(@offsetOf(webui.Event, "cookies") > @offsetOf(webui.Event, "element")); +} + +test "WebUIError contains expected variants" { + // Compile-time check: every variant we ship is reachable. + const want: []const webui.WebUIError = &.{ + webui.WebUIError.GenericError, + webui.WebUIError.CreateWindowError, + webui.WebUIError.BindError, + webui.WebUIError.ShowError, + webui.WebUIError.ServerError, + webui.WebUIError.EncodeError, + webui.WebUIError.DecodeError, + webui.WebUIError.UrlError, + webui.WebUIError.ProcessError, + webui.WebUIError.HWNDError, + webui.WebUIError.PortError, + webui.WebUIError.ScriptError, + webui.WebUIError.AllocateFailed, + }; + try std.testing.expectEqual(@as(usize, 13), want.len); +} + +test "compat_tuple.fnParamsToTuple synthesizes correct tuple" { + const Type = std.builtin.Type; + const params = [_]Type.Fn.Param{ + .{ .is_generic = false, .is_noalias = false, .type = i32 }, + .{ .is_generic = false, .is_noalias = false, .type = bool }, + .{ .is_generic = false, .is_noalias = false, .type = f64 }, + }; + const Tup = compat_tuple.fnParamsToTuple(¶ms); + + const info = @typeInfo(Tup).@"struct"; + try std.testing.expect(info.is_tuple); + try std.testing.expectEqual(@as(usize, 3), info.fields.len); + try std.testing.expectEqual(i32, info.fields[0].type); + try std.testing.expectEqual(bool, info.fields[1].type); + try std.testing.expectEqual(f64, info.fields[2].type); + + // We can build an instance and read it back. + var t: Tup = undefined; + t[0] = -7; + t[1] = true; + t[2] = 3.5; + try std.testing.expectEqual(@as(i32, -7), t[0]); + try std.testing.expect(t[1]); + try std.testing.expectEqual(@as(f64, 3.5), t[2]); +} + +// ============================================================================= +// C-backed smoke tests (no browser, no server) +// ============================================================================= + +test "newWindow then destroy roundtrip" { + const win = webui.newWindow(); + defer win.destroy(); + // window_handle is a positive id assigned by the C layer. + try std.testing.expect(win.window_handle > 0); + try std.testing.expect(win.window_handle < webui.WEBUI_MAX_IDS); + + // The window was just created; nothing is shown yet. + try std.testing.expect(!win.isShown()); +} + +test "newWindowWithId rejects 0 and out-of-range ids" { + try std.testing.expectError(webui.WebUIError.CreateWindowError, webui.newWindowWithId(0)); + try std.testing.expectError(webui.WebUIError.CreateWindowError, webui.newWindowWithId(webui.WEBUI_MAX_IDS)); + try std.testing.expectError(webui.WebUIError.CreateWindowError, webui.newWindowWithId(webui.WEBUI_MAX_IDS + 100)); +} + +test "newWindowWithId with explicit id" { + const id = webui.getNewWindowId(); + try std.testing.expect(id > 0); + try std.testing.expect(id < webui.WEBUI_MAX_IDS); + + const win = try webui.newWindowWithId(id); + defer win.destroy(); + try std.testing.expectEqual(id, win.window_handle); +} + +test "getNewWindowId returns distinct ids" { + const a = webui.getNewWindowId(); + const b = webui.getNewWindowId(); + try std.testing.expect(a != b); + try std.testing.expect(a > 0); + try std.testing.expect(b > 0); +} + +test "getFreePort returns a non-zero port" { + const port = webui.getFreePort(); + try std.testing.expect(port > 0); + try std.testing.expect(port < 65536); +} + +test "getMimeType resolves common extensions" { + try std.testing.expectEqualStrings("text/html", webui.getMimeType("index.html")); + try std.testing.expectEqualStrings("text/css", webui.getMimeType("style.css")); + // JavaScript MIME type label varies a bit across versions; just check it's + // a non-empty string that mentions javascript. + const js_mime = webui.getMimeType("app.js"); + try std.testing.expect(js_mime.len > 0); + try std.testing.expect(std.mem.indexOf(u8, js_mime, "javascript") != null); +} + +test "encode then decode roundtrips" { + const original: [:0]const u8 = "Hello, WebUI!"; + const encoded = try webui.encode(original); + defer webui.free(encoded); + try std.testing.expect(encoded.len > 0); + // Base64 of ASCII has no NUL bytes embedded. + try std.testing.expect(std.mem.indexOfScalar(u8, encoded, 0) == null); + + // Build a NUL-terminated copy for the decode call (the C API insists). + var buf: [128]u8 = undefined; + @memcpy(buf[0..encoded.len], encoded); + buf[encoded.len] = 0; + const encoded_z: [:0]const u8 = buf[0..encoded.len :0]; + + const decoded = try webui.decode(encoded_z); + defer webui.free(decoded); + try std.testing.expectEqualStrings(original, decoded); +} + +test "malloc / free roundtrip" { + const buf = try webui.malloc(64); + defer webui.free(buf); + try std.testing.expectEqual(@as(usize, 64), buf.len); + // We can write to the buffer without faulting. + @memset(buf, 0xAB); + try std.testing.expectEqual(@as(u8, 0xAB), buf[0]); + try std.testing.expectEqual(@as(u8, 0xAB), buf[63]); +} + +test "setTimeout / setConfig / setBrowserFolder do not crash" { + // Pure setters: the most we can check without driving a real browser is + // that they execute and return. + webui.setTimeout(0); + webui.setConfig(.show_wait_connection, true); + webui.setConfig(.multi_client, false); + webui.setBrowserFolder(""); +} + +test "setDefaultRootFolder accepts a relative path" { + // Should at least succeed for the current working directory. + try webui.setDefaultRootFolder("."); +} + +test "browserExist for NoBrowser returns false" { + // The "no browser" pseudo-value is never installed; this gives us a + // deterministic answer without depending on the test host having Chrome. + try std.testing.expectEqual(false, webui.browserExist(.NoBrowser)); +} + +test "clean is callable" { + // No assertion: just make sure the symbol is wired up and doesn't crash. + webui.clean(); +} + +// Suppress "unused" warnings for builtin import on 0.14 paths that don't +// touch it directly. +comptime { + _ = builtin; +} diff --git a/src/webui.zig b/src/webui.zig index 550477f..f958fdb 100644 --- a/src/webui.zig +++ b/src/webui.zig @@ -15,6 +15,11 @@ const windows = std.os.windows; const flags = @import("flags"); +/// Tuple-synthesis helper. The implementation differs between Zig versions +/// (0.14/0.15 build the tuple via `@Type`; 0.16 uses the new `@Tuple` +/// builtin), and `build.zig` selects the file matching the running compiler. +const compat_tuple = @import("compat_tuple"); + pub const c = @import("c.zig"); pub const WebUIError = error{ @@ -946,31 +951,7 @@ pub fn binding(self: webui, element: [:0]const u8, comptime callback: anytype) ! } /// this funciton will return a fn's params tuple -fn fnParamsToTuple(comptime params: []const std.builtin.Type.Fn.Param) type { - const Type = std.builtin.Type; - const fields: [params.len]Type.StructField = blk: { - var res: [params.len]Type.StructField = undefined; - - for (params, 0..params.len) |param, i| { - res[i] = Type.StructField{ - .type = param.type.?, - .alignment = @alignOf(param.type.?), - .default_value_ptr = null, - .is_comptime = false, - .name = std.fmt.comptimePrint("{}", .{i}), - }; - } - break :blk res; - }; - return @Type(.{ - .@"struct" = std.builtin.Type.Struct{ - .layout = .auto, - .is_tuple = true, - .decls = &.{}, - .fields = &fields, - }, - }); -} +const fnParamsToTuple = compat_tuple.fnParamsToTuple; pub const WEBUI_VERSION: std.SemanticVersion = .{ .major = 2,