/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ package com.github.copilot.sdk; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; /** * Manages a replaying proxy server for E2E tests. * *
* This spawns the shared test harness server from test/harness/server.ts which * acts as a replaying proxy to AI endpoints. It captures and stores * request/response pairs in YAML snapshot files and replays stored responses on * subsequent runs for deterministic testing. *
* ** Usage example: *
* *
* {@code
* CapiProxy proxy = new CapiProxy();
* String proxyUrl = proxy.start();
*
* // Configure for a specific test
* proxy.configure("test/snapshots/tools/my_test.yaml", workDir);
*
* // ... run tests with proxyUrl ...
*
* // Get captured exchanges
* List
*/
public class CapiProxy implements AutoCloseable {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Pattern LISTENING_PATTERN = Pattern.compile("Listening: (http://[^\\s]+)");
private Process process;
private String proxyUrl;
private final HttpClient httpClient;
private BufferedReader stdoutReader;
public CapiProxy() {
this.httpClient = HttpClient.newHttpClient();
}
/**
* Starts the proxy server and returns its URL.
*
* @return the proxy URL (e.g., "http://localhost:12345")
* @throws IOException
* if the server fails to start
* @throws InterruptedException
* if the startup is interrupted
*/
public String start() throws IOException, InterruptedException {
if (proxyUrl != null) {
return proxyUrl;
}
// Find the repo root by looking for the test/harness directory
Path harnessDir = findHarnessDirectory();
if (harnessDir == null) {
throw new IOException("Could not find test/harness directory. "
+ "Make sure you are running from within the copilot-sdk repository.");
}
// Start the harness server using npx tsx
var pb = new ProcessBuilder("npx", "tsx", "server.ts");
pb.directory(harnessDir.toFile());
pb.redirectErrorStream(false);
process = pb.start();
// Read stdout to get the listening URL
// Note: We keep the reader open to avoid closing the process input stream
stdoutReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
// Also consume stderr in a background thread to prevent blocking
Thread stderrThread = new Thread(() -> {
try (BufferedReader errReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String errLine;
while ((errLine = errReader.readLine()) != null) {
System.err.println("[CapiProxy stderr] " + errLine);
}
} catch (IOException e) {
// Ignore
}
});
stderrThread.setDaemon(true);
stderrThread.start();
String line = stdoutReader.readLine();
if (line == null) {
// Try to get error info
StringBuilder errInfo = new StringBuilder();
try (BufferedReader errReader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String errLine;
while ((errLine = errReader.readLine()) != null) {
errInfo.append(errLine).append("\n");
}
}
process.destroyForcibly();
throw new IOException("Failed to read proxy URL - server may have crashed. Stderr: " + errInfo);
}
Matcher matcher = LISTENING_PATTERN.matcher(line);
if (!matcher.find()) {
process.destroyForcibly();
throw new IOException("Unexpected proxy output: " + line);
}
proxyUrl = matcher.group(1);
return proxyUrl;
}
/**
* Configures the proxy for a specific test file.
*
* @param filePath
* the path to the YAML snapshot file (relative to repo root)
* @param workDir
* the working directory for path normalization
* @throws IOException
* if the configuration fails
* @throws InterruptedException
* if the request is interrupted
*/
public void configure(String filePath, String workDir) throws IOException, InterruptedException {
configure(filePath, workDir, null);
}
/**
* Configures the proxy for a specific test file.
*
* @param filePath
* the path to the YAML snapshot file (relative to repo root)
* @param workDir
* the working directory for path normalization
* @param testInfo
* optional test information (file and line number)
* @throws IOException
* if the configuration fails
* @throws InterruptedException
* if the request is interrupted
*/
public void configure(String filePath, String workDir, TestInfo testInfo) throws IOException, InterruptedException {
if (proxyUrl == null) {
throw new IllegalStateException("Proxy not started");
}
Map