/*---------------------------------------------------------------------------------------------
* 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.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.github.copilot.sdk.json.CopilotClientOptions;
/**
* E2E test context that manages the test environment including the CapiProxy,
* working directories, and CLI path.
*
*
* This provides a complete test environment similar to the Node.js, .NET, Go,
* and Python SDK test harnesses. It manages:
*
*
* - A replaying CapiProxy for deterministic API responses
* - Temporary home and work directories for test isolation
* - Environment variables for the Copilot CLI
*
*
*
* Usage example:
*
*
*
* {@code
* try (E2ETestContext ctx = E2ETestContext.create()) {
* ctx.configureForTest("tools", "my_test_name");
*
* try (CopilotClient client = ctx.createClient()) {
* CopilotSession session = client
* .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get();
* // ... run test ...
* }
* }
* }
*
*/
public class E2ETestContext implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(E2ETestContext.class.getName());
private static final Pattern SNAKE_CASE = Pattern.compile("[^a-zA-Z0-9]");
private static final Pattern USER_CONTENT_PATTERN = Pattern
.compile("^\\s+-\\s+role:\\s+user\\s*$\\s+content:\\s*(.+?)$", Pattern.MULTILINE);
private final String cliPath;
private final Path homeDir;
private final Path workDir;
private String proxyUrl;
private final CapiProxy proxy;
private final Path repoRoot;
private Path currentSnapshotFile;
private E2ETestContext(String cliPath, Path homeDir, Path workDir, String proxyUrl, CapiProxy proxy,
Path repoRoot) {
this.cliPath = cliPath;
this.homeDir = homeDir;
this.workDir = workDir;
this.proxyUrl = proxyUrl;
this.proxy = proxy;
this.repoRoot = repoRoot;
}
/**
* Creates a new E2E test context.
*
* @return the test context
* @throws IOException
* if setup fails
* @throws InterruptedException
* if setup is interrupted
*/
public static E2ETestContext create() throws IOException, InterruptedException {
Path repoRoot = findRepoRoot();
String cliPath = getCliPath(repoRoot);
Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"));
Path homeDir = Files.createTempDirectory(tempDir, "copilot-test-config-");
Path workDir = Files.createTempDirectory(tempDir, "copilot-test-work-");
CapiProxy proxy = new CapiProxy();
String proxyUrl = proxy.start();
return new E2ETestContext(cliPath, homeDir, workDir, proxyUrl, proxy, repoRoot);
}
/**
* Gets the Copilot CLI path.
*/
public String getCliPath() {
return cliPath;
}
/**
* Gets the temporary home directory for test isolation.
*/
public Path getHomeDir() {
return homeDir;
}
/**
* Gets the temporary working directory for tests.
*/
public Path getWorkDir() {
return workDir;
}
/**
* Gets the proxy URL.
*/
public String getProxyUrl() {
return proxyUrl;
}
/**
* Configures the proxy for a specific test.
*
* @param testFile
* the test category folder (e.g., "tools", "session", "permissions")
* @param testName
* the test method name (will be converted to snake_case)
* @throws IOException
* if configuration fails
* @throws InterruptedException
* if configuration is interrupted
*/
public void configureForTest(String testFile, String testName) throws IOException, InterruptedException {
// Restart the proxy if it has crashed
ensureProxyAlive();
// Convert test method names to lowercase snake_case for snapshot filenames
// to avoid case collisions on case-insensitive filesystems (macOS/Windows)
String sanitizedName = SNAKE_CASE.matcher(testName).replaceAll("_").toLowerCase();
Path snapshotFile = repoRoot.resolve("test").resolve("snapshots").resolve(testFile)
.resolve(sanitizedName + ".yaml");
// Validate snapshot exists - fail fast with a clear message
if (!Files.exists(snapshotFile)) {
Path snapshotsDir = repoRoot.resolve("test").resolve("snapshots").resolve(testFile);
String availableSnapshots = "";
if (Files.exists(snapshotsDir)) {
try (var files = Files.list(snapshotsDir)) {
availableSnapshots = files.filter(p -> p.toString().endsWith(".yaml"))
.map(p -> p.getFileName().toString().replace(".yaml", "")).sorted()
.reduce((a, b) -> a + ", " + b).orElse("");
}
}
throw new IOException(String.format(
"Snapshot file not found: %s%n" + "Category: %s, Test: %s (sanitized: %s)%n"
+ "Available snapshots in '%s/': %s%n"
+ "Ensure the snapshot exists and the test name matches exactly.",
snapshotFile, testFile, testName, sanitizedName, testFile, availableSnapshots));
}
this.currentSnapshotFile = snapshotFile;
proxy.configure(snapshotFile.toString(), workDir.toString());
// Log expected prompts to help debug prompt mismatch issues
List expectedPrompts = getExpectedUserPrompts();
if (!expectedPrompts.isEmpty()) {
LOG.info(() -> String.format("Configured snapshot '%s/%s' expects prompts: %s", testFile, sanitizedName,
expectedPrompts));
}
}
/**
* Gets the expected user prompts from the current snapshot file.
*
* This is useful for debugging when tests fail with "No cached response found"
* errors from CapiProxy. The prompts in your test must match these exactly.
*
*
* @return list of expected user prompt strings, or empty list if none found
*/
public List getExpectedUserPrompts() {
if (currentSnapshotFile == null || !Files.exists(currentSnapshotFile)) {
return List.of();
}
try {
String content = Files.readString(currentSnapshotFile);
List prompts = new ArrayList<>();
Matcher matcher = USER_CONTENT_PATTERN.matcher(content);
while (matcher.find()) {
String prompt = matcher.group(1).trim();
// Remove quotes if present
if ((prompt.startsWith("\"") && prompt.endsWith("\""))
|| (prompt.startsWith("'") && prompt.endsWith("'"))) {
prompt = prompt.substring(1, prompt.length() - 1);
}
if (!prompts.contains(prompt)) {
prompts.add(prompt);
}
}
return prompts;
} catch (IOException e) {
LOG.warning("Failed to read snapshot file: " + e.getMessage());
return List.of();
}
}
/**
* Ensures the proxy is alive, restarting it if necessary.
*
* @throws IOException
* if the proxy cannot be restarted
* @throws InterruptedException
* if interrupted during restart
*/
public void ensureProxyAlive() throws IOException, InterruptedException {
if (!proxy.isAlive()) {
proxyUrl = proxy.restart();
}
}
/**
* Gets the captured HTTP exchanges from the proxy.
*
* @return list of exchange maps
* @throws IOException
* if the request fails
* @throws InterruptedException
* if the request is interrupted
*/
public List