Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ dependencies {
implementation(project(":libtmux-workspace"))

testImplementation(project(":libtmux-junit5"))
testImplementation(libs.jackson.databind)
}
129 changes: 117 additions & 12 deletions examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import io.github.libtmux.Session;
import io.github.libtmux.Window;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;

/**
* Lays out a session the way you would set one up by hand before starting work.
Expand All @@ -18,12 +20,61 @@
*/
public final class BuildAWorkspace {

private static final String ARENA_ARTIFACT = "java-build-a-workspace";

private BuildAWorkspace() {}

public static void main(String[] args) {
Optional<ServerConfig> arena = arenaConfig(System.getenv());
if (arena.isPresent()) {
System.out.println("LIBTMUX_ARENA_EVIDENCE=" + runArena(arena.orElseThrow()));
return;
}
run(Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s"));
}

static Optional<ServerConfig> arenaConfig(Map<String, String> environment) {
String descriptor = environment.get("LIBTMUX_ARENA_DESCRIPTOR");
if (descriptor == null || descriptor.isEmpty()) {
return Optional.empty();
}

String artifact = required(environment, "LIBTMUX_ARENA_ARTIFACT");
if (!ARENA_ARTIFACT.equals(artifact)) {
throw new IllegalArgumentException("LIBTMUX_ARENA_ARTIFACT does not select this example");
}
return Optional.of(ServerConfig.builder()
.binary(required(environment, "LIBTMUX_TMUX_BIN"))
.endpoint(ServerEndpoint.socketPath(Path.of(required(environment, "LIBTMUX_SOCKET_PATH"))))
.build());
}

static String runArena(Map<String, String> environment) {
return runArena(arenaConfig(environment)
.orElseThrow(() -> new IllegalStateException("LIBTMUX_ARENA_DESCRIPTOR is not set")));
}

private static String runArena(ServerConfig config) {
if (!(config.endpoint() instanceof ServerEndpoint.SocketPath socket)) {
throw new IllegalStateException("arena requires a socket path");
}
try (Server server = Server.open(config)) {
run(server);
String challenge = server.globalOptions()
.get("@libtmux_arena_challenge")
.filter(value -> !value.isEmpty())
.orElseThrow(() -> new IllegalStateException("arena challenge is missing"));
long serverPid = Long.parseLong(server.expand("#{pid}"));
String actualSocket = server.expand("#{socket_path}");
if (!socket.path().toString().equals(actualSocket)) {
throw new IllegalStateException("arena server socket does not match the requested socket");
}
return "{\"artifact\":\"" + ARENA_ARTIFACT + "\",\"challenge\":" + jsonString(challenge)
+ ",\"schema\":1,\"server_pid\":" + serverPid + ",\"socket_path\":"
+ jsonString(actualSocket) + "}";
}
}

/** Separated from {@code main} so the suite can run exactly what a reader runs. */
public static String run(Path socket) {
ServerConfig config = ServerConfig.builder()
Expand All @@ -33,21 +84,75 @@ public static String run(Path socket) {
// Closing a server closes this client. The tmux server, and the session, outlive the program
// — which is the whole point of tmux and the reason nothing here kills it.
try (Server server = Server.open(config)) {
Session session = server.hasSession("work")
? server.sessions().stream()
.filter(candidate -> candidate.name().equals("work"))
.findFirst()
.orElseThrow()
: server.newSession("work");
return run(server);
}
}

static String run(Server server) {
Session session = server.hasSession("work")
? server.sessions().stream()
.filter(candidate -> candidate.name().equals("work"))
.findFirst()
.orElseThrow()
: server.newSession("work");

Window editor = session.newWindow(window -> window.named("editor").detached());
Pane shell = editor.split(split -> split.toRight());
shell.sendLine("git status --short");

Window editor = session.newWindow(window -> window.named("editor").detached());
Pane shell = editor.split(split -> split.toRight());
shell.sendLine("git status --short");
editor.selectLayout(Layout.MAIN_VERTICAL);

editor.selectLayout(Layout.MAIN_VERTICAL);
return "session " + session.name() + " has "
+ session.refresh().windows().size() + " windows";
}

private static String required(Map<String, String> environment, String name) {
String value = environment.get(name);
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException(name + " is required for arena mode");
}
return value;
}

return "session " + session.name() + " has "
+ session.refresh().windows().size() + " windows";
static String jsonString(String value) {
StringBuilder json = new StringBuilder(value.length() + 2).append('"');
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (Character.isHighSurrogate(character)) {
if (index + 1 == value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) {
throw new IllegalArgumentException("JSON evidence cannot contain an unpaired surrogate");
}
json.appendCodePoint(value.codePointAt(index));
index++;
continue;
}
if (Character.isLowSurrogate(character)) {
throw new IllegalArgumentException("JSON evidence cannot contain an unpaired surrogate");
}
switch (character) {
case '"' -> json.append("\\\"");
case '\\' -> json.append("\\\\");
case '\b' -> json.append("\\b");
case '\f' -> json.append("\\f");
case '\n' -> json.append("\\n");
case '\r' -> json.append("\\r");
case '\t' -> json.append("\\t");
default -> {
if (character < ' ') {
appendUnicodeEscape(json, character);
} else {
json.append(character);
}
}
}
}
return json.append('"').toString();
}

private static void appendUnicodeEscape(StringBuilder json, char character) {
String hex = Integer.toHexString(character);
json.append("\\u");
json.append("0000", 0, 4 - hex.length());
json.append(hex);
}
}
134 changes: 134 additions & 0 deletions examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.libtmux.Pane;
import io.github.libtmux.Server;
import io.github.libtmux.ServerConfig;
import io.github.libtmux.control.ControlEvent;
import io.github.libtmux.control.PaneOutput;
import io.github.libtmux.junit5.TmuxExtension;
import io.github.libtmux.junit5.TmuxSocketPath;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

Expand All @@ -25,6 +33,9 @@
@ExtendWith(TmuxExtension.class)
final class ExamplesRunTest {

private static final String ARENA_EVIDENCE_PREFIX = "LIBTMUX_ARENA_EVIDENCE=";
private static final ObjectMapper JSON = new ObjectMapper();

@Test
void buildingAWorkspaceLeavesOneBehind(Server server, TmuxSocketPath socket) {
String reported = BuildAWorkspace.run(socket.path());
Expand All @@ -33,6 +44,102 @@ void buildingAWorkspaceLeavesOneBehind(Server server, TmuxSocketPath socket) {
assertTrue(server.hasSession("work"), "the example is supposed to leave a session running");
}

@Test
void arenaAliasesAndAnEmptyDescriptorKeepTheSocketArgumentPath(Server server, TmuxSocketPath socket) {
Map<String, String> aliases = Map.of(
"LIBTMUX_SOCKET_PATH", socket.path().toString(),
"LIBTMUX_TMUX_BIN", server.config().binary());

assertTrue(BuildAWorkspace.arenaConfig(aliases).isEmpty());
assertTrue(BuildAWorkspace.arenaConfig(with(aliases, "LIBTMUX_ARENA_DESCRIPTOR", ""))
.isEmpty());
assertTrue(BuildAWorkspace.run(socket.path()).startsWith("session work has "));
}

@Test
void arenaRejectsActivatedIncompleteAndMismatchedContracts() {
assertThrows(
IllegalArgumentException.class,
() -> BuildAWorkspace.arenaConfig(Map.of("LIBTMUX_ARENA_DESCRIPTOR", "arena")));
assertThrows(
IllegalArgumentException.class,
() -> BuildAWorkspace.arenaConfig(Map.of(
"LIBTMUX_ARENA_DESCRIPTOR", "arena",
"LIBTMUX_ARENA_ARTIFACT", "java-build-a-workspace",
"LIBTMUX_SOCKET_PATH", "",
"LIBTMUX_TMUX_BIN", "not-a-tmux-binary")));
assertThrows(
IllegalArgumentException.class,
() -> BuildAWorkspace.arenaConfig(Map.of(
"LIBTMUX_ARENA_DESCRIPTOR", "arena",
"LIBTMUX_ARENA_ARTIFACT", "other-artifact",
"LIBTMUX_SOCKET_PATH", "/tmp/no-server",
"LIBTMUX_TMUX_BIN", "not-a-tmux-binary")));
}

@Test
void arenaConfigPinsTheRequestedBinaryAndSocket(TmuxSocketPath socket) {
ServerConfig config = BuildAWorkspace.arenaConfig(Map.of(
"LIBTMUX_ARENA_DESCRIPTOR", "arena",
"LIBTMUX_ARENA_ARTIFACT", "java-build-a-workspace",
"LIBTMUX_SOCKET_PATH", socket.path().toString(),
"LIBTMUX_TMUX_BIN", "requested-tmux"))
.orElseThrow();

assertEquals("requested-tmux", config.binary());
assertEquals(
List.of("-S", socket.path().toAbsolutePath().normalize().toString()),
config.endpoint().flags());
}

@Test
void arenaMainEmitsOneValidatedEvidenceRecordWithoutStoppingExternalServer(Server server, TmuxSocketPath socket)
throws Exception {
String challenge = "quote\" slash\\";
server.globalOptions().set("@libtmux_arena_challenge", challenge);
Path output = socket.path().resolveSibling("arena-main-output");
ProcessBuilder builder = new ProcessBuilder(
Path.of(System.getProperty("java.home"), "bin", "java").toString(),
"-classpath",
System.getProperty("java.class.path"),
BuildAWorkspace.class.getName());
builder.redirectErrorStream(true);
builder.redirectOutput(output.toFile());
builder.environment().remove("TMUX");
builder.environment().remove("TMUX_PANE");
builder.environment().putAll(arenaEnvironment(server, socket));

Process process = builder.start();
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
process.waitFor(30, TimeUnit.SECONDS);
}
List<String> lines = Files.readAllLines(output);

assertTrue(finished, () -> "the arena main did not finish; it said " + lines);
assertEquals(0, process.exitValue(), () -> "the arena main failed; it said " + lines);
List<String> records = lines.stream()
.filter(line -> line.startsWith(ARENA_EVIDENCE_PREFIX))
.toList();
assertEquals(1, records.size(), () -> "expected one arena evidence record; main said " + lines);
JsonNode evidence = JSON.readTree(records.getFirst().substring(ARENA_EVIDENCE_PREFIX.length()));

assertEquals(arenaEvidence(challenge, server, socket), evidence);
assertTrue(server.hasSession("work"), "closing the arena client must not stop its daemon");
}

@Test
void arenaJsonRejectsUnpairedSurrogatesAndPreservesUnicode() throws Exception {
String controls = "\u0000\b\f\n\r\t\u001f\"\\";
String supplementary = "\ud83d\ude03";

assertEquals(controls, JSON.readValue(BuildAWorkspace.jsonString(controls), String.class));
assertEquals(supplementary, JSON.readValue(BuildAWorkspace.jsonString(supplementary), String.class));
assertThrows(IllegalArgumentException.class, () -> BuildAWorkspace.jsonString("\ud800"));
assertThrows(IllegalArgumentException.class, () -> BuildAWorkspace.jsonString("\udc00"));
}

@Test
void findingPanesSelectsOnWhatIsRunning(Server server, TmuxSocketPath socket) {
// The fixture's pane runs a shell, so the shell's own name is the one thing certain to match.
Expand Down Expand Up @@ -60,4 +167,31 @@ void watchingAServerIsToldWhenAWindowAppears(TmuxSocketPath socket) {
WatchWhatChanges.sawTheNewWindow(seen),
"tmux compares a watched format itself and reports the difference: " + seen);
}

private static Map<String, String> arenaEnvironment(Server server, TmuxSocketPath socket) {
return Map.of(
"LIBTMUX_ARENA_DESCRIPTOR",
"arena",
"LIBTMUX_ARENA_ARTIFACT",
"java-build-a-workspace",
"LIBTMUX_SOCKET_PATH",
socket.path().toString(),
"LIBTMUX_TMUX_BIN",
server.config().binary());
}

private static Map<String, String> with(Map<String, String> values, String name, String value) {
var copy = new java.util.HashMap<>(values);
copy.put(name, value);
return copy;
}

private static JsonNode arenaEvidence(String challenge, Server server, TmuxSocketPath socket) {
return JSON.createObjectNode()
.put("artifact", "java-build-a-workspace")
.put("challenge", challenge)
.put("schema", 1)
.put("server_pid", Integer.parseInt(server.expand("#{pid}")))
.put("socket_path", socket.path().toAbsolutePath().normalize().toString());
}
}
Loading