From 4aae8f8b3e9e2ef28b83514a36f6be4c02eae832 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 12:46:15 +0100 Subject: [PATCH] refactor: add integration test suite --- build.zig | 12 + src/nightwatch_test.zig | 540 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 552 insertions(+) create mode 100644 src/nightwatch_test.zig diff --git a/build.zig b/build.zig index 2cf4311..a737665 100644 --- a/build.zig +++ b/build.zig @@ -40,7 +40,19 @@ pub fn build(b: *std.Build) void { }); const run_exe_tests = b.addRunArtifact(exe_tests); + // Integration test suite: exercises the public API by performing real + // filesystem operations and verifying Handler callbacks via TestHandler. + const integration_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/nightwatch_test.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_integration_tests = b.addRunArtifact(integration_tests); + const test_step = b.step("test", "Run tests"); test_step.dependOn(&run_mod_tests.step); test_step.dependOn(&run_exe_tests.step); + test_step.dependOn(&run_integration_tests.step); } diff --git a/src/nightwatch_test.zig b/src/nightwatch_test.zig new file mode 100644 index 0000000..d14ba6f --- /dev/null +++ b/src/nightwatch_test.zig @@ -0,0 +1,540 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const nw = @import("nightwatch.zig"); + +// --------------------------------------------------------------------------- +// TestHandler - records every callback so tests can assert on them. +// --------------------------------------------------------------------------- + +const RecordedEvent = union(enum) { + change: struct { path: []u8, event_type: nw.EventType }, + rename: struct { src: []u8, dst: []u8 }, + + fn deinit(self: RecordedEvent, allocator: std.mem.Allocator) void { + switch (self) { + .change => |c| allocator.free(c.path), + .rename => |r| { + allocator.free(r.src); + allocator.free(r.dst); + }, + } + } +}; + +const TestHandler = struct { + handler: nw.Handler, + allocator: std.mem.Allocator, + events: std.ArrayListUnmanaged(RecordedEvent), + + fn init(allocator: std.mem.Allocator) !*TestHandler { + const self = try allocator.create(TestHandler); + self.* = .{ + .handler = .{ .vtable = &vtable }, + .allocator = allocator, + .events = .empty, + }; + return self; + } + + fn deinit(self: *TestHandler) void { + for (self.events.items) |e| e.deinit(self.allocator); + self.events.deinit(self.allocator); + self.allocator.destroy(self); + } + + // ----------------------------------------------------------------------- + // vtable + // ----------------------------------------------------------------------- + + const vtable = nw.Handler.VTable{ + .change = change_cb, + .rename = rename_cb, + .wait_readable = if (builtin.os.tag == .linux) wait_readable_cb else {}, + }; + + fn change_cb(handler: *nw.Handler, path: []const u8, event_type: nw.EventType) error{HandlerFailed}!void { + const self: *TestHandler = @fieldParentPtr("handler", handler); + const owned = self.allocator.dupe(u8, path) catch return error.HandlerFailed; + self.events.append(self.allocator, .{ + .change = .{ .path = owned, .event_type = event_type }, + }) catch { + self.allocator.free(owned); + return error.HandlerFailed; + }; + } + + fn rename_cb(handler: *nw.Handler, src: []const u8, dst: []const u8) error{HandlerFailed}!void { + const self: *TestHandler = @fieldParentPtr("handler", handler); + const owned_src = self.allocator.dupe(u8, src) catch return error.HandlerFailed; + errdefer self.allocator.free(owned_src); + const owned_dst = self.allocator.dupe(u8, dst) catch return error.HandlerFailed; + self.events.append(self.allocator, .{ + .rename = .{ .src = owned_src, .dst = owned_dst }, + }) catch { + self.allocator.free(owned_dst); + return error.HandlerFailed; + }; + } + + // On Linux the inotify backend calls wait_readable() inside arm() and + // after each read-drain. We return `will_notify` so it parks; the test + // then calls handle_read_ready() explicitly to drive event delivery. + fn wait_readable_cb(handler: *nw.Handler) error{HandlerFailed}!nw.ReadableStatus { + _ = handler; + return .will_notify; + } + + // ----------------------------------------------------------------------- + // Query helpers + // ----------------------------------------------------------------------- + + fn hasChange(self: *const TestHandler, path: []const u8, event_type: nw.EventType) bool { + return self.indexOfChange(path, event_type) != null; + } + + fn hasRename(self: *const TestHandler, src: []const u8, dst: []const u8) bool { + return self.indexOfRename(src, dst) != null; + } + + /// Returns the list index of the first matching change event, or null. + fn indexOfChange(self: *const TestHandler, path: []const u8, event_type: nw.EventType) ?usize { + for (self.events.items, 0..) |e, i| { + if (e == .change and + e.change.event_type == event_type and + std.mem.eql(u8, e.change.path, path)) return i; + } + return null; + } + + /// Returns the list index of the first matching rename event, or null. + fn indexOfRename(self: *const TestHandler, src: []const u8, dst: []const u8) ?usize { + for (self.events.items, 0..) |e, i| { + if (e == .rename and + std.mem.eql(u8, e.rename.src, src) and + std.mem.eql(u8, e.rename.dst, dst)) return i; + } + return null; + } + + /// Returns the list index of the first event (any type) whose path equals + /// `path`, or null. Used for cross-platform ordering checks where we care + /// about position but not the exact event variant. + fn indexOfAnyPath(self: *const TestHandler, path: []const u8) ?usize { + for (self.events.items, 0..) |e, i| { + const p = switch (e) { + .change => |c| c.path, + .rename => |r| r.src, // treat src as the "from" path + }; + if (std.mem.eql(u8, p, path)) return i; + } + return null; + } +}; + +// --------------------------------------------------------------------------- +// Watcher type alias - nightwatch.zig is itself a struct type. +// --------------------------------------------------------------------------- +const Watcher = nw; + +// --------------------------------------------------------------------------- +// Test utilities +// --------------------------------------------------------------------------- + +var temp_dir_counter = std.atomic.Value(u32).init(0); + +/// Create a fresh temporary directory and return its absolute path (caller frees). +fn makeTempDir(allocator: std.mem.Allocator) ![]u8 { + const n = temp_dir_counter.fetchAdd(1, .monotonic); + const name = try std.fmt.allocPrint( + allocator, + "/tmp/nightwatch_test_{d}_{d}", + .{ std.os.linux.getpid(), n }, + ); + errdefer allocator.free(name); + try std.fs.makeDirAbsolute(name); + return name; +} + +fn removeTempDir(path: []const u8) void { + std.fs.deleteTreeAbsolute(path) catch {}; +} + +/// Drive event delivery: +/// - Linux: call handle_read_ready() so inotify events are processed. +/// - Others: the backend uses its own thread/callback; sleep briefly. +fn drainEvents(watcher: *Watcher) !void { + if (builtin.os.tag == .linux) { + try watcher.handle_read_ready(); + } else { + std.time.sleep(300 * std.time.ns_per_ms); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "creating a file emits a 'created' event" { + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + const file_path = try std.fmt.allocPrint(allocator, "{s}/hello.txt", .{tmp}); + defer allocator.free(file_path); + { + const f = try std.fs.createFileAbsolute(file_path, .{}); + f.close(); + } + + try drainEvents(&watcher); + + try std.testing.expect(th.hasChange(file_path, .created)); +} + +test "writing to a file emits a 'modified' event" { + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + // Create the file before setting up the watcher to start from a clean slate. + const file_path = try std.fmt.allocPrint(allocator, "{s}/data.txt", .{tmp}); + defer allocator.free(file_path); + { + const f = try std.fs.createFileAbsolute(file_path, .{}); + f.close(); + } + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + { + const f = try std.fs.openFileAbsolute(file_path, .{ .mode = .write_only }); + defer f.close(); + try f.writeAll("hello nightwatch\n"); + } + + try drainEvents(&watcher); + + try std.testing.expect(th.hasChange(file_path, .modified)); +} + +test "deleting a file emits a 'deleted' event" { + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + const file_path = try std.fmt.allocPrint(allocator, "{s}/gone.txt", .{tmp}); + defer allocator.free(file_path); + { + const f = try std.fs.createFileAbsolute(file_path, .{}); + f.close(); + } + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + try std.fs.deleteFileAbsolute(file_path); + + try drainEvents(&watcher); + + try std.testing.expect(th.hasChange(file_path, .deleted)); +} + +test "creating a sub-directory emits a 'dir_created' event" { + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + const dir_path = try std.fmt.allocPrint(allocator, "{s}/subdir", .{tmp}); + defer allocator.free(dir_path); + try std.fs.makeDirAbsolute(dir_path); + + try drainEvents(&watcher); + + try std.testing.expect(th.hasChange(dir_path, .dir_created)); +} + +test "renaming a file is reported correctly per-platform" { + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + const src_path = try std.fmt.allocPrint(allocator, "{s}/before.txt", .{tmp}); + defer allocator.free(src_path); + const dst_path = try std.fmt.allocPrint(allocator, "{s}/after.txt", .{tmp}); + defer allocator.free(dst_path); + + { + const f = try std.fs.createFileAbsolute(src_path, .{}); + f.close(); + } + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + try std.fs.renameAbsolute(src_path, dst_path); + + try drainEvents(&watcher); + + if (builtin.os.tag == .linux) { + // inotify pairs MOVED_FROM + MOVED_TO by cookie → single rename event. + try std.testing.expect(th.hasRename(src_path, dst_path)); + } else { + // macOS/Windows emit individual .renamed change events per path. + const has_old = th.hasChange(src_path, .renamed) or th.hasChange(src_path, .deleted); + const has_new = th.hasChange(dst_path, .renamed) or th.hasChange(dst_path, .created); + try std.testing.expect(has_old or has_new); + } +} + +test "an unwatched directory produces no events" { + const allocator = std.testing.allocator; + + const watched = try makeTempDir(allocator); + defer { + removeTempDir(watched); + allocator.free(watched); + } + const unwatched = try makeTempDir(allocator); + defer { + removeTempDir(unwatched); + allocator.free(unwatched); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(watched); // only watch the first dir + + const file_path = try std.fmt.allocPrint(allocator, "{s}/silent.txt", .{unwatched}); + defer allocator.free(file_path); + { + const f = try std.fs.createFileAbsolute(file_path, .{}); + f.close(); + } + + try drainEvents(&watcher); + + try std.testing.expectEqual(@as(usize, 0), th.events.items.len); +} + +test "unwatch stops delivering events for that directory" { + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + // Create a file while watching - should be reported. + const file1 = try std.fmt.allocPrint(allocator, "{s}/watched.txt", .{tmp}); + defer allocator.free(file1); + { + const f = try std.fs.createFileAbsolute(file1, .{}); + f.close(); + } + try drainEvents(&watcher); + try std.testing.expect(th.hasChange(file1, .created)); + + // Stop watching, then create another file - must NOT appear. + try watcher.unwatch(tmp); + const count_before = th.events.items.len; + + const file2 = try std.fmt.allocPrint(allocator, "{s}/after_unwatch.txt", .{tmp}); + defer allocator.free(file2); + { + const f = try std.fs.createFileAbsolute(file2, .{}); + f.close(); + } + try drainEvents(&watcher); + + try std.testing.expectEqual(count_before, th.events.items.len); +} + +test "multiple files created sequentially all appear in the event list" { + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + const N = 5; + var paths: [N][]u8 = undefined; + for (&paths, 0..) |*p, i| { + p.* = try std.fmt.allocPrint(allocator, "{s}/file{d}.txt", .{ tmp, i }); + const f = try std.fs.createFileAbsolute(p.*, .{}); + f.close(); + } + defer for (paths) |p| allocator.free(p); + + try drainEvents(&watcher); + + for (paths) |p| { + try std.testing.expect(th.hasChange(p, .created)); + } +} + +test "rename: old-name event precedes new-name event" { + // On Linux inotify produces a single paired rename event, so there is + // nothing to order. On macOS/Windows two separate change events are + // emitted; we assert the old-name (source) event arrives first. + if (builtin.os.tag == .linux) return error.SkipZigTest; + + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + const src_path = try std.fmt.allocPrint(allocator, "{s}/old.txt", .{tmp}); + defer allocator.free(src_path); + const dst_path = try std.fmt.allocPrint(allocator, "{s}/new.txt", .{tmp}); + defer allocator.free(dst_path); + + { + const f = try std.fs.createFileAbsolute(src_path, .{}); + f.close(); + } + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + try std.fs.renameAbsolute(src_path, dst_path); + try drainEvents(&watcher); + + // Both paths must have produced some event. + const src_idx = th.indexOfAnyPath(src_path) orelse + return error.MissingSrcEvent; + const dst_idx = th.indexOfChange(dst_path, .renamed) orelse + th.indexOfChange(dst_path, .created) orelse + return error.MissingDstEvent; + + // The source (old name) event must precede the destination (new name) event. + try std.testing.expect(src_idx < dst_idx); +} + +test "rename-then-modify: rename event precedes the subsequent modify event" { + // After renaming a file, a write to the new name should produce events in + // the order [rename/old-name, rename/new-name, modify] so that a consumer + // always knows the current identity of the file before seeing changes to it. + const allocator = std.testing.allocator; + + const tmp = try makeTempDir(allocator); + defer { + removeTempDir(tmp); + allocator.free(tmp); + } + + const th = try TestHandler.init(allocator); + defer th.deinit(); + + const src_path = try std.fmt.allocPrint(allocator, "{s}/original.txt", .{tmp}); + defer allocator.free(src_path); + const dst_path = try std.fmt.allocPrint(allocator, "{s}/renamed.txt", .{tmp}); + defer allocator.free(dst_path); + + { + const f = try std.fs.createFileAbsolute(src_path, .{}); + f.close(); + } + + var watcher = try Watcher.init(allocator, &th.handler); + defer watcher.deinit(); + try watcher.watch(tmp); + + // Step 1: rename. + try std.fs.renameAbsolute(src_path, dst_path); + try drainEvents(&watcher); + + // Step 2: modify the file under its new name. + { + const f = try std.fs.openFileAbsolute(dst_path, .{ .mode = .write_only }); + defer f.close(); + try f.writeAll("post-rename content\n"); + } + try drainEvents(&watcher); + + // Locate the rename boundary: on Linux a single rename event carries both + // paths; on other platforms we look for the first event touching src_path. + const rename_idx: usize = if (builtin.os.tag == .linux) + th.indexOfRename(src_path, dst_path) orelse return error.MissingRenameEvent + else + th.indexOfAnyPath(src_path) orelse return error.MissingSrcEvent; + + // The modify event on the new name must come strictly after the rename. + const modify_idx = th.indexOfChange(dst_path, .modified) orelse + return error.MissingModifyEvent; + + try std.testing.expect(rename_idx < modify_idx); +}