Skip to content
Merged
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
2 changes: 1 addition & 1 deletion dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public class RpcExtensionsLoadedE2ETests(E2ETestFixture fixture, ITestOutputHelp
/// </summary>
private Dictionary<string, string> ExtensionsEnabledEnvironment()
{
var env = new Dictionary<string, string>(Ctx.GetEnvironment(), StringComparer.OrdinalIgnoreCase)
var env = new Dictionary<string, string>(Ctx.GetEnvironment())
{
["COPILOT_CLI_ENABLED_FEATURE_FLAGS"] = "EXTENSIONS",
};
Expand Down
43 changes: 41 additions & 2 deletions dotnet/test/Harness/CapiProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public sealed partial class CapiProxy : IAsyncDisposable
private Process? _process;
private Task<string>? _startupTask;

public string? ConnectProxyUrl { get; private set; }
public string? CaFilePath { get; private set; }

public Task<string> StartAsync()
{
return _startupTask ??= StartCoreAsync();
Expand Down Expand Up @@ -57,8 +60,41 @@ async Task<string> StartCoreAsync()
_process.OutputDataReceived += (_, e) =>
{
if (e.Data == null) return;
var match = Regex.Match(e.Data, @"Listening: (http://[^\s]+)");
if (match.Success) tcs.TrySetResult(match.Groups[1].Value);
var match = Regex.Match(e.Data, @"Listening: (?<url>http://[^\s]+)\s+(?<metadata>\{.*\})$");
if (!match.Success)
{
if (e.Data.Contains("Listening: ", StringComparison.Ordinal))
{
tcs.TrySetException(
new InvalidOperationException(
$"Proxy startup line missing CONNECT proxy metadata: {e.Data}"));
}
return;
}
try
{
var metadata = JsonSerializer.Deserialize(
match.Groups["metadata"].Value,
CapiProxyJsonContext.Default.ProxyStartupMetadata);
ConnectProxyUrl = metadata?.ConnectProxyUrl;
CaFilePath = metadata?.CaFilePath;
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
tcs.TrySetException(
new InvalidOperationException(
$"Failed to parse proxy startup metadata: {match.Groups["metadata"].Value}",
ex));
return;
}
if (string.IsNullOrEmpty(ConnectProxyUrl) || string.IsNullOrEmpty(CaFilePath))
{
tcs.TrySetException(
new InvalidOperationException(
$"Proxy startup metadata missing CONNECT proxy details: {e.Data}"));
return;
}
tcs.TrySetResult(match.Groups["url"].Value);
};

_process.ErrorDataReceived += (_, e) =>
Expand Down Expand Up @@ -124,6 +160,8 @@ public async Task ConfigureAsync(string filePath, string workDir)

private record ConfigureRequest(string FilePath, string WorkDir);

private record ProxyStartupMetadata(string? ConnectProxyUrl, string? CaFilePath);

public async Task<List<ParsedHttpExchange>> GetExchangesAsync()
{
var url = await (_startupTask ?? throw new InvalidOperationException("Proxy not started"));
Expand Down Expand Up @@ -165,6 +203,7 @@ private static string FindRepoRoot()
[JsonSerializable(typeof(List<ParsedHttpExchange>))]
[JsonSerializable(typeof(CopilotUserByTokenRequest))]
[JsonSerializable(typeof(Dictionary<string, CopilotUserQuotaSnapshot>))]
[JsonSerializable(typeof(ProxyStartupMetadata))]
private partial class CapiProxyJsonContext : JsonSerializerContext;
}

Expand Down
25 changes: 25 additions & 0 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,33 @@ public IReadOnlyDictionary<string, string> GetEnvironment()

env["COPILOT_API_URL"] = ProxyUrl;
env["COPILOT_HOME"] = HomeDir;
env["GH_CONFIG_DIR"] = HomeDir;
env["XDG_CONFIG_HOME"] = HomeDir;
env["XDG_STATE_HOME"] = HomeDir;
if (!string.IsNullOrEmpty(_proxy.ConnectProxyUrl) && !string.IsNullOrEmpty(_proxy.CaFilePath))
{
const string noProxy = "127.0.0.1,localhost,::1";
env["HTTP_PROXY"] = _proxy.ConnectProxyUrl;
env["HTTPS_PROXY"] = _proxy.ConnectProxyUrl;
env["http_proxy"] = _proxy.ConnectProxyUrl;
env["https_proxy"] = _proxy.ConnectProxyUrl;
env["NO_PROXY"] = noProxy;
env["no_proxy"] = noProxy;
env["NODE_EXTRA_CA_CERTS"] = _proxy.CaFilePath;
env["SSL_CERT_FILE"] = _proxy.CaFilePath;
env["REQUESTS_CA_BUNDLE"] = _proxy.CaFilePath;
env["CURL_CA_BUNDLE"] = _proxy.CaFilePath;
env["GIT_SSL_CAINFO"] = _proxy.CaFilePath;
env["GH_TOKEN"] = "";
env["GITHUB_TOKEN"] = "";
env["GH_ENTERPRISE_TOKEN"] = "";
env["GITHUB_ENTERPRISE_TOKEN"] = "";
}
if (Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true")
{
env["GH_TOKEN"] = "fake-token-for-e2e-tests";
env["GITHUB_TOKEN"] = "fake-token-for-e2e-tests";
}

return env!;
}
Expand Down
8 changes: 8 additions & 0 deletions go/internal/e2e/testharness/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,20 @@ func (c *TestContext) Env() []string {
env := os.Environ()

// Add overrides (later values take precedence in most systems)
env = append(env, c.proxy.ProxyEnv()...)
env = append(env,
"COPILOT_API_URL="+c.ProxyURL,
"COPILOT_HOME="+c.HomeDir,
"GH_CONFIG_DIR="+c.HomeDir,
"XDG_CONFIG_HOME="+c.HomeDir,
"XDG_STATE_HOME="+c.HomeDir,
)
if os.Getenv("GITHUB_ACTIONS") == "true" {
env = append(env,
"GH_TOKEN=fake-token-for-e2e-tests",
"GITHUB_TOKEN=fake-token-for-e2e-tests",
)
}
return env
}

Expand Down
85 changes: 70 additions & 15 deletions go/internal/e2e/testharness/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import (
// CapiProxy manages a child process that acts as a replaying proxy to AI endpoints.
// It spawns the shared test harness server from test/harness/server.ts.
type CapiProxy struct {
cmd *exec.Cmd
proxyURL string
mu sync.Mutex
cmd *exec.Cmd
proxyURL string
connectProxyURL string
caFilePath string
mu sync.Mutex
}

// NewCapiProxy creates a new proxy instance.
Expand Down Expand Up @@ -54,23 +56,48 @@ func (p *CapiProxy) Start() (string, error) {
return "", fmt.Errorf("failed to start proxy server: %w", err)
}

// Read the first line to get the listening URL
// Read until the server prints "Listening: http://..."; npm/npx may emit
// wrapper output first on some platforms.
reader := bufio.NewReader(stdout)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
p.cmd.Process.Kill()
return "", fmt.Errorf("failed to read proxy URL: %w", err)
re := regexp.MustCompile(`Listening: (http://[^\s]+)\s+(\{.*\})$`)
var matches []string
var line string
for {
nextLine, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
p.cmd.Process.Kill()
return "", fmt.Errorf("failed to read proxy URL: %w", err)
}
line = strings.TrimSpace(nextLine)
matches = re.FindStringSubmatch(line)
if len(matches) >= 3 {
break
}
if strings.Contains(line, "Listening: ") {
p.cmd.Process.Kill()
return "", fmt.Errorf("proxy startup line missing CONNECT proxy metadata: %s", line)
}
if err == io.EOF {
p.cmd.Process.Kill()
return "", fmt.Errorf("proxy exited before startup; last output: %s", line)
}
}

// Parse "Listening: http://..." from output
re := regexp.MustCompile(`Listening: (http://[^\s]+)`)
matches := re.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) < 2 {
p.proxyURL = matches[1]
var metadata struct {
ConnectProxyURL string `json:"connectProxyUrl"`
CAFilePath string `json:"caFilePath"`
}
if err := json.Unmarshal([]byte(matches[2]), &metadata); err != nil {
p.cmd.Process.Kill()
return "", fmt.Errorf("unexpected proxy output: %s", line)
return "", fmt.Errorf("failed to parse proxy startup metadata: %w", err)
}
p.connectProxyURL = metadata.ConnectProxyURL
p.caFilePath = metadata.CAFilePath
if p.connectProxyURL == "" || p.caFilePath == "" {
p.cmd.Process.Kill()
return "", fmt.Errorf("proxy startup metadata missing CONNECT proxy details: %s", line)
}

p.proxyURL = matches[1]
return p.proxyURL, nil
}

Expand Down Expand Up @@ -254,6 +281,34 @@ func (p *CapiProxy) URL() string {
return p.proxyURL
}

// ProxyEnv returns environment variables that route HTTPS traffic through the CONNECT proxy.
func (p *CapiProxy) ProxyEnv() []string {
p.mu.Lock()
defer p.mu.Unlock()
if p.connectProxyURL == "" || p.caFilePath == "" {
return nil
}

noProxy := "127.0.0.1,localhost,::1"
return []string{
"HTTP_PROXY=" + p.connectProxyURL,
"HTTPS_PROXY=" + p.connectProxyURL,
"http_proxy=" + p.connectProxyURL,
"https_proxy=" + p.connectProxyURL,
"NO_PROXY=" + noProxy,
"no_proxy=" + noProxy,
"NODE_EXTRA_CA_CERTS=" + p.caFilePath,
"SSL_CERT_FILE=" + p.caFilePath,
"REQUESTS_CA_BUNDLE=" + p.caFilePath,
"CURL_CA_BUNDLE=" + p.caFilePath,
"GIT_SSL_CAINFO=" + p.caFilePath,
"GH_TOKEN=",
"GITHUB_TOKEN=",
"GH_ENTERPRISE_TOKEN=",
"GITHUB_ENTERPRISE_TOKEN=",
}
}

// SetCopilotUserByToken registers a per-token user configuration on the proxy.
func (p *CapiProxy) SetCopilotUserByToken(token string, response map[string]interface{}) error {
p.mu.Lock()
Expand Down
94 changes: 89 additions & 5 deletions nodejs/test/e2e/harness/CapiProxy.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { spawn } from "child_process";
import { resolve } from "path";
import { createInterface } from "readline";
import { expect } from "vitest";
import {
CopilotUserResponse,
ParsedHttpExchange,
} from "../../../../test/harness/replayingCapiProxy";

const HARNESS_SERVER_PATH = resolve(__dirname, "../../../../test/harness/server.ts");
const NO_PROXY = "127.0.0.1,localhost,::1";

interface ProxyStartupInfo {
capiProxyUrl: string;
connectProxyUrl?: string;
caFilePath?: string;
}

// Manages a child process that acts as a replaying proxy to the underlying AI endpoints
export class CapiProxy {
private proxyUrl: string | undefined;
private startupInfo: ProxyStartupInfo | undefined;

/**
* Returns the URL of the running proxy. Throws if the proxy has not been started.
Expand All @@ -28,16 +37,67 @@ export class CapiProxy {
shell: true,
});

this.proxyUrl = await new Promise<string>((resolve) => {
serverProcess.stdout!.once("data", (chunk: Buffer) => {
const match = chunk.toString().match(/Listening: (http:\/\/[^\s]+)/);
resolve(match![1]);
});
this.startupInfo = await new Promise<ProxyStartupInfo>((resolve, reject) => {
const stdout = serverProcess.stdout!;
const lines: string[] = [];
const lineReader = createInterface({ input: stdout });
const cleanup = () => {
lineReader.off("line", onLine);
serverProcess.off("exit", onExit);
lineReader.close();
};
const onLine = (line: string) => {
lines.push(line);
try {
const info = tryParseStartupInfo(line);
if (!info) {
return;
}
cleanup();
resolve(info);
} catch (error) {
cleanup();
reject(error);
}
};
const onExit = (code: number | null) => {
cleanup();
reject(
new Error(`Proxy exited before startup with code ${code}: ${lines.join("\n")}`)
);
};
lineReader.on("line", onLine);
serverProcess.once("exit", onExit);
});
this.proxyUrl = this.startupInfo.capiProxyUrl;
Comment thread
stephentoub marked this conversation as resolved.

return this.proxyUrl;
}

getProxyEnv(): Record<string, string> {
if (!this.startupInfo?.connectProxyUrl || !this.startupInfo.caFilePath) {
return {};
}

return {
HTTP_PROXY: this.startupInfo.connectProxyUrl,
HTTPS_PROXY: this.startupInfo.connectProxyUrl,
http_proxy: this.startupInfo.connectProxyUrl,
https_proxy: this.startupInfo.connectProxyUrl,
NO_PROXY,
no_proxy: NO_PROXY,
NODE_EXTRA_CA_CERTS: this.startupInfo.caFilePath,
SSL_CERT_FILE: this.startupInfo.caFilePath,
REQUESTS_CA_BUNDLE: this.startupInfo.caFilePath,
CURL_CA_BUNDLE: this.startupInfo.caFilePath,
GIT_SSL_CAINFO: this.startupInfo.caFilePath,
GH_TOKEN: "",
GITHUB_TOKEN: "",
GH_ENTERPRISE_TOKEN: "",
GITHUB_ENTERPRISE_TOKEN: "",
};
Comment thread
stephentoub marked this conversation as resolved.
}

async updateConfig(config: {
filePath: string;
workDir: string;
Expand Down Expand Up @@ -78,3 +138,27 @@ export class CapiProxy {
expect(res.ok).toBe(true);
}
}

function tryParseStartupInfo(line: string): ProxyStartupInfo | undefined {
if (!line) {
return undefined;
}

const match = line.match(/Listening: (http:\/\/[^\s]+)\s+(\{.*\})$/);
if (!match) {
if (!line.includes("Listening: ")) {
return undefined;
}
throw new Error(`Unexpected proxy output: ${line}`);
}

const metadata = JSON.parse(match[2]) as Partial<ProxyStartupInfo>;
if (!metadata.connectProxyUrl || !metadata.caFilePath) {
throw new Error(`Proxy startup metadata missing CONNECT proxy details: ${line}`);
}
return {
capiProxyUrl: match[1],
connectProxyUrl: metadata.connectProxyUrl,
caFilePath: metadata.caFilePath,
};
}
6 changes: 6 additions & 0 deletions nodejs/test/e2e/harness/sdkTestContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,21 @@ export async function createSdkTestContext({
const proxyUrl = await openAiEndpoint.start();
const env = {
...process.env,
...openAiEndpoint.getProxyEnv(),
COPILOT_API_URL: proxyUrl,
COPILOT_HOME: copilotHomeDir,
GH_CONFIG_DIR: homeDir,

// TODO: I'm not convinced the SDK should default to using whatever config you happen to have in your homedir.
// The SDK config should be independent of the regular CLI app. Likewise it shouldn't mix sessions from the
// SDK with those from the CLI app, at least not by default.
XDG_CONFIG_HOME: homeDir,
XDG_STATE_HOME: homeDir,
};
if (isCI) {
env.GH_TOKEN = "fake-token-for-e2e-tests";
env.GITHUB_TOKEN = "fake-token-for-e2e-tests";
}

const copilotClient = new CopilotClient({
cwd: workDir,
Expand Down
Loading
Loading