diff --git a/packages/roach/src/action.ts b/packages/roach/src/action.ts index 8f0c0a4..36b93a7 100644 --- a/packages/roach/src/action.ts +++ b/packages/roach/src/action.ts @@ -51,7 +51,7 @@ function runCommand(command: string, env: NodeJS.ProcessEnv): Promise { } 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(); diff --git a/packages/roach/src/client.ts b/packages/roach/src/client.ts index 8868c0f..6f1865b 100644 --- a/packages/roach/src/client.ts +++ b/packages/roach/src/client.ts @@ -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, @@ -91,7 +92,7 @@ function callControl( if (status >= 400) { reject( new Error( - `Roach ${method} ${route} failed with HTTP ${status}: ${text}`, + `Roach: ${method} ${route} failed with HTTP ${status}: ${text}`, ), ); } else { @@ -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 { @@ -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 { const run = await callControl( `${service.url.replace(/\/$/, "")}${CONTROL_PATH}`, diff --git a/packages/roach/src/config.ts b/packages/roach/src/config.ts index acc5117..33ceafa 100644 --- a/packages/roach/src/config.ts +++ b/packages/roach/src/config.ts @@ -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"; @@ -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): () => 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. @@ -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) { diff --git a/packages/roach/src/vitest-setup.ts b/packages/roach/src/vitest-setup.ts index 26493b7..057d930 100644 --- a/packages/roach/src/vitest-setup.ts +++ b/packages/roach/src/vitest-setup.ts @@ -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) { @@ -36,12 +37,10 @@ function useRun(run: NonNullable): 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, @@ -49,10 +48,7 @@ function useRun(run: NonNullable): void { }); 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. diff --git a/packages/roach/src/vitest.ts b/packages/roach/src/vitest.ts index ab3ec72..61408b3 100644 --- a/packages/roach/src/vitest.ts +++ b/packages/roach/src/vitest.ts @@ -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"; @@ -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, @@ -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"); @@ -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(); } }; } diff --git a/packages/service/tests/service.test.ts b/packages/service/tests/service.test.ts index 4af2ee9..ece17c8 100644 --- a/packages/service/tests/service.test.ts +++ b/packages/service/tests/service.test.ts @@ -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.`, ); });