const std = @import("std"); const spec_data = @import("spec_data"); pub const StandardInput = enum { ignored }; pub const WorkingDirectory = enum { empty }; /// Rejected invocations observed by one execution of this fixture. const cases_max = 32; /// Arguments one listed invocation may carry, excluding the program name. const case_arguments_max = 4; /// Bytes one startup diagnostic may occupy. const diagnostic_bytes_max = 512; /// The registered bound on one echoed argument. const echo_bytes_max: usize = @intCast(spec_data.limit_value.diagnostic_argument_bytes); /// The marker that discloses a truncated echo. const truncation_marker = "..."; const quote_open = ": '"; const quote_close = "'\n"; const usage_line = "usage: " ++ spec_data.cli_executable ++ " [OPTIONS]; run '" ++ spec_data.cli_executable ++ " " ++ spec_data.cliOption(.help).spelling ++ "' for the option list.\n"; const Case = struct { arguments: []const []const u8, expected_echo: []const u8, result: ?std.process.RunResult = null, }; pub const Fixture = struct { temporary: std.testing.TmpDir, executable: ?[:0]u8 = null, cases: [cases_max]Case = undefined, case_count: usize = 0, stdin_ignored: bool = false, executed: bool = false, pub fn init() !Fixture { return .{ .temporary = std.testing.tmpDir(.{ .iterate = true }) }; } pub fn deinit(fixture: *Fixture) void { for (fixture.cases[0..fixture.case_count]) |case| { if (case.result) |result| { std.testing.allocator.free(result.stdout); std.testing.allocator.free(result.stderr); } } if (fixture.executable) |executable| std.testing.allocator.free(executable); fixture.temporary.cleanup(); fixture.* = undefined; } pub fn given_program(fixture: *Fixture, executable_name: []const u8) !void { std.debug.assert(fixture.executable == null); try std.testing.expectEqualStrings(spec_data.cli_executable, executable_name); fixture.executable = try std.Io.Dir.cwd().realPathFileAlloc( std.testing.io, "zig-out/bin/termux-janitor", std.testing.allocator, ); } pub fn given_standard_input(fixture: *Fixture, mode: StandardInput) !void { std.debug.assert(!fixture.stdin_ignored); try std.testing.expectEqual(StandardInput.ignored, mode); fixture.stdin_ignored = true; } pub fn given_working_directory(fixture: *Fixture, state: WorkingDirectory) !void { try std.testing.expectEqual(WorkingDirectory.empty, state); var entries = fixture.temporary.dir.iterate(); try std.testing.expect(try entries.next(std.testing.io) == null); } pub fn given_rejected_invocation( fixture: *Fixture, arguments: []const []const u8, expected_echo: []const u8, ) !void { std.debug.assert(!fixture.executed); try std.testing.expect(fixture.case_count < cases_max); try std.testing.expect(arguments.len > 0); try std.testing.expect(arguments.len <= case_arguments_max); try std.testing.expect(expected_echo.len > 0); fixture.cases[fixture.case_count] = .{ .arguments = arguments, .expected_echo = expected_echo, }; fixture.case_count += 1; } pub fn when_run_each(fixture: *Fixture) !void { const executable = fixture.executable orelse return error.ExecutableNotConfigured; if (fixture.case_count == 0) return error.CasesNotConfigured; if (!fixture.stdin_ignored) return error.StandardInputNotConfigured; for (fixture.cases[0..fixture.case_count]) |*case| { var argv: [1 + case_arguments_max][]const u8 = undefined; argv[0] = executable; for (case.arguments, 0..) |argument, index| argv[index + 1] = argument; case.result = try fixture.run(argv[0 .. case.arguments.len + 1]); } fixture.executed = true; } pub fn then_inv_usage_status(fixture: *Fixture) !void { for (fixture.cases[0..fixture.case_count]) |case| { switch (resultRef(&case).term) { .exited => |status| try std.testing.expectEqual( spec_data.cliExitCode(.usage), status, ), else => return error.UnexpectedTermination, } } } pub fn then_inv_usage_diagnostic(fixture: *Fixture) !void { for (fixture.cases[0..fixture.case_count]) |case| { const stderr = resultRef(&case).stderr; try std.testing.expect(stderr.len <= diagnostic_bytes_max); try std.testing.expect(std.mem.startsWith( u8, stderr, spec_data.cli_executable ++ ": ", )); try std.testing.expect(std.mem.endsWith(u8, stderr, usage_line)); const quoted = quotedEcho(stderr) orelse return error.MissingQuotedArgument; try std.testing.expect(quoted.len > 0); try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, stderr, "\n")); } } pub fn then_inv_usage_echo_bound(fixture: *Fixture) !void { for (fixture.cases[0..fixture.case_count]) |case| { const quoted = quotedEcho(resultRef(&case).stderr) orelse { return error.MissingQuotedArgument; }; const truncated = std.mem.endsWith(u8, quoted, truncation_marker); const echo = if (truncated) quoted[0 .. quoted.len - truncation_marker.len] else quoted; try std.testing.expect(echo.len <= echo_bytes_max); try std.testing.expectEqual(exceedsBound(case.arguments), truncated); } } pub fn then_inv_usage_echo_sanitized(fixture: *Fixture) !void { for (fixture.cases[0..fixture.case_count]) |case| { const stderr = resultRef(&case).stderr; const quoted = quotedEcho(stderr) orelse return error.MissingQuotedArgument; try std.testing.expectEqualStrings(case.expected_echo, quoted); try std.testing.expect(std.unicode.utf8ValidateSlice(stderr)); for (stderr) |byte| { if (byte == '\n') continue; try std.testing.expect(byte >= 0x20); try std.testing.expect(byte != 0x7f); } } } pub fn forbid_no_usage_stdout(fixture: *Fixture) !void { for (fixture.cases[0..fixture.case_count]) |case| { try std.testing.expectEqualStrings("", resultRef(&case).stdout); } } pub fn forbid_no_usage_fixture_mutation(fixture: *Fixture) !void { var entries = fixture.temporary.dir.iterate(); try std.testing.expect(try entries.next(std.testing.io) == null); } fn run(fixture: *Fixture, argv: []const []const u8) !std.process.RunResult { std.debug.assert(argv.len > 1); std.debug.assert(fixture.stdin_ignored); return std.process.run(std.testing.allocator, std.testing.io, .{ .argv = argv, .stderr_limit = .limited(@intCast(spec_data.limit_value.captured_stderr_bytes)), .stdout_limit = .limited(@intCast(spec_data.limit_value.captured_stdout_bytes)), .reserve_amount = diagnostic_bytes_max, .cwd = .{ .dir = fixture.temporary.dir }, .environ_map = null, .expand_arg0 = .no_expand, .progress_node = .none, .create_no_window = true, .disable_aslr = false, .timeout = .{ .duration = .{ .raw = .fromSeconds(@intCast(spec_data.limit_value.test_process_timeout)), .clock = .awake, } }, }); } }; /// The argument text quoted by the first diagnostic line, without its quotes. fn quotedEcho(stderr: []const u8) ?[]const u8 { const start = std.mem.indexOf(u8, stderr, quote_open) orelse return null; const content = stderr[start + quote_open.len ..]; const end = std.mem.indexOf(u8, content, quote_close) orelse return null; return content[0..end]; } /// Whether any listed argument is longer than the registered echo bound. fn exceedsBound(arguments: []const []const u8) bool { std.debug.assert(arguments.len > 0); std.debug.assert(echo_bytes_max > 0); for (arguments) |argument| { if (argument.len > echo_bytes_max) return true; } return false; } fn resultRef(case: *const Case) *const std.process.RunResult { if (case.result) |*result| return result; @panic("process has not run"); }