@sentry/roach 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +1075 -2
- package/package.json +43 -3
- package/src/action.ts +83 -0
- package/src/client.ts +234 -0
- package/src/config.ts +188 -0
- package/src/report.ts +86 -0
- package/src/types.ts +181 -0
- package/src/values.ts +267 -0
- package/src/vitest-setup.ts +62 -0
- package/src/vitest.ts +105 -0
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/roach",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A recording HTTPS proxy for tests, as a shared service.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/getsentry/roach.git",
|
|
9
|
+
"directory": "packages/roach"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
"./client": "./src/client.ts",
|
|
17
|
+
"./report": "./src/report.ts",
|
|
18
|
+
"./types": "./src/types.ts",
|
|
19
|
+
"./values": "./src/values.ts",
|
|
20
|
+
"./vitest": "./src/vitest.ts",
|
|
21
|
+
"./vitest/setup": "./src/vitest-setup.ts"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^24.19.1",
|
|
28
|
+
"typescript": "^7.0.2",
|
|
29
|
+
"vitest": "^5.1.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"vitest": ">=3"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"vitest": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=24"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"typecheck": "tsc -p ."
|
|
45
|
+
}
|
|
6
46
|
}
|
package/src/action.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub Action of Roach (`action.yml`). See "Use the GitHub Action" in
|
|
3
|
+
* `README.md`.
|
|
4
|
+
*
|
|
5
|
+
* It starts a run, runs the command of the step with the proxy variables of
|
|
6
|
+
* the run, and ends the run. The run config comes from `roach.json`
|
|
7
|
+
* (`config.ts`).
|
|
8
|
+
* The runner gives each input as `INPUT_<NAME>`.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { startRemoteRun } from "./client.ts";
|
|
12
|
+
import { readEnv, readRoachFile, runConfigOf } from "./config.ts";
|
|
13
|
+
import { writeRunReport } from "./report.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Variables that the command does not get: the inputs, which have the token,
|
|
17
|
+
* the tokens of the runner (`ACTIONS_*`, such as `ACTIONS_RUNTIME_TOKEN` and
|
|
18
|
+
* `ACTIONS_ID_TOKEN_REQUEST_TOKEN`), and the proxy variables of the job.
|
|
19
|
+
*/
|
|
20
|
+
const HIDDEN_VARIABLE =
|
|
21
|
+
/^(INPUT_.*|ACTIONS_.*|https?_proxy|no_proxy|all_proxy)$/i;
|
|
22
|
+
|
|
23
|
+
/** Run the command in bash, and return its exit code. */
|
|
24
|
+
function runCommand(command: string, env: NodeJS.ProcessEnv): Promise<number> {
|
|
25
|
+
// `detached` puts bash and its children in a new process group.
|
|
26
|
+
const child = spawn("bash", ["-e", "-o", "pipefail", "-c", command], {
|
|
27
|
+
detached: true,
|
|
28
|
+
env,
|
|
29
|
+
stdio: "inherit",
|
|
30
|
+
});
|
|
31
|
+
// A canceled job sends a signal. Bash does not pass it to its children, so
|
|
32
|
+
// send it to the whole process group. Then end the run.
|
|
33
|
+
const stop = (signal: NodeJS.Signals) => {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(-child.pid!, signal);
|
|
36
|
+
} catch {
|
|
37
|
+
// The process group has already stopped.
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
process.on("SIGINT", stop);
|
|
41
|
+
process.on("SIGTERM", stop);
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
child.once("error", reject);
|
|
44
|
+
child.once("exit", (code) => {
|
|
45
|
+
// Stop what the command left in the background, such as `cmd &`. Bash
|
|
46
|
+
// makes it ignore `SIGINT`, and the run ends next.
|
|
47
|
+
stop("SIGTERM");
|
|
48
|
+
resolve(code ?? 1);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const command = process.env.INPUT_RUN;
|
|
54
|
+
if (!command) throw new Error("Roach: the run input is required");
|
|
55
|
+
// The runner gives an empty string for an input that the step does not set.
|
|
56
|
+
const token = readEnv("INPUT_TOKEN");
|
|
57
|
+
const file = await readRoachFile();
|
|
58
|
+
const config = runConfigOf(file, { canWrite: token !== undefined });
|
|
59
|
+
const run = await startRemoteRun(
|
|
60
|
+
{ url: process.env.INPUT_URL!, token },
|
|
61
|
+
config,
|
|
62
|
+
file.noProxy ? { noProxy: file.noProxy } : {},
|
|
63
|
+
);
|
|
64
|
+
// The proxy URL has the run token. Keep it out of the log.
|
|
65
|
+
process.stdout.write(`::add-mask::${run.token}\n`);
|
|
66
|
+
|
|
67
|
+
let exitCode = 1;
|
|
68
|
+
try {
|
|
69
|
+
const session = await run.startSession(process.env.GITHUB_JOB ?? "command");
|
|
70
|
+
exitCode = await runCommand(command, {
|
|
71
|
+
...Object.fromEntries(
|
|
72
|
+
Object.entries(process.env).filter(
|
|
73
|
+
([name]) => !HIDDEN_VARIABLE.test(name),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
...run.env,
|
|
77
|
+
});
|
|
78
|
+
const { missed } = await session.end(exitCode === 0);
|
|
79
|
+
if (missed > 0 && exitCode === 0) exitCode = 1;
|
|
80
|
+
} finally {
|
|
81
|
+
await writeRunReport(`Roach (${config.mode})`, await run.close());
|
|
82
|
+
}
|
|
83
|
+
process.exitCode = exitCode;
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client of Roach. See `README.md`.
|
|
3
|
+
*
|
|
4
|
+
* `startRemoteRun()` starts a run on a Roach service
|
|
5
|
+
* (`@sentry/roach-service`). It returns `env`, the variables that send the
|
|
6
|
+
* traffic of a process through the run. `connectRoach()` controls a run
|
|
7
|
+
* from another process, such as a test worker.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import https from "node:https";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { DEFAULT_NO_PROXY } from "./config.ts";
|
|
15
|
+
import {
|
|
16
|
+
CONTROL_PATH,
|
|
17
|
+
type OpenedSession,
|
|
18
|
+
type RecordingStats,
|
|
19
|
+
type RemoteRun,
|
|
20
|
+
type RunConfig,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/** The open session of one test. */
|
|
24
|
+
export interface RecordingSession {
|
|
25
|
+
/**
|
|
26
|
+
* The proxy URL of the session. Send the requests of the test through
|
|
27
|
+
* it when other sessions of the run can be open at the same time.
|
|
28
|
+
*/
|
|
29
|
+
url: string;
|
|
30
|
+
/**
|
|
31
|
+
* End the session. A passed session writes its new recordings, and a
|
|
32
|
+
* failed one drops them. `missed` counts the requests of the session that
|
|
33
|
+
* `replay` mode failed. A test with a miss must fail.
|
|
34
|
+
*/
|
|
35
|
+
end(passed: boolean): Promise<{ missed: number }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The control API of a run. */
|
|
39
|
+
export interface RoachControl {
|
|
40
|
+
/**
|
|
41
|
+
* Open the session of one test. Many sessions can be open at once. A
|
|
42
|
+
* request through `url` of the run belongs to the open session only when
|
|
43
|
+
* one is open, so concurrent tests send through `url` of their session.
|
|
44
|
+
*/
|
|
45
|
+
startSession(name: string): Promise<RecordingSession>;
|
|
46
|
+
/** The totals of the run. */
|
|
47
|
+
stats(): Promise<RecordingStats>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The request functions of `node:http` and `node:https` as this module
|
|
52
|
+
* loads. In-process HTTP mocks, such as MSW and nock, replace them when they
|
|
53
|
+
* start. Control calls go to the service, never to a mock, so they use the
|
|
54
|
+
* originals. Load the setup file of Roach before a setup file that starts a
|
|
55
|
+
* mock.
|
|
56
|
+
*/
|
|
57
|
+
const httpRequest = http.request;
|
|
58
|
+
const httpsRequest = https.request;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Call the control API at `base`, and return the JSON of the response.
|
|
62
|
+
* `token` is the bearer token.
|
|
63
|
+
*/
|
|
64
|
+
function callControl<T>(
|
|
65
|
+
base: string,
|
|
66
|
+
token: string | undefined,
|
|
67
|
+
method: "DELETE" | "GET" | "POST",
|
|
68
|
+
route: string,
|
|
69
|
+
body?: unknown,
|
|
70
|
+
): Promise<T> {
|
|
71
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
72
|
+
const url = route ? `${base}/${route}` : base;
|
|
73
|
+
const send = url.startsWith("https:") ? httpsRequest : httpRequest;
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const request = send(
|
|
76
|
+
url,
|
|
77
|
+
{
|
|
78
|
+
// Its own agent, so that a proxy agent of the process is not used.
|
|
79
|
+
agent: false,
|
|
80
|
+
method,
|
|
81
|
+
headers: {
|
|
82
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
83
|
+
...(payload ? { "content-type": "application/json" } : {}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
(response) => {
|
|
87
|
+
const chunks: Buffer[] = [];
|
|
88
|
+
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
89
|
+
response.on("end", () => {
|
|
90
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
91
|
+
const status = response.statusCode ?? 500;
|
|
92
|
+
if (status >= 400) {
|
|
93
|
+
reject(
|
|
94
|
+
new Error(
|
|
95
|
+
`Roach: ${method} ${route} failed with HTTP ${status}: ${text}`,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
} else {
|
|
99
|
+
resolve((text ? JSON.parse(text) : undefined) as T);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
response.on("error", reject);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
request.on("error", reject);
|
|
106
|
+
request.end(payload);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The first service version that returns the proxy URL of a session. An
|
|
112
|
+
* older service answers `POST /session` with an empty body.
|
|
113
|
+
*/
|
|
114
|
+
const SESSION_URL_VERSION = "0.2.0";
|
|
115
|
+
|
|
116
|
+
/** Control a run, for example from a test worker. */
|
|
117
|
+
export function connectRoach({
|
|
118
|
+
controlUrl,
|
|
119
|
+
token,
|
|
120
|
+
}: Pick<RemoteRun, "controlUrl" | "token">): RoachControl {
|
|
121
|
+
return {
|
|
122
|
+
async startSession(name) {
|
|
123
|
+
const opened = await callControl<OpenedSession | undefined>(
|
|
124
|
+
controlUrl,
|
|
125
|
+
token,
|
|
126
|
+
"POST",
|
|
127
|
+
"session",
|
|
128
|
+
{ name },
|
|
129
|
+
);
|
|
130
|
+
const url = opened?.url;
|
|
131
|
+
if (typeof url !== "string") {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`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.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
url,
|
|
138
|
+
end: (passed) =>
|
|
139
|
+
callControl(controlUrl, token, "POST", "session/end", {
|
|
140
|
+
name,
|
|
141
|
+
passed,
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
stats: () => callControl(controlUrl, token, "GET", "stats"),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A run on a Roach service (`@sentry/roach-service`). */
|
|
150
|
+
export interface RemoteRoach extends RemoteRun, RoachControl {
|
|
151
|
+
/**
|
|
152
|
+
* The variables that send the HTTP traffic of a process through the run:
|
|
153
|
+
* `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and
|
|
154
|
+
* `NODE_EXTRA_CA_CERTS`. Give them to a process when it starts. Node
|
|
155
|
+
* reads the last two only at startup.
|
|
156
|
+
*/
|
|
157
|
+
env: Record<string, string>;
|
|
158
|
+
/** End the run. Returns its stats. */
|
|
159
|
+
close(): Promise<RecordingStats>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start a run on a Roach service, such as `https://roach.example.com`.
|
|
164
|
+
*
|
|
165
|
+
* `token` is the write token of the service. Only this call uses it. Without it,
|
|
166
|
+
* the service only allows `replay` mode, so CI jobs of forks can replay
|
|
167
|
+
* without a secret. The run and its workers use the run token.
|
|
168
|
+
*/
|
|
169
|
+
export async function startRemoteRun(
|
|
170
|
+
service: { url: string; token?: string | undefined },
|
|
171
|
+
config: RunConfig,
|
|
172
|
+
{ noProxy = DEFAULT_NO_PROXY }: { noProxy?: string } = {},
|
|
173
|
+
): Promise<RemoteRoach> {
|
|
174
|
+
const run = await callControl<RemoteRun>(
|
|
175
|
+
`${service.url.replace(/\/$/, "")}${CONTROL_PATH}`,
|
|
176
|
+
service.token,
|
|
177
|
+
"POST",
|
|
178
|
+
"runs",
|
|
179
|
+
config,
|
|
180
|
+
);
|
|
181
|
+
const proxyEnv = await createProxyEnv(run, noProxy).catch(async (error) => {
|
|
182
|
+
// End the run, or it stays open until the service times it out.
|
|
183
|
+
await callControl(run.controlUrl, run.token, "DELETE", "").catch(() => {});
|
|
184
|
+
throw error;
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
...run,
|
|
188
|
+
...connectRoach(run),
|
|
189
|
+
env: proxyEnv.env,
|
|
190
|
+
async close() {
|
|
191
|
+
try {
|
|
192
|
+
return await callControl<RecordingStats>(
|
|
193
|
+
run.controlUrl,
|
|
194
|
+
run.token,
|
|
195
|
+
"DELETE",
|
|
196
|
+
"",
|
|
197
|
+
);
|
|
198
|
+
} finally {
|
|
199
|
+
await proxyEnv.remove();
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The proxy variables of a process, with the CA certificate in a file. The
|
|
207
|
+
* file also has the certificates of `NODE_EXTRA_CA_CERTS` of this process,
|
|
208
|
+
* so the process still trusts them.
|
|
209
|
+
*/
|
|
210
|
+
async function createProxyEnv(
|
|
211
|
+
address: Pick<RemoteRun, "caCert" | "url">,
|
|
212
|
+
noProxy: string,
|
|
213
|
+
): Promise<{ env: Record<string, string>; remove(): Promise<void> }> {
|
|
214
|
+
// `NODE_EXTRA_CA_CERTS` takes a file.
|
|
215
|
+
const caDirectory = await mkdtemp(path.join(tmpdir(), "roach-"));
|
|
216
|
+
const caFile = path.join(caDirectory, "ca.pem");
|
|
217
|
+
const extra = process.env.NODE_EXTRA_CA_CERTS;
|
|
218
|
+
await writeFile(
|
|
219
|
+
caFile,
|
|
220
|
+
extra
|
|
221
|
+
? `${address.caCert.trimEnd()}\n${await readFile(extra, "utf8")}`
|
|
222
|
+
: address.caCert,
|
|
223
|
+
);
|
|
224
|
+
return {
|
|
225
|
+
env: {
|
|
226
|
+
HTTP_PROXY: address.url,
|
|
227
|
+
HTTPS_PROXY: address.url,
|
|
228
|
+
NO_PROXY: noProxy,
|
|
229
|
+
NODE_USE_ENV_PROXY: "1",
|
|
230
|
+
NODE_EXTRA_CA_CERTS: caFile,
|
|
231
|
+
},
|
|
232
|
+
remove: () => rm(caDirectory, { force: true, recursive: true }),
|
|
233
|
+
};
|
|
234
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `roach.json`: the run config of a repository. The GitHub Action
|
|
3
|
+
* (`action.ts`) and the vitest integration (`vitest.ts`) both read it, so a
|
|
4
|
+
* repository writes its origins and rules once, as data. See "Config file"
|
|
5
|
+
* in `README.md`.
|
|
6
|
+
*/
|
|
7
|
+
import { access, readFile } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import type {
|
|
10
|
+
RecordingMode,
|
|
11
|
+
RecordingRule,
|
|
12
|
+
RunCi,
|
|
13
|
+
RunConfig,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
import { VALUE_PATTERNS } from "./values.ts";
|
|
16
|
+
|
|
17
|
+
/** The Roach service of Sentry. See "Deploy" in `README.md`. */
|
|
18
|
+
export const DEFAULT_SERVICE_URL = "https://roach-proxy.getsentry.net";
|
|
19
|
+
|
|
20
|
+
/** The hosts that do not use the proxy when `roach.json` sets no `noProxy`. */
|
|
21
|
+
export const DEFAULT_NO_PROXY = "localhost,127.0.0.1,::1";
|
|
22
|
+
|
|
23
|
+
/** The name of the config file. */
|
|
24
|
+
const FILE_NAME = "roach.json";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A changing value in `roach.json`: a regular expression source, or the
|
|
28
|
+
* name of a pattern in `VALUE_PATTERNS`, such as `{ "builtin": "uuid" }`.
|
|
29
|
+
*/
|
|
30
|
+
type FileValue = string | { builtin: keyof typeof VALUE_PATTERNS };
|
|
31
|
+
|
|
32
|
+
/** A rule in `roach.json`. */
|
|
33
|
+
interface FileRule extends Omit<RecordingRule, "values"> {
|
|
34
|
+
values?: Record<string, FileValue>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The content of `roach.json`. */
|
|
38
|
+
export interface RoachFile {
|
|
39
|
+
allow: string[];
|
|
40
|
+
rules: FileRule[];
|
|
41
|
+
/** Default: `GITHUB_REPOSITORY`. */
|
|
42
|
+
tenant?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Environment variables that hold credentials, such as
|
|
45
|
+
* `ANTHROPIC_API_KEY`. The run never writes their values.
|
|
46
|
+
*/
|
|
47
|
+
secretEnv?: string[];
|
|
48
|
+
/** Hosts that do not use the proxy. Default: loopback. */
|
|
49
|
+
noProxy?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MODES = new Set<string>(["auto", "off", "record", "replay"]);
|
|
53
|
+
|
|
54
|
+
/** The variable of each field of `RunConfig.ci`. */
|
|
55
|
+
const CI_VARIABLES: Record<keyof RunCi, string> = {
|
|
56
|
+
runId: "GITHUB_RUN_ID",
|
|
57
|
+
job: "GITHUB_JOB",
|
|
58
|
+
repo: "GITHUB_REPOSITORY",
|
|
59
|
+
ref: "GITHUB_REF",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A variable of `env`, or `undefined` when it is not set or is blank. */
|
|
63
|
+
export function readEnv(
|
|
64
|
+
name: string,
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
): string | undefined {
|
|
67
|
+
const value = env[name]?.trim();
|
|
68
|
+
return value === "" ? undefined : value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set the variables `values` in `process.env`. Returns a function that
|
|
73
|
+
* sets each of them back. A variable that was not set before is deleted.
|
|
74
|
+
*/
|
|
75
|
+
export function swapEnv(values: Record<string, string>): () => void {
|
|
76
|
+
const previous = Object.keys(values).map(
|
|
77
|
+
(name) => [name, process.env[name]] as const,
|
|
78
|
+
);
|
|
79
|
+
Object.assign(process.env, values);
|
|
80
|
+
return () => {
|
|
81
|
+
for (const [name, value] of previous) {
|
|
82
|
+
if (value === undefined) delete process.env[name];
|
|
83
|
+
else process.env[name] = value;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Find `roach.json` in `from` or the closest parent directory, and read
|
|
90
|
+
* it. Throws when no directory has one.
|
|
91
|
+
*/
|
|
92
|
+
export async function readRoachFile(
|
|
93
|
+
from = process.cwd(),
|
|
94
|
+
): Promise<RoachFile & { path: string }> {
|
|
95
|
+
for (let directory = path.resolve(from); ;) {
|
|
96
|
+
const file = path.join(directory, FILE_NAME);
|
|
97
|
+
const found = await access(file).then(
|
|
98
|
+
() => true,
|
|
99
|
+
() => false,
|
|
100
|
+
);
|
|
101
|
+
if (found) {
|
|
102
|
+
const text = await readFile(file, "utf8");
|
|
103
|
+
let parsed: RoachFile;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(text) as RoachFile;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Roach: ${file} is not valid JSON: ${(error as Error).message}`,
|
|
109
|
+
{ cause: error },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return { ...parsed, path: file };
|
|
113
|
+
}
|
|
114
|
+
const parent = path.dirname(directory);
|
|
115
|
+
if (parent === directory) {
|
|
116
|
+
throw new Error(`Roach: no ${FILE_NAME} in ${from} or its parents`);
|
|
117
|
+
}
|
|
118
|
+
directory = parent;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveValue(rule: string, name: string, value: FileValue): string {
|
|
123
|
+
if (typeof value === "string") return value;
|
|
124
|
+
const pattern = VALUE_PATTERNS[value.builtin];
|
|
125
|
+
if (!pattern) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Roach: rule ${rule} value ${name} names no built-in pattern: ${value.builtin}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return pattern;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The run config of `file` in the environment `env`. Without the write
|
|
135
|
+
* token (`canWrite`), the mode is `replay`, as for forks. `ROACH_MODE`
|
|
136
|
+
* sets another mode.
|
|
137
|
+
*/
|
|
138
|
+
export function runConfigOf(
|
|
139
|
+
file: RoachFile,
|
|
140
|
+
{
|
|
141
|
+
canWrite,
|
|
142
|
+
env = process.env,
|
|
143
|
+
}: { canWrite: boolean; env?: NodeJS.ProcessEnv },
|
|
144
|
+
): RunConfig {
|
|
145
|
+
const mode = readEnv("ROACH_MODE", env) ?? (canWrite ? "auto" : "replay");
|
|
146
|
+
if (!MODES.has(mode)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Roach: ROACH_MODE must be auto, replay, record, or off, got ${mode}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const tenant = file.tenant ?? env.GITHUB_REPOSITORY;
|
|
152
|
+
if (!tenant) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Roach: set tenant in ${FILE_NAME}, or run with GITHUB_REPOSITORY`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const config: RunConfig = {
|
|
158
|
+
tenant,
|
|
159
|
+
mode: mode as RecordingMode,
|
|
160
|
+
allow: file.allow,
|
|
161
|
+
rules: file.rules.map(({ values, ...rule }) =>
|
|
162
|
+
values
|
|
163
|
+
? {
|
|
164
|
+
...rule,
|
|
165
|
+
values: Object.fromEntries(
|
|
166
|
+
Object.entries(values).map(([name, value]) => [
|
|
167
|
+
name,
|
|
168
|
+
resolveValue(rule.name, name, value),
|
|
169
|
+
]),
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
: rule,
|
|
173
|
+
),
|
|
174
|
+
secrets: (file.secretEnv ?? []).flatMap((name) => readEnv(name, env) ?? []),
|
|
175
|
+
};
|
|
176
|
+
const runId = readEnv("GITHUB_RUN_ID", env);
|
|
177
|
+
if (runId) {
|
|
178
|
+
config.name = `${runId}-${readEnv("GITHUB_RUN_ATTEMPT", env) ?? "1"}`;
|
|
179
|
+
}
|
|
180
|
+
const ci = Object.fromEntries(
|
|
181
|
+
Object.entries(CI_VARIABLES).flatMap(([field, name]) => {
|
|
182
|
+
const value = readEnv(name, env);
|
|
183
|
+
return value ? [[field, value]] : [];
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
if (Object.keys(ci).length > 0) config.ci = ci;
|
|
187
|
+
return config;
|
|
188
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text reports of a Roach run, for logs and CI summaries.
|
|
3
|
+
*/
|
|
4
|
+
import { appendFile } from "node:fs/promises";
|
|
5
|
+
import type {
|
|
6
|
+
RecordingChange,
|
|
7
|
+
RecordingMiss,
|
|
8
|
+
RecordingStats,
|
|
9
|
+
RefusalReason,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/** What to fix for each refusal reason. */
|
|
13
|
+
const REFUSAL_HINTS: Record<RefusalReason, string> = {
|
|
14
|
+
notAllowed: "not in allow",
|
|
15
|
+
notPublic: "not a public address",
|
|
16
|
+
replayOnly: "no rule records it, and the run can only replay",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Describe the totals of a run in one line. */
|
|
20
|
+
export function describeRecordingStats(stats: RecordingStats): string {
|
|
21
|
+
const rules = Object.entries(stats.counts)
|
|
22
|
+
.map(
|
|
23
|
+
([rule, { live, missed, replayed }]) =>
|
|
24
|
+
`${rule} ${replayed} replayed, ${live} live${missed ? `, ${missed} missed` : ""}`,
|
|
25
|
+
)
|
|
26
|
+
.join("; ");
|
|
27
|
+
const passthrough =
|
|
28
|
+
Object.entries(stats.passthrough)
|
|
29
|
+
.map(([origin, count]) => `${origin} ${count}`)
|
|
30
|
+
.join(", ") || "none";
|
|
31
|
+
const refused = Object.entries(stats.refused ?? {}).flatMap(
|
|
32
|
+
([reason, origins]) =>
|
|
33
|
+
Object.entries(origins).map(
|
|
34
|
+
([origin, count]) =>
|
|
35
|
+
`${origin} ${count} (${REFUSAL_HINTS[reason as RefusalReason]})`,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
return `${rules}. ${stats.written} recordings new or changed, ${stats.discarded} dropped from failed sessions. Not recorded: ${passthrough}.${refused.length ? ` Refused: ${refused.join(", ")}.` : ""}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Describe the first change of a miss, in an indented line. The report
|
|
43
|
+
* goes to public CI logs, so it never shows the text of a request. Sentry
|
|
44
|
+
* has it.
|
|
45
|
+
*/
|
|
46
|
+
const describeChange = ({ file, at }: RecordingChange) =>
|
|
47
|
+
`\n Changed at character ${at} of the request of ${file}. See miss.recorded and miss.request on the roach.request span in Sentry.`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Describe the first request without a recording of each test. The first
|
|
51
|
+
* miss of a test is the one to fix. Each later miss of the test usually
|
|
52
|
+
* follows from it, because the live response differs from the recording.
|
|
53
|
+
* When the proxy has the text of the closest recorded request, the
|
|
54
|
+
* description also shows where it first changed, in an indented line.
|
|
55
|
+
*/
|
|
56
|
+
export function describeRecordingMisses(misses: RecordingMiss[]): string[] {
|
|
57
|
+
const seen = new Set<string | undefined>();
|
|
58
|
+
return misses
|
|
59
|
+
.filter((miss) => {
|
|
60
|
+
const first = !seen.has(miss.session);
|
|
61
|
+
seen.add(miss.session);
|
|
62
|
+
return first;
|
|
63
|
+
})
|
|
64
|
+
.map(
|
|
65
|
+
(miss) =>
|
|
66
|
+
`${miss.session ?? "outside a test"}: ${miss.file}${miss.change ? describeChange(miss.change) : ""}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Write the report of a run to stdout, and in GitHub Actions also to the
|
|
72
|
+
* job summary. `title` names the run, such as `Roach (auto)`.
|
|
73
|
+
*/
|
|
74
|
+
export async function writeRunReport(
|
|
75
|
+
title: string,
|
|
76
|
+
stats: RecordingStats,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const report = [
|
|
79
|
+
`${title}: ${describeRecordingStats(stats)}`,
|
|
80
|
+
...describeRecordingMisses(stats.misses).map((miss) => `- ${miss}`),
|
|
81
|
+
].join("\n");
|
|
82
|
+
process.stdout.write(`${report}\n`);
|
|
83
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
84
|
+
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${report}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|