Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
test: move proxy workflow plumbing into helper
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
  • Loading branch information
parkerbxyz and Copilot committed Mar 13, 2026
commit a7b1842611e1deb0cf0aa7902c8923660d1f3555
29 changes: 5 additions & 24 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ jobs:
runs-on: ubuntu-latest
# do not run from forks, as forks don’t have access to repository secrets
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login
env:
PROXY_LOG_PATH: ${{ runner.temp }}/proxy-events.jsonl
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
Expand All @@ -70,21 +72,7 @@ jobs:
- run: npm ci
- run: npm run build
- name: Start local proxy
env:
PROXY_LOG_PATH: ${{ runner.temp }}/proxy-events.jsonl
PROXY_SERVER_LOG_PATH: ${{ runner.temp }}/proxy-server.log
run: |
node scripts/test-proxy-server.js "$PROXY_LOG_PATH" 3128 >"$PROXY_SERVER_LOG_PATH" 2>&1 &
echo $! > "$RUNNER_TEMP/proxy-server.pid"

for _ in $(seq 1 30); do
if test -f "$PROXY_SERVER_LOG_PATH" && grep -q "Proxy server listening" "$PROXY_SERVER_LOG_PATH"; then
break
fi
sleep 1
done

grep -q "Proxy server listening" "$PROXY_SERVER_LOG_PATH"
run: node scripts/test-proxy-server.js start "$PROXY_LOG_PATH" 3128
- uses: ./ # Uses the action in the root directory
env:
NODE_USE_ENV_PROXY: "1"
Expand All @@ -93,17 +81,10 @@ jobs:
app-id: ${{ vars.TEST_APP_ID }}
private-key: ${{ secrets.TEST_APP_PRIVATE_KEY }}
- name: Assert proxy traffic
env:
PROXY_LOG_PATH: ${{ runner.temp }}/proxy-events.jsonl
run: node scripts/test-proxy-server.js assert "$PROXY_LOG_PATH" api.github.com:443
- if: failure()
name: Show proxy logs
run: |
test -f "$RUNNER_TEMP/proxy-server.log" && cat "$RUNNER_TEMP/proxy-server.log"
test -f "$RUNNER_TEMP/proxy-events.jsonl" && cat "$RUNNER_TEMP/proxy-events.jsonl"
run: node scripts/test-proxy-server.js logs "$PROXY_LOG_PATH"
- if: always()
name: Stop local proxy
run: |
if test -f "$RUNNER_TEMP/proxy-server.pid"; then
kill "$(cat "$RUNNER_TEMP/proxy-server.pid")"
fi
run: node scripts/test-proxy-server.js stop "$PROXY_LOG_PATH"
238 changes: 217 additions & 21 deletions scripts/test-proxy-server.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,150 @@
import { spawn } from "node:child_process";
import { closeSync, openSync } from "node:fs";
import http from "node:http";
import net from "node:net";
import { mkdir, appendFile, readFile } from "node:fs/promises";
import { mkdir, appendFile, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { setTimeout as delay } from "node:timers/promises";

function getProxyPidPath(logPath) {
return `${logPath}.pid`;
}

function getProxyServerLogPath(logPath) {
return `${logPath}.server.log`;
}

function isFileNotFoundError(error) {
return error && typeof error === "object" && error.code === "ENOENT";
}

function isProcessMissingError(error) {
return error && typeof error === "object" && error.code === "ESRCH";
}

function parsePort(value, usage) {
const port = Number(value ?? 3128);

if (!Number.isInteger(port) || port <= 0) {
throw new Error(`${usage}\nInvalid port: ${value}`);
}

return port;
}

async function readEvents(logPath) {
try {
return (await readFile(logPath, "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
} catch (error) {
if (isFileNotFoundError(error)) {
return [];
}

throw error;
}
}

function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (isProcessMissingError(error)) {
return false;
}

throw error;
}
}

async function waitForProxyServer(logPath, pid, serverLogPath) {
for (let attempt = 0; attempt < 30; attempt++) {
const events = await readEvents(logPath);

if (events.some((event) => event.event === "listening")) {
return;
}

if (!isProcessRunning(pid)) {
throw new Error(
`Proxy server exited before it was ready. See ${serverLogPath}`,
);
}

await delay(1000);
}

throw new Error(
`Timed out waiting for proxy server readiness. See ${serverLogPath}`,
);
}

async function printIfExists(path) {
try {
process.stdout.write(await readFile(path, "utf8"));
} catch (error) {
if (!isFileNotFoundError(error)) {
throw error;
}
}
}

function reportLogWriteFailure(error) {
console.error("Failed to write proxy log", error);
}

const command = process.argv[2];

if (command === "start") {
const logPath = process.argv[3];
const usage = "Usage: node scripts/test-proxy-server.js start <log-path> [port]";
const port = parsePort(process.argv[4], usage);

if (!logPath) {
throw new Error(usage);
}

const pidPath = getProxyPidPath(logPath);
const serverLogPath = getProxyServerLogPath(logPath);

await mkdir(dirname(logPath), { recursive: true });
await writeFile(logPath, "");
await writeFile(serverLogPath, "");

const serverLogFd = openSync(serverLogPath, "a");
const child = spawn(
process.execPath,
[process.argv[1], "serve", logPath, String(port)],
{
detached: true,
stdio: ["ignore", serverLogFd, serverLogFd],
},
);
closeSync(serverLogFd);

if (child.pid === undefined) {
throw new Error("Failed to start proxy server");
}

child.unref();
await writeFile(pidPath, `${child.pid}\n`);

try {
await waitForProxyServer(logPath, child.pid, serverLogPath);
} catch (error) {
if (isProcessRunning(child.pid)) {
process.kill(child.pid, "SIGTERM");
}

throw error;
}

process.exit(0);
}

if (command === "assert") {
const logPath = process.argv[3];
const expectedTarget = process.argv[4];
Expand All @@ -15,10 +155,7 @@ if (command === "assert") {
);
}

const events = (await readFile(logPath, "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
const events = await readEvents(logPath);

if (
!events.some(
Expand All @@ -34,26 +171,81 @@ if (command === "assert") {
process.exit(0);
}

const logPath = process.argv[2];
const port = Number(process.argv[3] ?? 3128);
if (command === "logs") {
const logPath = process.argv[3];

if (!logPath) {
throw new Error("Usage: node scripts/test-proxy-server.js <log-path> [port]");
if (!logPath) {
throw new Error("Usage: node scripts/test-proxy-server.js logs <log-path>");
}

const serverLogPath = getProxyServerLogPath(logPath);

await printIfExists(serverLogPath);
await printIfExists(logPath);
process.exit(0);
}

if (!Number.isInteger(port) || port <= 0) {
throw new Error(`Invalid port: ${process.argv[3]}`);
if (command === "stop") {
const logPath = process.argv[3];

if (!logPath) {
throw new Error("Usage: node scripts/test-proxy-server.js stop <log-path>");
}

const pidPath = getProxyPidPath(logPath);
let pidText;

try {
pidText = (await readFile(pidPath, "utf8")).trim();
} catch (error) {
if (isFileNotFoundError(error)) {
process.exit(0);
}

throw error;
}

const pid = Number(pidText);

if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`Invalid proxy process ID in ${pidPath}: ${pidText}`);
}

if (!isProcessRunning(pid)) {
process.exit(0);
}

process.kill(pid, "SIGTERM");

for (let attempt = 0; attempt < 50; attempt++) {
if (!isProcessRunning(pid)) {
process.exit(0);
}

await delay(100);
}

throw new Error(`Timed out waiting for proxy server process ${pid} to exit`);
}

const usesServeSubcommand = command === "serve";
const logPath = process.argv[usesServeSubcommand ? 3 : 2];
const usage = usesServeSubcommand
? "Usage: node scripts/test-proxy-server.js serve <log-path> [port]"
: "Usage: node scripts/test-proxy-server.js <log-path> [port]";
const port = parsePort(process.argv[usesServeSubcommand ? 4 : 3], usage);

if (!logPath) {
throw new Error(usage);
}

await mkdir(dirname(logPath), { recursive: true });

function logEvent(event) {
return appendFile(
async function logEvent(event) {
await appendFile(
logPath,
`${JSON.stringify({ ...event, timestamp: new Date().toISOString() })}\n`,
).catch((error) => {
console.error("Failed to write proxy log", error);
});
);
}

const server = http.createServer((req, res) => {
Expand All @@ -62,7 +254,7 @@ const server = http.createServer((req, res) => {
method: req.method,
url: req.url,
host: req.headers.host,
});
}).catch(reportLogWriteFailure);

res.writeHead(501, { "content-type": "text/plain" });
res.end("This test proxy only supports CONNECT requests.\n");
Expand All @@ -76,7 +268,7 @@ server.on("connect", (req, clientSocket, head) => {
event: "connect",
target: req.url,
host: req.headers.host,
});
}).catch(reportLogWriteFailure);

const targetSocket = net.connect(targetPort, hostname, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
Expand All @@ -94,7 +286,7 @@ server.on("connect", (req, clientSocket, head) => {
event: "target-error",
target: req.url,
message: error.message,
});
}).catch(reportLogWriteFailure);

clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
});
Expand All @@ -104,7 +296,7 @@ server.on("connect", (req, clientSocket, head) => {
event: "client-error",
target: req.url,
message: error.message,
});
}).catch(reportLogWriteFailure);

targetSocket.destroy(error);
});
Expand All @@ -114,7 +306,7 @@ server.on("clientError", (error, socket) => {
void logEvent({
event: "proxy-error",
message: error.message,
});
}).catch(reportLogWriteFailure);

socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
});
Expand All @@ -129,4 +321,8 @@ process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
await logEvent({
event: "listening",
address: `http://127.0.0.1:${port}`,
});
console.log(`Proxy server listening on http://127.0.0.1:${port}`);