renderer: isolate tests from the host terminal environment (#1223)

Renderer tests inherited the real process environment, so the OSC 52
exact-encoding test failed inside tmux where Terminal detects the
multiplexer and wraps the sequence in DCS passthrough.

Plumb an optional env_map through CliRenderer.CreateOptions into
Terminal.init and give TestRenderer an owned, empty-by-default map so
every renderer test is hermetic by construction. Add createWithEnv to
inject variables at init time and cover the previously untested tmux
and screen passthrough encodings.
This commit is contained in:
Simon Klee
2026-07-03 11:05:35 +02:00
committed by GitHub
parent cea1daec46
commit 38bc1a17a4
3 changed files with 99 additions and 6 deletions
+4 -1
View File
@@ -225,6 +225,9 @@ pub const CliRenderer = struct {
remote_mode: Terminal.RemoteMode = .local,
output: OutputTarget = .stdout,
clearOnShutdown: bool = true,
// Optional override for terminal environment lookups. Borrowed: the
// caller owns the map and must keep it alive for the renderer's lifetime.
env_map: ?*const std.process.EnvMap = null,
};
pub fn create(allocator: Allocator, width: u32, height: u32, pool: *gp.GraphemePool) !*CliRenderer {
@@ -296,7 +299,7 @@ pub const CliRenderer = struct {
.pool = pool,
.backgroundColor = ansi.rgbColor(0, 0, 0, 0),
.renderOffset = 0,
.terminal = Terminal.init(.{ .remote_mode = opts.remote_mode }),
.terminal = Terminal.init(.{ .remote_mode = opts.remote_mode, .env_map = opts.env_map }),
.clearOnShutdown = opts.clearOnShutdown,
.backend = backend,
.lastCursorStyleTag = null,
@@ -107,6 +107,58 @@ test "renderer - clipboard allocates one exact encoded sequence" {
try std.testing.expect(std.mem.endsWith(u8, output, "QUE=\x1b\\"));
}
test "renderer - clipboard wraps OSC 52 in tmux DCS passthrough" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
var local_link_pool = link.LinkPool.init(std.testing.allocator);
defer local_link_pool.deinit();
var test_renderer = try TestRenderer.createWithEnv(std.testing.allocator, 80, 24, pool, &.{
.{ .key = "TMUX", .value = "/tmp/tmux-1000/default,12345,0" },
});
defer test_renderer.deinit();
const payload = [_]u8{'A'} ** 2048;
try std.testing.expect(test_renderer.renderer.copyToClipboardOSC52(.clipboard, &payload));
// Envelope: "\x1bPtmux;" (7) + bare sequence (base64 + 9) with both inner
// ESC bytes doubled (+2) + "\x1b\\" (2) = base64 + 20.
const output = test_renderer.lastOutput();
try std.testing.expectEqual(std.base64.standard.Encoder.calcSize(payload.len) + 20, output.len);
try std.testing.expect(std.mem.startsWith(u8, output, "\x1bPtmux;\x1b\x1b]52;c;QUFB"));
try std.testing.expect(std.mem.endsWith(u8, output, "QUE=\x1b\x1b\\\x1b\\"));
}
test "renderer - clipboard chunks OSC 52 in screen DCS passthrough" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
var local_link_pool = link.LinkPool.init(std.testing.allocator);
defer local_link_pool.deinit();
var test_renderer = try TestRenderer.createWithEnv(std.testing.allocator, 80, 24, pool, &.{
.{ .key = "STY", .value = "12345.pts-0.host" },
});
defer test_renderer.deinit();
const payload = [_]u8{'A'} ** 2048;
try std.testing.expect(test_renderer.renderer.copyToClipboardOSC52(.clipboard, &payload));
// Escaped sequence: base64 (2732) + OSC 52 framing (9) + doubled ESC bytes
// (+2) = 2743 bytes, split into 11 chunks of at most 252 bytes, each wrapped
// in "\x1bP" .. "\x1b\\" (+4 per chunk) = 2787 total.
const output = test_renderer.lastOutput();
try std.testing.expectEqual(@as(usize, 2787), output.len);
try std.testing.expect(std.mem.startsWith(u8, output, "\x1bP\x1b\x1b]52;c;QUFB"));
try std.testing.expect(std.mem.endsWith(u8, output, "QUE=\x1b\x1b\\\x1b\\"));
// ESC only precedes 'P' at envelope starts, so this counts the chunks.
try std.testing.expectEqual(@as(usize, 11), std.mem.count(u8, output, "\x1bP"));
// First envelope closes after exactly 252 payload bytes: "\x1bP" (2) +
// payload (252) puts the terminator and the next start at offset 254.
try std.testing.expectEqualSlices(u8, "\x1b\\\x1bP", output[254..258]);
}
test "renderer - simple text rendering to currentRenderBuffer" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
+43 -5
View File
@@ -39,45 +39,83 @@ pub const TestMemoryOutput = struct {
}
};
pub const TestEnvVar = struct {
key: []const u8,
value: []const u8,
};
pub const TestRenderer = struct {
allocator: std.mem.Allocator,
memory: *TestMemoryOutput,
// Owned environment injected into the renderer's Terminal. Defaults to an
// empty map so tests never observe the host environment (TMUX, STY, TERM,
// ...). Heap-allocated because Terminal borrows a stable pointer while
// TestRenderer is returned by value.
env_map: *std.process.EnvMap,
renderer: *renderer.CliRenderer,
const CreateConfig = struct {
thread_safe: bool = false,
env_vars: []const TestEnvVar = &.{},
};
pub fn create(allocator: std.mem.Allocator, width: u32, height: u32, pool: *gp.GraphemePool) !TestRenderer {
return createWithThreadSafety(allocator, width, height, pool, false);
return createWithConfig(allocator, width, height, pool, .{});
}
pub fn createThreadSafe(allocator: std.mem.Allocator, width: u32, height: u32, pool: *gp.GraphemePool) !TestRenderer {
return createWithThreadSafety(allocator, width, height, pool, true);
return createWithConfig(allocator, width, height, pool, .{ .thread_safe = true });
}
fn createWithThreadSafety(
pub fn createWithEnv(
allocator: std.mem.Allocator,
width: u32,
height: u32,
pool: *gp.GraphemePool,
thread_safe: bool,
env_vars: []const TestEnvVar,
) !TestRenderer {
return createWithConfig(allocator, width, height, pool, .{ .env_vars = env_vars });
}
fn createWithConfig(
allocator: std.mem.Allocator,
width: u32,
height: u32,
pool: *gp.GraphemePool,
config: CreateConfig,
) !TestRenderer {
const memory = try allocator.create(TestMemoryOutput);
errdefer allocator.destroy(memory);
memory.* = TestMemoryOutput.init(allocator);
memory.thread_safe = thread_safe;
memory.thread_safe = config.thread_safe;
errdefer memory.deinit();
const env_map = try allocator.create(std.process.EnvMap);
errdefer allocator.destroy(env_map);
env_map.* = std.process.EnvMap.init(allocator);
errdefer env_map.deinit();
for (config.env_vars) |env_var| {
try env_map.put(env_var.key, env_var.value);
}
const cli_renderer = try renderer.CliRenderer.createWithOptions(allocator, width, height, pool, .{
.output = .{ .buffered = memory.bufferedOutput() },
.env_map = env_map,
});
return .{
.allocator = allocator,
.memory = memory,
.env_map = env_map,
.renderer = cli_renderer,
};
}
pub fn deinit(self: *TestRenderer) void {
// The renderer's Terminal borrows env_map; destroy the renderer first.
self.renderer.destroy();
self.env_map.deinit();
self.allocator.destroy(self.env_map);
self.memory.deinit();
self.allocator.destroy(self.memory);
}