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 packages/roach/src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function runCommand(command: string, env: NodeJS.ProcessEnv): Promise<number> {
}

const command = process.env.INPUT_RUN;
if (!command) throw new Error("The run input is required");
if (!command) throw new Error("Roach: the run input is required");
// The runner gives an empty string for an input that the step does not set.
const token = readEnv("INPUT_TOKEN");
const file = await readRoachFile();
Expand Down
7 changes: 4 additions & 3 deletions packages/roach/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import http from "node:http";
import https from "node:https";
import { tmpdir } from "node:os";
import path from "node:path";
import { DEFAULT_NO_PROXY } from "./config.ts";
import {
CONTROL_PATH,
type OpenedSession,
Expand Down Expand Up @@ -91,7 +92,7 @@ function callControl<T>(
if (status >= 400) {
reject(
new Error(
`Roach ${method} ${route} failed with HTTP ${status}: ${text}`,
`Roach: ${method} ${route} failed with HTTP ${status}: ${text}`,
),
);
} else {
Expand Down Expand Up @@ -129,7 +130,7 @@ export function connectRoach({
const url = opened?.url;
if (typeof url !== "string") {
throw new Error(
`Roach service at ${new URL(controlUrl).origin} is older than this client: it returned no session URL. Deploy the service at ${SESSION_URL_VERSION} or later.`,
`Roach: the service at ${new URL(controlUrl).origin} is older than this client: it returned no session URL. Deploy the service at ${SESSION_URL_VERSION} or later.`,
);
}
return {
Expand Down Expand Up @@ -168,7 +169,7 @@ export interface RemoteRoach extends RemoteRun, RoachControl {
export async function startRemoteRun(
service: { url: string; token?: string | undefined },
config: RunConfig,
{ noProxy = "localhost,127.0.0.1,::1" }: { noProxy?: string } = {},
{ noProxy = DEFAULT_NO_PROXY }: { noProxy?: string } = {},
): Promise<RemoteRoach> {
const run = await callControl<RemoteRun>(
`${service.url.replace(/\/$/, "")}${CONTROL_PATH}`,
Expand Down
35 changes: 31 additions & 4 deletions packages/roach/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { VALUE_PATTERNS } from "./values.ts";
/** The Roach service of Sentry. See "Deploy" in `README.md`. */
export const DEFAULT_SERVICE_URL = "https://roach-proxy.getsentry.net";

/** The hosts that do not use the proxy when `roach.json` sets no `noProxy`. */
export const DEFAULT_NO_PROXY = "localhost,127.0.0.1,::1";

/** The name of the config file. */
const FILE_NAME = "roach.json";

Expand Down Expand Up @@ -65,6 +68,23 @@ export function readEnv(
return value === "" ? undefined : value;
}

/**
* Set the variables `values` in `process.env`. Returns a function that
* sets each of them back. A variable that was not set before is deleted.
*/
export function swapEnv(values: Record<string, string>): () => void {
const previous = Object.keys(values).map(
(name) => [name, process.env[name]] as const,
);
Object.assign(process.env, values);
return () => {
for (const [name, value] of previous) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
};
}

/**
* Find `roach.json` in `from` or the closest parent directory, and read
* it. Throws when no directory has one.
Expand All @@ -79,10 +99,17 @@ export async function readRoachFile(
() => false,
);
if (found) {
return {
...(JSON.parse(await readFile(file, "utf8")) as RoachFile),
path: file,
};
const text = await readFile(file, "utf8");
let parsed: RoachFile;
try {
parsed = JSON.parse(text) as RoachFile;
} catch (error) {
throw new Error(
`Roach: ${file} is not valid JSON: ${(error as Error).message}`,
{ cause: error },
);
}
return { ...parsed, path: file };
}
const parent = path.dirname(directory);
if (parent === directory) {
Expand Down
16 changes: 6 additions & 10 deletions packages/roach/src/vitest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ProvidedContext,
} from "vitest";
import { connectRoach } from "./client.ts";
import { swapEnv } from "./config.ts";

const provided = inject("roach");
if (provided === undefined) {
Expand All @@ -36,23 +37,18 @@ function useRun(run: NonNullable<ProvidedContext["roach"]>): void {
// The full name starts with the file, so tests of two files never share
// a session.
const session = await control.startSession(task.fullName);
const previous = {
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
};
process.env.HTTP_PROXY = session.url;
process.env.HTTPS_PROXY = session.url;
const restoreEnv = swapEnv({
HTTP_PROXY: session.url,
HTTPS_PROXY: session.url,
});
const restoreProxy = http.setGlobalProxyFromEnv({
HTTP_PROXY: session.url,
HTTPS_PROXY: session.url,
NO_PROXY: run.noProxy,
});
onTestFinished(async ({ task: finished }) => {
restoreProxy();
for (const [name, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
restoreEnv();
const { missed } = await session.end(finished.result?.state === "pass");
// `replay` mode failed these requests, so nothing went live. The test
// must fail, also when it recovered from the error.
Expand Down
16 changes: 6 additions & 10 deletions packages/roach/src/vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import tls from "node:tls";
import type { TestProject } from "vitest/node";
import { startRemoteRun } from "./client.ts";
import {
DEFAULT_NO_PROXY,
DEFAULT_SERVICE_URL,
readEnv,
readRoachFile,
runConfigOf,
swapEnv,
} from "./config.ts";
import { writeRunReport } from "./report.ts";
import type { RemoteRun } from "./types.ts";
Expand Down Expand Up @@ -54,9 +56,9 @@ export async function startRoach(
}
// Only a run needs it, so `off` mode works on older Node too.
if (typeof http.setGlobalProxyFromEnv !== "function") {
throw new Error("Roach needs Node 24.14 or later for vitest");
throw new Error("Roach: vitest needs Node 24.14 or later");
}
const noProxy = file.noProxy ?? "localhost,127.0.0.1,::1";
const noProxy = file.noProxy ?? DEFAULT_NO_PROXY;
const run = await startRemoteRun(
{ url: readEnv("ROACH_URL") ?? DEFAULT_SERVICE_URL, token },
config,
Expand All @@ -66,11 +68,8 @@ export async function startRoach(
if (process.env.GITHUB_ACTIONS) {
process.stdout.write(`::add-mask::${run.token}\n`);
}
const previousEnv = Object.keys(run.env).map(
(name) => [name, process.env[name]] as const,
);
// Test workers and their child processes start later and inherit these.
Object.assign(process.env, run.env);
const restoreEnv = swapEnv(run.env);
// This process started before them, so it reads neither the proxy nor
// the CA file. Set both here.
const previousCa = tls.getCACertificates("default");
Expand All @@ -95,10 +94,7 @@ export async function startRoach(
} finally {
restoreProxy();
tls.setDefaultCACertificates(previousCa);
for (const [name, value] of previousEnv) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
restoreEnv();
}
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/service/tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ describe("roach service", () => {
token: "run-token",
}).startSession("test"),
).rejects.toThrow(
`Roach service at ${oldUrl} is older than this client: it returned no session URL. Deploy the service at 0.2.0 or later.`,
`Roach: the service at ${oldUrl} is older than this client: it returned no session URL. Deploy the service at 0.2.0 or later.`,
);
});

Expand Down
Loading