refactor: add integration test suite
This commit is contained in:
parent
6cbf389d81
commit
4aae8f8b3e
2 changed files with 552 additions and 0 deletions
12
build.zig
12
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);
|
||||
}
|
||||
|
|
|
|||
540
src/nightwatch_test.zig
Normal file
540
src/nightwatch_test.zig
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue