@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/src/types.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The public contract of Roach: rules, modes, runs, sessions, and the
3
+ * results of a run. The client, the service, and the vitest integration
4
+ * share it. See `README.md`.
5
+ */
6
+ import type { ValuePatterns } from "./values.ts";
7
+
8
+ /** The path prefix of the control API. */
9
+ export const CONTROL_PATH = "/__roach";
10
+
11
+ /**
12
+ * - `auto`: replay a request that has a recording. Send other requests live
13
+ * and record them.
14
+ * - `replay`: replay a request that has a recording. Fail other requests
15
+ * with HTTP 412. Nothing goes live, and nothing is recorded.
16
+ * - `record`: send every request live and record it again.
17
+ * - `off`: record and replay nothing.
18
+ */
19
+ export type RecordingMode = "auto" | "off" | "record" | "replay";
20
+
21
+ /** One kind of traffic that the proxy records. */
22
+ export interface RecordingRule {
23
+ /**
24
+ * The name of the rule, such as `model`. Its keys start with
25
+ * `<name>/`. Use letters, digits, `_`, `.`, and `-`.
26
+ */
27
+ name: string;
28
+ /** The requests of the rule. Each field that is set must match. */
29
+ match: {
30
+ /** The method, such as `POST`. */
31
+ method?: string;
32
+ /**
33
+ * The start of the URL, such as `https://ai-gateway.vercel.sh/`. A list
34
+ * matches a URL that starts with one of its prefixes, so one rule can
35
+ * record the same API on many hosts. The key does not use `match`, so a
36
+ * string and a list with that string give the same keys.
37
+ */
38
+ url?: string | string[];
39
+ /** Header values. Names are lowercase. */
40
+ headers?: Record<string, string>;
41
+ };
42
+ /**
43
+ * Request headers that are in the key, with the method, the URL, and the
44
+ * body. Names are lowercase.
45
+ */
46
+ keyHeaders?: string[];
47
+ /**
48
+ * Values in the request body that change from run to run, such as ids and
49
+ * times. See `values.ts`.
50
+ */
51
+ values?: ValuePatterns;
52
+ }
53
+
54
+ /**
55
+ * Where a request without a recording first differs from the request of
56
+ * the closest recording, in the texts that the keys hash. It has no text:
57
+ * the texts go only to the `roach.request` span in Sentry, as
58
+ * `miss.recorded` and `miss.request`.
59
+ */
60
+ export interface RecordingChange {
61
+ /** The key of the recording that the request is compared with. */
62
+ file: string;
63
+ /** The offset of the first different character. */
64
+ at: number;
65
+ }
66
+
67
+ /** A request that a rule matched, but that had no recording. */
68
+ export interface RecordingMiss {
69
+ rule: string;
70
+ /** The session of the request. */
71
+ session?: string | undefined;
72
+ /** The key of the recording that the request needed. */
73
+ file: string;
74
+ /**
75
+ * The first change from the request of the closest recording. Only the
76
+ * first miss of each session in a run with the write token has it, and
77
+ * only when the proxy has the text of that request.
78
+ */
79
+ change?: RecordingChange;
80
+ }
81
+
82
+ /** The totals of a run. */
83
+ export interface RecordingStats {
84
+ /**
85
+ * Requests by rule name. `missed` counts the requests that `replay` mode
86
+ * failed, because they had no recording.
87
+ */
88
+ counts: Record<string, { live: number; missed: number; replayed: number }>;
89
+ /** The requests without a recording, in order. The first 500 are kept. */
90
+ misses: RecordingMiss[];
91
+ /** Recordings that the proxy wrote and that were new or changed. */
92
+ written: number;
93
+ /** New recordings that the proxy dropped, because their session failed. */
94
+ discarded: number;
95
+ /**
96
+ * Requests that matched no rule, by origin. They went live and were not
97
+ * recorded. Use this to find traffic that a rule misses.
98
+ */
99
+ passthrough: Record<string, number>;
100
+ /**
101
+ * Requests that the proxy refused with HTTP 403 and sent nowhere, by
102
+ * reason and origin. An older service does not send it.
103
+ */
104
+ refused?: Partial<Record<RefusalReason, Record<string, number>>>;
105
+ }
106
+
107
+ /**
108
+ * Why the proxy refused a request:
109
+ * - `notAllowed`: its origin is not in `allow`.
110
+ * - `notPublic`: its origin is in `allow`, but its address is not public.
111
+ * - `replayOnly`: the run can only replay, and no rule records the request.
112
+ */
113
+ export type RefusalReason = "notAllowed" | "notPublic" | "replayOnly";
114
+
115
+ /** The configuration of one run. The client sends it. */
116
+ export interface RunConfig {
117
+ /**
118
+ * The tenant of the run, as `owner/repo`, such as
119
+ * `process.env.GITHUB_REPOSITORY`.
120
+ */
121
+ tenant: string;
122
+ mode: RecordingMode;
123
+ rules: RecordingRule[];
124
+ /**
125
+ * The only origins that the run can reach, such as
126
+ * `https://ai-gateway.vercel.sh`. The service sends requests only to
127
+ * their public addresses.
128
+ */
129
+ allow: string[];
130
+ /**
131
+ * The name of the run in Sentry, such as
132
+ * `${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`. Default: the run id.
133
+ */
134
+ name?: string;
135
+ /**
136
+ * Credentials that this run must never write, such as the API keys of
137
+ * its CI job. The service redacts them with the `secrets` of the service
138
+ * config, keeps them only in memory, and forgets them when the run ends.
139
+ */
140
+ secrets?: string[];
141
+ /**
142
+ * The CI job of the run, for Sentry. `runConfigOf()` takes it from
143
+ * `GITHUB_RUN_ID`, `GITHUB_JOB`, `GITHUB_REPOSITORY`, and `GITHUB_REF`.
144
+ * An older service ignores it.
145
+ */
146
+ ci?: RunCi;
147
+ }
148
+
149
+ /** The CI job of a run. Each field is a string of at most 256 characters. */
150
+ export interface RunCi {
151
+ runId?: string;
152
+ job?: string;
153
+ repo?: string;
154
+ ref?: string;
155
+ }
156
+
157
+ /** A run that the service created. */
158
+ export interface RemoteRun {
159
+ /** The id of the run. */
160
+ id: string;
161
+ /**
162
+ * The proxy URL, with the run id and token as its user and password. A
163
+ * request through it belongs to the open session when only one is open.
164
+ */
165
+ url: string;
166
+ /** The run token. Session and stats calls use it. */
167
+ token: string;
168
+ /** The base URL of the control API of the run. */
169
+ controlUrl: string;
170
+ /** The PEM certificate of the authority that signs intercepted hosts. */
171
+ caCert: string;
172
+ }
173
+
174
+ /** A session that the service opened. */
175
+ export interface OpenedSession {
176
+ /**
177
+ * The proxy URL of the session. Each request through it belongs to the
178
+ * session, also when other sessions are open.
179
+ */
180
+ url: string;
181
+ }
package/src/values.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Changing values in recorded requests, such as ids and clock times.
3
+ *
4
+ * A rule names the values that change from run to run. The proxy handles
5
+ * them in two directions:
6
+ *
7
+ * - The key sees each value as `<<name>>`, so two runs with other ids or
8
+ * times send the same request.
9
+ * - A response often repeats a value of its request, for example when the
10
+ * model archives the memory with id `<<uuid>>`. When the proxy records a
11
+ * response, it writes each value of the request as `<<name:n>>`, the n-th
12
+ * value of that name in the request. On replay, it writes back the n-th
13
+ * value of the current request. So a replayed response uses the ids of
14
+ * this run, not the ids of the recording run.
15
+ *
16
+ * A value that the response makes itself, such as a date that the model
17
+ * calculates, stays as it was recorded.
18
+ */
19
+ import vm from "node:vm";
20
+
21
+ /**
22
+ * Lookbehind: the value does not continue a word or number. The proxy
23
+ * matches values in JSON text, where `\nab12` is a newline and then
24
+ * `ab12`. So a letter after a backslash, such as the `n` of `\n`, does not
25
+ * count as a word. Use it in a pattern that starts with a letter or digit.
26
+ */
27
+ export const NOT_AFTER_WORD = String.raw`(?<!(?:^|[^\\])[0-9A-Za-z])`;
28
+
29
+ /**
30
+ * Patterns for common changing values. Use them in `values` of a rule. Each
31
+ * pattern is a regular expression source.
32
+ */
33
+ export const VALUE_PATTERNS = {
34
+ /** A UUID, also inside a name such as `junior-ws-<uuid>`. */
35
+ uuid: String.raw`${NOT_AFTER_WORD}[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?![0-9A-Za-z])`,
36
+ /**
37
+ * An ISO time, with `T` or a space, with or without seconds, and in UTC,
38
+ * with an offset, or in local time.
39
+ */
40
+ isoTime: String.raw`(?<![\d-])\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2}| ?UTC)?(?![\d:])`,
41
+ /** An ISO date, such as `2026-10-07`. */
42
+ date: String.raw`(?<![\d-])\d{4}-\d{2}-\d{2}(?![\d-])`,
43
+ /**
44
+ * Unix milliseconds from 2023 to 2033. Unix seconds, such as Slack
45
+ * timestamps, do not match.
46
+ */
47
+ epochMs: String.raw`(?<!\d)1[789]\d{11}(?!\d)`,
48
+ /**
49
+ * A SHA-256 hex digest. Ids that the product hashes from other changing
50
+ * values, such as attachment ids, change with them.
51
+ */
52
+ sha256: String.raw`${NOT_AFTER_WORD}[0-9a-f]{64}(?![0-9A-Za-z])`,
53
+ /**
54
+ * A git commit id. Its hash covers the commit time, so it changes on each
55
+ * run. A full id has 40 hex digits. A short id only counts in the output
56
+ * of `git commit`, such as `[main (root-commit) a7a9f1d]`, and in the range
57
+ * of `git push`, such as `a5fd79f..a73e959`, because seven hex digits can
58
+ * also be a word.
59
+ */
60
+ gitCommit: String.raw`${NOT_AFTER_WORD}[0-9a-f]{40}(?![0-9A-Za-z])|(?<=\[[^\]\s]+ (?:\(root-commit\) )?)[0-9a-f]{7,12}(?=\])|${NOT_AFTER_WORD}[0-9a-f]{7,12}(?=\.\.\.?[0-9a-f]{7,12}(?![0-9A-Za-z]))|(?<=${NOT_AFTER_WORD}[0-9a-f]{7,12}\.\.\.?)[0-9a-f]{7,12}(?![0-9A-Za-z])`,
61
+ } as const;
62
+
63
+ /** Changing values by name. Each value is a regular expression source. */
64
+ export type ValuePatterns = Record<string, string>;
65
+
66
+ /** The values of one request, by name, in the order of the request. */
67
+ export type RequestValues = Map<string, string[]>;
68
+
69
+ interface CompiledValues {
70
+ names: string[];
71
+ /** One expression for all patterns, with one group per name. */
72
+ pattern: RegExp;
73
+ /** The index of the group of each name. */
74
+ groups: number[];
75
+ }
76
+
77
+ const compiled = new WeakMap<ValuePatterns, CompiledValues>();
78
+
79
+ /** The number of capturing groups in a pattern. */
80
+ const groupCount = (source: string) =>
81
+ new RegExp(`${source}|`).exec("")!.length - 1;
82
+
83
+ function compile(patterns: ValuePatterns): CompiledValues {
84
+ const cached = compiled.get(patterns);
85
+ if (cached) return cached;
86
+ const names = Object.keys(patterns);
87
+ const groups: number[] = [];
88
+ let next = 1;
89
+ for (const name of names) {
90
+ groups.push(next);
91
+ next += 1 + groupCount(patterns[name]!);
92
+ }
93
+ const result = {
94
+ names,
95
+ // Earlier names win when two patterns match at the same place.
96
+ pattern: new RegExp(
97
+ names.map((name) => `(${patterns[name]})`).join("|"),
98
+ "g",
99
+ ),
100
+ groups,
101
+ };
102
+ compiled.set(patterns, result);
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Run `fn` with a time limit. A slow pattern cannot stop itself, but the
108
+ * `timeout` of `vm` stops any code that runs too long, also code outside
109
+ * the context that the script calls. A timeout throws an error with HTTP
110
+ * status 400 and `timedOut`.
111
+ */
112
+ export function withTimeLimit<T>(ms: number, fn: () => T): T {
113
+ try {
114
+ return vm.runInContext("fn()", vm.createContext({ fn }), {
115
+ timeout: ms,
116
+ }) as T;
117
+ } catch (error) {
118
+ if ((error as { code?: string }).code === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
119
+ throw Object.assign(
120
+ new Error(`the value patterns took over ${ms} ms. Make them simpler.`),
121
+ { status: 400, timedOut: true },
122
+ );
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Check that the patterns of each rule compile. Returns the error, or
130
+ * `undefined`. A pattern can be slow to compile, so all of them together
131
+ * have 1 s.
132
+ */
133
+ export function invalidValuePatterns(
134
+ list: ValuePatterns[],
135
+ ): string | undefined {
136
+ try {
137
+ withTimeLimit(1000, () => list.forEach((patterns) => compile(patterns)));
138
+ return undefined;
139
+ } catch (error) {
140
+ return (error as Error).message;
141
+ }
142
+ }
143
+
144
+ /** Values that an earlier request of the same session had, by value. */
145
+ export type KnownValues = ReadonlyMap<string, string>;
146
+
147
+ /**
148
+ * Replace each changing value in `text` with `<<name>>`. Returns the text
149
+ * and the values in their order. With no patterns, nothing changes.
150
+ *
151
+ * `known` maps values of earlier requests in the session to their names. A
152
+ * known value also counts where no pattern finds it. For example, the
153
+ * model repeats the short commit id of a `git push` range in its reply,
154
+ * and a later request sends that reply back. A pattern wins over a known
155
+ * value at the same place.
156
+ *
157
+ * Past `maxValues` values, it stops and throws an error with HTTP status
158
+ * 400. It counts each value when it finds it, so a text with millions of
159
+ * values cannot fill the memory before the error.
160
+ */
161
+ export function extractValues(
162
+ text: string,
163
+ patterns: ValuePatterns | undefined,
164
+ known?: KnownValues,
165
+ maxValues = Infinity,
166
+ ): { text: string; values: RequestValues } {
167
+ const values: RequestValues = new Map();
168
+ if (!patterns || Object.keys(patterns).length === 0) {
169
+ return { text, values };
170
+ }
171
+ const { names, pattern, groups } = compile(patterns);
172
+ const hasKnown = known !== undefined && known.size > 0;
173
+ // Known values are the last group, after the groups of the patterns.
174
+ const knownGroup = groupCount(pattern.source) + 1;
175
+ const search = hasKnown
176
+ ? new RegExp(`${pattern.source}|(${literals([...known.keys()])})`, "g")
177
+ : pattern;
178
+ // An `exec()` loop, not `replace()`: `replace()` finds all matches
179
+ // before it calls its function, so it cannot stop at `maxValues`. The
180
+ // loop finds the same matches, also the empty ones.
181
+ const parts: string[] = [];
182
+ let count = 0;
183
+ let end = 0;
184
+ search.lastIndex = 0;
185
+ try {
186
+ for (let match = search.exec(text); match; match = search.exec(text)) {
187
+ count += 1;
188
+ if (count > maxValues) {
189
+ throw Object.assign(
190
+ new Error("the request has too many changing values"),
191
+ { status: 400 },
192
+ );
193
+ }
194
+ const value = match[0];
195
+ const name =
196
+ hasKnown && match[knownGroup] !== undefined
197
+ ? known.get(value)!
198
+ : names[groups.findIndex((group) => match[group] !== undefined)]!;
199
+ const list = values.get(name) ?? [];
200
+ list.push(value);
201
+ values.set(name, list);
202
+ parts.push(text.slice(end, match.index), `<<${name}>>`);
203
+ end = match.index + value.length;
204
+ if (value.length === 0) search.lastIndex += 1;
205
+ }
206
+ } finally {
207
+ // `pattern` is shared, so it must not keep a position.
208
+ search.lastIndex = 0;
209
+ }
210
+ parts.push(text.slice(end));
211
+ return { text: parts.join(""), values };
212
+ }
213
+
214
+ /**
215
+ * Whether `value` can be in `known` or in a template (`templateValues()`).
216
+ * V8 cannot run a pattern with a literal of over 32,767 characters on
217
+ * any text, also on a text of 5 characters. (A literal with a character
218
+ * over U+00FF fails only on a text with such a character, and only such a
219
+ * text can hold it.) Such a value in `known` failed each later request of
220
+ * its session, so those requests had no key, and to leave it out changes
221
+ * no key.
222
+ */
223
+ export const canBeKnown = (value: string): boolean => value.length <= 32_767;
224
+
225
+ const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
226
+
227
+ /**
228
+ * A pattern source for any of `values`, where it is not part of a longer
229
+ * word or number. Longer values come first, so a date never replaces part
230
+ * of a time.
231
+ */
232
+ const literals = (values: string[]) =>
233
+ `${NOT_AFTER_WORD}(?:${values
234
+ .toSorted((a, b) => b.length - a.length)
235
+ .map(escape)
236
+ .join("|")})(?![0-9A-Za-z])`;
237
+
238
+ /**
239
+ * Write each value of the request that a recorded response repeats as
240
+ * `<<name:n>>`. A value is replaced only where it is not part of a longer
241
+ * word or number. A value that `canBeKnown()` refuses stays as it is.
242
+ */
243
+ export function templateValues(text: string, values: RequestValues): string {
244
+ const placeholders = new Map<string, string>();
245
+ for (const [name, list] of values) {
246
+ list.forEach((value, index) => {
247
+ if (!placeholders.has(value) && canBeKnown(value)) {
248
+ placeholders.set(value, `<<${name}:${index + 1}>>`);
249
+ }
250
+ });
251
+ }
252
+ if (placeholders.size === 0) return text;
253
+ const pattern = new RegExp(literals([...placeholders.keys()]), "g");
254
+ return text.replace(pattern, (value) => placeholders.get(value)!);
255
+ }
256
+
257
+ /**
258
+ * Write back the values of the current request into a recorded response.
259
+ * This undoes `templateValues()` with the values of this run.
260
+ */
261
+ export function fillValues(text: string, values: RequestValues): string {
262
+ return text.replace(
263
+ /<<([^<>:\s]+):(\d+)>>/g,
264
+ (placeholder, name: string, index: string) =>
265
+ values.get(name)?.[Number(index) - 1] ?? placeholder,
266
+ );
267
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The vitest setup file of Roach (`@sentry/roach/vitest/setup`). See "Use
3
+ * with vitest" in `README.md`.
4
+ *
5
+ * It opens one session for each test of the run that `vitest.ts` started.
6
+ * Requests of the test go through the proxy URL of its session, so tests
7
+ * in other workers record at the same time. Its child processes get that
8
+ * URL too. A passed test writes its new recordings, and a failed test
9
+ * drops them. A test with a request that `replay` mode failed fails.
10
+ *
11
+ * The tests of one file share one process, so they must run one at a
12
+ * time. Do not use `test.concurrent` with Roach.
13
+ */
14
+ import http from "node:http";
15
+ import {
16
+ beforeEach,
17
+ inject,
18
+ onTestFinished,
19
+ type ProvidedContext,
20
+ } from "vitest";
21
+ import { connectRoach } from "./client.ts";
22
+ import { swapEnv } from "./config.ts";
23
+
24
+ const provided = inject("roach");
25
+ if (provided === undefined) {
26
+ throw new Error(
27
+ "Roach: add @sentry/roach/vitest to globalSetup to use the setup file",
28
+ );
29
+ }
30
+ // `null` is `off` mode: no run, so requests go direct.
31
+ if (provided) useRun(provided);
32
+
33
+ /** Open a session for each test of the run. */
34
+ function useRun(run: NonNullable<ProvidedContext["roach"]>): void {
35
+ const control = connectRoach(run);
36
+ beforeEach(async ({ task }) => {
37
+ // The full name starts with the file, so tests of two files never share
38
+ // a session.
39
+ const session = await control.startSession(task.fullName);
40
+ const restoreEnv = swapEnv({
41
+ HTTP_PROXY: session.url,
42
+ HTTPS_PROXY: session.url,
43
+ });
44
+ const restoreProxy = http.setGlobalProxyFromEnv({
45
+ HTTP_PROXY: session.url,
46
+ HTTPS_PROXY: session.url,
47
+ NO_PROXY: run.noProxy,
48
+ });
49
+ onTestFinished(async ({ task: finished }) => {
50
+ restoreProxy();
51
+ restoreEnv();
52
+ const { missed } = await session.end(finished.result?.state === "pass");
53
+ // `replay` mode failed these requests, so nothing went live. The test
54
+ // must fail, also when it recovered from the error.
55
+ if (missed > 0) {
56
+ throw new Error(
57
+ `Roach: ${missed} request(s) had no recording in replay mode. Run with ROACH_TOKEN to record them.`,
58
+ );
59
+ }
60
+ });
61
+ });
62
+ }
package/src/vitest.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The vitest global setup of Roach (`@sentry/roach/vitest`). See "Use with
3
+ * vitest" in `README.md`.
4
+ *
5
+ * It starts one run with the config of `roach.json` (`config.ts`), and
6
+ * gives the proxy variables of the run to the test workers, which start
7
+ * after it. `vitest-setup.ts` opens one session for each test, so the
8
+ * tests of many workers record at the same time. At the end it closes the
9
+ * run and reports it.
10
+ *
11
+ * - `ROACH_TOKEN` is the write token. Only this file reads it, and the
12
+ * workers do not get it. Without it, the run can only replay.
13
+ * - `ROACH_URL` is the service. Default: the Roach service of Sentry.
14
+ * - `ROACH_MODE` sets the mode. See `runConfigOf()`. With `off`, it starts
15
+ * no run and does not use the service, so requests go direct.
16
+ */
17
+ import http from "node:http";
18
+ import tls from "node:tls";
19
+ import type { TestProject } from "vitest/node";
20
+ import { startRemoteRun } from "./client.ts";
21
+ import {
22
+ DEFAULT_NO_PROXY,
23
+ DEFAULT_SERVICE_URL,
24
+ readEnv,
25
+ readRoachFile,
26
+ runConfigOf,
27
+ swapEnv,
28
+ } from "./config.ts";
29
+ import { writeRunReport } from "./report.ts";
30
+ import type { RemoteRun } from "./types.ts";
31
+
32
+ declare module "vitest" {
33
+ export interface ProvidedContext {
34
+ /** The run of `@sentry/roach/vitest`, for the test workers. */
35
+ roach:
36
+ | (Pick<RemoteRun, "controlUrl" | "token"> & { noProxy: string })
37
+ | null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Start a Roach run for this vitest invocation. Returns the teardown,
43
+ * which ends the run and reports it. Call it from a global setup of your
44
+ * own when you have one, or use this module as a global setup.
45
+ */
46
+ export async function startRoach(
47
+ project: Pick<TestProject, "provide">,
48
+ ): Promise<() => Promise<void>> {
49
+ const token = readEnv("ROACH_TOKEN");
50
+ delete process.env.ROACH_TOKEN;
51
+ const file = await readRoachFile();
52
+ const config = runConfigOf(file, { canWrite: token !== undefined });
53
+ if (config.mode === "off") {
54
+ project.provide("roach", null);
55
+ return async () => {};
56
+ }
57
+ // Only a run needs it, so `off` mode works on older Node too.
58
+ if (typeof http.setGlobalProxyFromEnv !== "function") {
59
+ throw new Error("Roach: vitest needs Node 24.14 or later");
60
+ }
61
+ const noProxy = file.noProxy ?? DEFAULT_NO_PROXY;
62
+ const run = await startRemoteRun(
63
+ { url: readEnv("ROACH_URL") ?? DEFAULT_SERVICE_URL, token },
64
+ config,
65
+ { noProxy },
66
+ );
67
+ // The proxy URL has the run token. Keep it out of the CI log.
68
+ if (process.env.GITHUB_ACTIONS) {
69
+ process.stdout.write(`::add-mask::${run.token}\n`);
70
+ }
71
+ // Test workers and their child processes start later and inherit these.
72
+ const restoreEnv = swapEnv(run.env);
73
+ // This process started before them, so it reads neither the proxy nor
74
+ // the CA file. Set both here.
75
+ const previousCa = tls.getCACertificates("default");
76
+ tls.setDefaultCACertificates([...previousCa, run.caCert]);
77
+ const restoreProxy = http.setGlobalProxyFromEnv(run.env);
78
+ project.provide("roach", {
79
+ controlUrl: run.controlUrl,
80
+ token: run.token,
81
+ noProxy,
82
+ });
83
+
84
+ return async () => {
85
+ try {
86
+ const stats = await run.close();
87
+ await writeRunReport(`Roach (${config.mode})`, stats);
88
+ // A miss in a test fails that test. A miss outside a test, such as in
89
+ // a global setup, must fail the invocation too.
90
+ const missed = Object.values(stats.counts).some(
91
+ (count) => count.missed > 0,
92
+ );
93
+ if (missed) process.exitCode = 1;
94
+ } finally {
95
+ restoreProxy();
96
+ tls.setDefaultCACertificates(previousCa);
97
+ restoreEnv();
98
+ }
99
+ };
100
+ }
101
+
102
+ /** The vitest global setup. */
103
+ export default function setup(project: TestProject) {
104
+ return startRoach(project);
105
+ }