a73x

test/script.zig

Ref:   Size: 3.6 KiB   History

//! What the two scripted e2e fixtures share. ptyclient (a real client on a
//! real pty) and wsclient (the browser stand-in on a WebSocket) drive
//! different transports, but both read the SAME line-oriented dialect off
//! stdin and both report failures through the same exit codes — so the
//! escape table and the codes live here, in one copy. Two copies of an
//! escape decoder is two fixtures that can disagree about what a scenario
//! sent, with the scenario's heredoc reading identically either way.
const std = @import("std");

// Exit codes, distinct so a scenario failure names its layer:
//   2 usage / setup failure (bad flag, bad script line, cannot open a file)
//   3 an expect deadline passed
//   4 the far side died before the script finished — ptyclient's client on
//     the pty, wsclient's hub on the socket
// Anything else a fixture exits with is the child's own status, which only
// ptyclient's `waitexit` propagates.
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_TIMEOUT: u8 = 3;
pub const EXIT_DIED: u8 = 4;

/// C-style escapes: \xNN, \n, \r, \t, \\. Anything else after a backslash
/// is an error — a typo'd escape must fail loudly, not send mystery bytes.
pub fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    var i: usize = 0;
    while (i < s.len) : (i += 1) {
        if (s[i] != '\\') {
            try out.append(alloc, s[i]);
            continue;
        }
        i += 1;
        if (i >= s.len) return error.BadEscape;
        switch (s[i]) {
            'n' => try out.append(alloc, '\n'),
            'r' => try out.append(alloc, '\r'),
            't' => try out.append(alloc, '\t'),
            '\\' => try out.append(alloc, '\\'),
            'x' => {
                if (i + 2 >= s.len) return error.BadEscape;
                // Digit by digit rather than parseInt: parseInt accepts a
                // sign, so `\x+1` would quietly decode as 0x01.
                const hi = std.fmt.charToDigit(s[i + 1], 16) catch return error.BadEscape;
                const lo = std.fmt.charToDigit(s[i + 2], 16) catch return error.BadEscape;
                try out.append(alloc, hi * 16 + lo);
                i += 2;
            },
            else => return error.BadEscape,
        }
    }
    return out.toOwnedSlice(alloc);
}

test "decodeEscapes: named, hex, literal backslash" {
    const alloc = std.testing.allocator;
    const cases = [_]struct { in: []const u8, want: []const u8 }{
        .{ .in = "hello\\n", .want = "hello\n" },
        .{ .in = "\\x1b[5;2~", .want = "\x1b[5;2~" },
        .{ .in = "a\\\\b", .want = "a\\b" },
        .{ .in = "\\x04", .want = "\x04" },
        .{ .in = "cr\\r", .want = "cr\r" },
        .{ .in = "tab\\there", .want = "tab\there" },
    };
    for (cases) |cs| {
        const got = try decodeEscapes(alloc, cs.in);
        defer alloc.free(got);
        try std.testing.expectEqualSlices(u8, cs.want, got);
    }
    try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "bad\\q"));
    try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trunc\\x1"));
    try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trailing\\"));
    // parseInt would take the sign and decode this as 0x01.
    try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\x+1"));
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
    std.testing.refAllDeclsRecursive(@This());
}