//! Test-only support for reviewable snapshot golden fixtures. //! //! Snapshot compatibility references are stored as annotated hexadecimal text //! under `testdata/`. Each byte is written as exactly two hexadecimal digits. //! Whitespace is insignificant, and `#` begins a comment through the end of the //! line. This permits field, record, and absolute-offset annotations without //! changing the bytes embedded by a test. //! //! `parse` validates and decodes an `@embedFile` at comptime. Tests should //! encode their native value, compare the generated bytes with that embedded //! reference using `expectEqual`, and then decode the reference itself. This //! keeps encoding regression coverage separate from backwards-decoding //! coverage. //! //! `expectEqual` formats a fresh candidate on every run. Matching candidates //! remain in the test's temporary directory. A mismatch copies the candidate //! to the repository root, reports its path and first differing byte, and //! fails without modifying the checked-in reference. After reviewing an //! intentional wire change, a maintainer manually replaces the reference. //! Fixture filenames include the snapshot version so a future format adds new //! references instead of overwriting compatibility data for an older version. //! //! A typical fixed-value test looks like this: //! //! ```zig //! const fixture = @import("fixture.zig"); //! //! const reference = fixture.parse( //! @embedFile("testdata/example-v1.hex"), //! ); //! //! test "example golden encoding and decoding" { //! var encoded: [Example.len]u8 = undefined; //! var writer: std.Io.Writer = .fixed(&encoded); //! try Example.encode(value, &writer); //! //! try fixture.expectEqual( //! .bytes, //! "src/terminal/snapshot/testdata/example-v1.hex", //! "snapshot_fixture-example-v1.hex", //! &reference, //! writer.buffered(), //! ); //! //! var reader: std.Io.Reader = .fixed(&reference); //! try std.testing.expectEqualDeep( //! value, //! try Example.decode(&reader), //! ); //! } //! ``` //! //! ## Kaitai //! //! Every fixture has three machine-readable comment fields: //! //! ```text //! # Kaitai type: page_payload //! # Kaitai params: //! # Kaitai offset: 0 //! ``` //! //! These select a type from `snapshot.ksy`, its whitespace-separated //! parameters, and the byte offset where parsing begins. The Kaitai verifier //! discovers fixtures from the filesystem and uses this metadata instead of //! maintaining a parallel filename table. Generated candidates copy the //! metadata from their checked-in reference. //! const std = @import("std"); const envelope = @import("envelope.zig"); const record = @import("record.zig"); const log = std.log.scoped(.snapshot_fixture); pub const Kind = enum { bytes, page, snapshot, }; /// Decode an annotated hexadecimal fixture at comptime. /// /// Whitespace is ignored. `#` begins a comment that continues through the end /// of the line. Every other token must be exactly two hexadecimal digits. pub fn parse(comptime source: []const u8) [decodedLen(source)]u8 { comptime { // Large complete-snapshot fixtures exceed Zig's default comptime // branch budget while scanning their comments and byte tokens. @setEvalBranchQuota(100_000_000); // The first pass in decodedLen gives this result its exact array type. // Keeping it an array lets callers use the fixture in comptime slices. var result: [decodedLen(source)]u8 = undefined; var source_index: usize = 0; var result_index: usize = 0; while (source_index < source.len) { // Comments and layout are purely for reviewers and contribute no // bytes to the embedded reference. skipIgnored(source, &source_index); if (source_index == source.len) break; // Every data token is one complete byte. Parse it directly rather // than accepting variable-width integers or other hex syntax. if (source_index + 1 >= source.len) { @compileError("snapshot fixture ends with one hex digit"); } result[result_index] = hexNibble(source[source_index]) << 4 | hexNibble(source[source_index + 1]); result_index += 1; source_index += 2; // Requiring a separator catches accidental third digits and makes // the accepted grammar match the formatter below. if (source_index < source.len and !std.ascii.isWhitespace(source[source_index]) and source[source_index] != '#') { @compileError( "snapshot fixture hex bytes must be separated by whitespace", ); } } return result; } } /// Compare generated bytes with a checked-in reference. /// /// A formatted candidate is always created in a temporary directory. On a /// mismatch it is copied into the repository root before the test fails. pub fn expectEqual( kind: Kind, reference_path: []const u8, candidate_name: []const u8, expected: []const u8, actual: []const u8, ) !void { const testing = std.testing; const alloc = testing.allocator; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); // Kaitai parser selection lives in the reference itself. Read the textual // source so generated candidates preserve that metadata even though the // byte comparison below uses the comptime-decoded reference. const reference_source = try std.Io.Dir.cwd().readFileAlloc( testing.io, reference_path, alloc, .unlimited, ); defer alloc.free(reference_source); // Always materialize a candidate, even when it will match. This keeps the // generation path exercised on every test run while avoiding repository // artifacts for the normal successful case. { var candidate_file = try tmp.dir.createFile( testing.io, candidate_name, .{}, ); defer candidate_file.close(testing.io); var write_buffer: [4096]u8 = undefined; var candidate_writer = candidate_file.writer( testing.io, &write_buffer, ); try format( kind, reference_source, actual, &candidate_writer.interface, ); try candidate_writer.interface.flush(); } // Matching candidates remain in the temporary directory and disappear // with its cleanup. if (std.mem.eql(u8, expected, actual)) return; // The test temp directory is not necessarily below the working directory. // Resolve both paths before copying the candidate to the stable, // user-visible repository-root name. const tmp_path = try tmp.dir.realPathFileAlloc( testing.io, candidate_name, alloc, ); defer alloc.free(tmp_path); const cwd = try std.Io.Dir.cwd().realPathFileAlloc( testing.io, ".", alloc, ); defer alloc.free(cwd); const candidate_path = try std.fs.path.join( alloc, &.{ cwd, candidate_name }, ); defer alloc.free(candidate_path); try std.Io.Dir.copyFileAbsolute( tmp_path, candidate_path, testing.io, .{}, ); // Report the first useful comparison point. When one byte sequence is a // prefix of the other, this is the shared length where they diverge. const difference = difference: { const shared_len = @min(expected.len, actual.len); for ( expected[0..shared_len], actual[0..shared_len], 0.., ) |expected_byte, actual_byte, index| { if (expected_byte != actual_byte) break :difference index; } break :difference shared_len; }; log.err( "snapshot fixture differs reference={s} candidate={s} " ++ "offset=0x{x} expected_len={} actual_len={}", .{ reference_path, candidate_path, difference, expected.len, actual.len, }, ); return error.TestExpectedEqual; } fn decodedLen(comptime source: []const u8) usize { comptime { // This is a complete validation pass, not just a count. Invalid syntax // therefore fails before parse allocates and fills its result array. @setEvalBranchQuota(100_000_000); var index: usize = 0; var result: usize = 0; while (index < source.len) { skipIgnored(source, &index); if (index == source.len) break; if (index + 1 >= source.len) { @compileError("snapshot fixture ends with one hex digit"); } _ = hexNibble(source[index]); _ = hexNibble(source[index + 1]); result += 1; index += 2; if (index < source.len and !std.ascii.isWhitespace(source[index]) and source[index] != '#') { @compileError( "snapshot fixture hex bytes must be separated by whitespace", ); } } return result; } } fn skipIgnored(comptime source: []const u8, index: *usize) void { while (index.* < source.len) { // Layout whitespace is unrestricted so fixtures can group fields and // keep deterministic sixteen-byte output lines. if (std.ascii.isWhitespace(source[index.*])) { index.* += 1; continue; } // A comment consumes the rest of its line. The newline itself is // consumed by the whitespace case on the next iteration. if (source[index.*] != '#') return; while (index.* < source.len and source[index.*] != '\n') { index.* += 1; } } } fn hexNibble(comptime value: u8) u8 { return switch (value) { '0'...'9' => value - '0', 'a'...'f' => value - 'a' + 10, 'A'...'F' => value - 'A' + 10, else => @compileError("snapshot fixture contains a non-hex digit"), }; } fn format( kind: Kind, reference_source: []const u8, bytes: []const u8, writer: *std.Io.Writer, ) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void { // Every generated candidate starts with enough maintenance context to be // understandable when copied out of a failing test's temp directory. try writer.writeAll("# Ghostty snapshot fixture\n"); try writeKaitaiMetadata(reference_source, writer); try writer.print( "# Wire version: {}\n" ++ "# Generated by its snapshot test; review before replacing.\n" ++ "# On mismatch, the candidate is copied to the repository root.\n", .{envelope.version}, ); // Small values only need a hex dump. Larger compound formats get // structure-aware section labels without changing their decoded bytes. switch (kind) { .bytes => try writeSection( "encoded bytes", bytes, 0, bytes.len, writer, ), .page => try formatPage(bytes, writer), .snapshot => try formatSnapshot(bytes, writer), } } fn writeKaitaiMetadata( reference_source: []const u8, writer: *std.Io.Writer, ) (std.Io.Writer.Error || error{InvalidKaitaiMetadata})!void { const prefixes = [_][]const u8{ "# Kaitai type:", "# Kaitai params:", "# Kaitai offset:", }; var found = [_]bool{false} ** prefixes.len; var lines = std.mem.splitScalar(u8, reference_source, '\n'); while (lines.next()) |untrimmed| { const line = std.mem.trim(u8, untrimmed, " \r"); for (prefixes, &found) |prefix, *seen| { if (!std.mem.startsWith(u8, line, prefix)) continue; if (seen.*) return error.InvalidKaitaiMetadata; seen.* = true; try writer.print("{s}\n", .{line}); break; } } for (found) |seen| { if (!seen) return error.InvalidKaitaiMetadata; } } fn formatPage( bytes: []const u8, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { // PAGE version 1 has a twenty-byte fixed header. The remainder is // self-describing through the counts and dimensions in that header. const header_end = @min(bytes.len, 20); try writeSection("PAGE header", bytes, 0, header_end, writer); if (header_end < bytes.len) { try writeSection( "style and hyperlink tables, rows, and cells", bytes, header_end, bytes.len, writer, ); } } fn formatSnapshot( bytes: []const u8, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { // A complete snapshot begins with its unframed envelope. Clamp this first // section so a partially generated candidate can still be inspected. const envelope_end = @min(bytes.len, envelope.encoded_len); try writeSection("snapshot envelope", bytes, 0, envelope_end, writer); // Walk record framing only far enough to annotate boundaries. The emitted // hex remains authoritative; this formatter never repairs candidate data. var offset: usize = envelope_end; while (offset < bytes.len) { // A partial header cannot provide a safe payload boundary, so preserve // the remaining bytes as one explicitly incomplete section. if (bytes.len - offset < record.Header.len) { try writeSection( "trailing partial record", bytes, offset, bytes.len, writer, ); return; } // Tag and payload length are the first six bytes of every record // header. CRC validation belongs to the codec test, not this formatter. const tag_raw = std.mem.readInt( u16, bytes[offset..][0..2], .little, ); const payload_len: u32 = std.mem.readInt( u32, bytes[offset + 2 ..][0..4], .little, ); // Clamp the section to the available candidate bytes. This makes a // truncated payload reviewable instead of indexing beyond the slice. const record_end: usize = @min( bytes.len, offset + record.Header.len + @as(usize, payload_len), ); // Known tags make the annotated diff readable. Preserve unknown tags // numerically so formatter output is still useful for encoder bugs. if (std.enums.fromInt(record.Tag, tag_raw)) |tag| { try writer.print( "\n# offset 0x{x:0>8}: {s} record, payload {} bytes\n", .{ offset, @tagName(tag), payload_len }, ); } else { try writer.print( "\n# offset 0x{x:0>8}: unknown record {}, payload {} bytes\n", .{ offset, tag_raw, payload_len }, ); } try writeHex(bytes, offset, record_end, writer); offset = record_end; } } fn writeSection( name: []const u8, bytes: []const u8, start: usize, end: usize, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { // Sections begin on a fresh line and carry their absolute wire offset. try writer.print( "\n# offset 0x{x:0>8}: {s}\n", .{ start, name }, ); try writeHex(bytes, start, end, writer); } fn writeHex( bytes: []const u8, start: usize, end: usize, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { const digits = "0123456789abcdef"; var offset = start; while (offset < end) { // Lowercase, sixteen-byte lines provide stable textual diffs. The // trailing offset is a comment and is ignored by parse. const line_end = @min(end, offset + 16); for (bytes[offset..line_end], 0..) |byte, index| { if (index != 0) try writer.writeByte(' '); try writer.writeByte(digits[byte >> 4]); try writer.writeByte(digits[byte & 0x0f]); } try writer.print(" # 0x{x:0>8}\n", .{offset}); offset = line_end; } }