@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/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@sentry/roach",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }