@sentry/roach 0.2.0 → 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,11 +1,12 @@
1
1
  {
2
2
  "name": "@sentry/roach",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A recording HTTPS proxy for tests, as a shared service.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/getsentry/roach.git"
8
+ "url": "git+https://github.com/getsentry/roach.git",
9
+ "directory": "packages/roach"
9
10
  },
10
11
  "files": [
11
12
  "src"
@@ -14,7 +15,6 @@
14
15
  "exports": {
15
16
  "./client": "./src/client.ts",
16
17
  "./report": "./src/report.ts",
17
- "./service": "./src/service.ts",
18
18
  "./types": "./src/types.ts",
19
19
  "./values": "./src/values.ts",
20
20
  "./vitest": "./src/vitest.ts",
@@ -23,22 +23,10 @@
23
23
  "publishConfig": {
24
24
  "access": "public"
25
25
  },
26
- "dependencies": {
27
- "@sentry/node": "11.6.0"
28
- },
29
26
  "devDependencies": {
30
- "@sentry/dotagents": "^3.3.0",
31
27
  "@types/node": "^24.19.1",
32
- "@vitest/coverage-v8": "^5.0.3",
33
- "knip": "^6.40.0",
34
- "lint-staged": "^17.6.0",
35
- "oxfmt": "^0.72.0",
36
- "oxlint": "^1.87.0",
37
- "oxlint-tsgolint": "^7.0.2003",
38
- "simple-git-hooks": "^2.14.0",
39
28
  "typescript": "^7.0.2",
40
- "undici": "^8.11.2",
41
- "vitest": "^5.0.3"
29
+ "vitest": "^5.1.0"
42
30
  },
43
31
  "peerDependencies": {
44
32
  "vitest": ">=3"
@@ -48,30 +36,11 @@
48
36
  "optional": true
49
37
  }
50
38
  },
51
- "simple-git-hooks": {
52
- "pre-commit": "pnpm exec lint-staged",
53
- "pre-push": "pnpm typecheck"
54
- },
55
- "lint-staged": {
56
- "*.{ts,mts,js,mjs}": [
57
- "oxlint --fix --deny-warnings",
58
- "oxfmt --no-error-on-unmatched-pattern"
59
- ],
60
- "*.{json,jsonc,md,yml,yaml}": "oxfmt --no-error-on-unmatched-pattern"
61
- },
62
39
  "engines": {
63
40
  "node": ">=24"
64
41
  },
65
42
  "scripts": {
66
- "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm knip && pnpm test",
67
- "format": "oxfmt",
68
- "format:check": "oxfmt --check",
69
- "knip": "knip",
70
- "lint": "oxlint --deny-warnings",
71
- "lint:fix": "oxlint --fix",
72
- "skills:install": "dotagents --project install",
73
43
  "test": "vitest run",
74
- "test:coverage": "vitest run --coverage",
75
44
  "typecheck": "tsc -p ."
76
45
  }
77
46
  }
package/src/action.ts CHANGED
@@ -14,9 +14,11 @@ import { writeRunReport } from "./report.ts";
14
14
 
15
15
  /**
16
16
  * Variables that the command does not get: the inputs, which have the token,
17
- * and the proxy variables of the job.
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.
18
19
  */
19
- const HIDDEN_VARIABLE = /^(INPUT_.*|https?_proxy|no_proxy|all_proxy)$/i;
20
+ const HIDDEN_VARIABLE =
21
+ /^(INPUT_.*|ACTIONS_.*|https?_proxy|no_proxy|all_proxy)$/i;
20
22
 
21
23
  /** Run the command in bash, and return its exit code. */
22
24
  function runCommand(command: string, env: NodeJS.ProcessEnv): Promise<number> {
@@ -49,7 +51,7 @@ function runCommand(command: string, env: NodeJS.ProcessEnv): Promise<number> {
49
51
  }
50
52
 
51
53
  const command = process.env.INPUT_RUN;
52
- if (!command) throw new Error("The run input is required");
54
+ if (!command) throw new Error("Roach: the run input is required");
53
55
  // The runner gives an empty string for an input that the step does not set.
54
56
  const token = readEnv("INPUT_TOKEN");
55
57
  const file = await readRoachFile();
package/src/client.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * The client of Roach. See `README.md`.
3
3
  *
4
- * `startRemoteRun()` starts a run on a Roach service (`service.ts`). It
5
- * returns `env`, the variables that send the traffic of a process through
6
- * the run. `connectRoach()` controls a run from another process, such as a
7
- * test worker.
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
8
  */
9
9
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
10
10
  import http from "node:http";
11
11
  import https from "node:https";
12
12
  import { tmpdir } from "node:os";
13
13
  import path from "node:path";
14
+ import { DEFAULT_NO_PROXY } from "./config.ts";
14
15
  import {
15
16
  CONTROL_PATH,
16
17
  type OpenedSession,
@@ -46,6 +47,16 @@ export interface RoachControl {
46
47
  stats(): Promise<RecordingStats>;
47
48
  }
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
+
49
60
  /**
50
61
  * Call the control API at `base`, and return the JSON of the response.
51
62
  * `token` is the bearer token.
@@ -59,9 +70,9 @@ function callControl<T>(
59
70
  ): Promise<T> {
60
71
  const payload = body === undefined ? undefined : JSON.stringify(body);
61
72
  const url = route ? `${base}/${route}` : base;
62
- const client = url.startsWith("https:") ? https : http;
73
+ const send = url.startsWith("https:") ? httpsRequest : httpRequest;
63
74
  return new Promise((resolve, reject) => {
64
- const request = client.request(
75
+ const request = send(
65
76
  url,
66
77
  {
67
78
  // Its own agent, so that a proxy agent of the process is not used.
@@ -81,7 +92,7 @@ function callControl<T>(
81
92
  if (status >= 400) {
82
93
  reject(
83
94
  new Error(
84
- `Roach ${method} ${route} failed with HTTP ${status}: ${text}`,
95
+ `Roach: ${method} ${route} failed with HTTP ${status}: ${text}`,
85
96
  ),
86
97
  );
87
98
  } else {
@@ -96,6 +107,12 @@ function callControl<T>(
96
107
  });
97
108
  }
98
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
+
99
116
  /** Control a run, for example from a test worker. */
100
117
  export function connectRoach({
101
118
  controlUrl,
@@ -103,13 +120,19 @@ export function connectRoach({
103
120
  }: Pick<RemoteRun, "controlUrl" | "token">): RoachControl {
104
121
  return {
105
122
  async startSession(name) {
106
- const { url } = await callControl<OpenedSession>(
123
+ const opened = await callControl<OpenedSession | undefined>(
107
124
  controlUrl,
108
125
  token,
109
126
  "POST",
110
127
  "session",
111
128
  { name },
112
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
+ }
113
136
  return {
114
137
  url,
115
138
  end: (passed) =>
@@ -123,7 +146,7 @@ export function connectRoach({
123
146
  };
124
147
  }
125
148
 
126
- /** A run on a Roach service (`service.ts`). */
149
+ /** A run on a Roach service (`@sentry/roach-service`). */
127
150
  export interface RemoteRoach extends RemoteRun, RoachControl {
128
151
  /**
129
152
  * The variables that send the HTTP traffic of a process through the run:
@@ -146,7 +169,7 @@ export interface RemoteRoach extends RemoteRun, RoachControl {
146
169
  export async function startRemoteRun(
147
170
  service: { url: string; token?: string | undefined },
148
171
  config: RunConfig,
149
- { noProxy = "localhost,127.0.0.1,::1" }: { noProxy?: string } = {},
172
+ { noProxy = DEFAULT_NO_PROXY }: { noProxy?: string } = {},
150
173
  ): Promise<RemoteRoach> {
151
174
  const run = await callControl<RemoteRun>(
152
175
  `${service.url.replace(/\/$/, "")}${CONTROL_PATH}`,
package/src/config.ts CHANGED
@@ -6,12 +6,20 @@
6
6
  */
7
7
  import { access, readFile } from "node:fs/promises";
8
8
  import path from "node:path";
9
- import type { RecordingMode, RecordingRule, RunConfig } from "./types.ts";
9
+ import type {
10
+ RecordingMode,
11
+ RecordingRule,
12
+ RunCi,
13
+ RunConfig,
14
+ } from "./types.ts";
10
15
  import { VALUE_PATTERNS } from "./values.ts";
11
16
 
12
17
  /** The Roach service of Sentry. See "Deploy" in `README.md`. */
13
18
  export const DEFAULT_SERVICE_URL = "https://roach-proxy.getsentry.net";
14
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
+
15
23
  /** The name of the config file. */
16
24
  const FILE_NAME = "roach.json";
17
25
 
@@ -43,6 +51,14 @@ export interface RoachFile {
43
51
 
44
52
  const MODES = new Set<string>(["auto", "off", "record", "replay"]);
45
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
+
46
62
  /** A variable of `env`, or `undefined` when it is not set or is blank. */
47
63
  export function readEnv(
48
64
  name: string,
@@ -52,6 +68,23 @@ export function readEnv(
52
68
  return value === "" ? undefined : value;
53
69
  }
54
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
+
55
88
  /**
56
89
  * Find `roach.json` in `from` or the closest parent directory, and read
57
90
  * it. Throws when no directory has one.
@@ -66,10 +99,17 @@ export async function readRoachFile(
66
99
  () => false,
67
100
  );
68
101
  if (found) {
69
- return {
70
- ...(JSON.parse(await readFile(file, "utf8")) as RoachFile),
71
- path: file,
72
- };
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 };
73
113
  }
74
114
  const parent = path.dirname(directory);
75
115
  if (parent === directory) {
@@ -137,5 +177,12 @@ export function runConfigOf(
137
177
  if (runId) {
138
178
  config.name = `${runId}-${readEnv("GITHUB_RUN_ATTEMPT", env) ?? "1"}`;
139
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;
140
187
  return config;
141
188
  }
package/src/report.ts CHANGED
@@ -2,7 +2,19 @@
2
2
  * Text reports of a Roach run, for logs and CI summaries.
3
3
  */
4
4
  import { appendFile } from "node:fs/promises";
5
- import type { RecordingMiss, RecordingStats } from "./types.ts";
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
+ };
6
18
 
7
19
  /** Describe the totals of a run in one line. */
8
20
  export function describeRecordingStats(stats: RecordingStats): string {
@@ -16,14 +28,30 @@ export function describeRecordingStats(stats: RecordingStats): string {
16
28
  Object.entries(stats.passthrough)
17
29
  .map(([origin, count]) => `${origin} ${count}`)
18
30
  .join(", ") || "none";
19
- return `${rules}. ${stats.written} recordings new or changed, ${stats.discarded} dropped from failed sessions. Not recorded: ${passthrough}.`;
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(", ")}.` : ""}`;
20
39
  }
21
40
 
22
41
  /**
23
- * Describe the first request without a recording of each test, one line
24
- * each. The first miss of a test is the one to fix. Each later miss of the
25
- * test usually follows from it, because the live response differs from
26
- * the recording.
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.
27
55
  */
28
56
  export function describeRecordingMisses(misses: RecordingMiss[]): string[] {
29
57
  const seen = new Set<string | undefined>();
@@ -33,7 +61,10 @@ export function describeRecordingMisses(misses: RecordingMiss[]): string[] {
33
61
  seen.add(miss.session);
34
62
  return first;
35
63
  })
36
- .map((miss) => `${miss.session ?? "outside a test"}: ${miss.file}`);
64
+ .map(
65
+ (miss) =>
66
+ `${miss.session ?? "outside a test"}: ${miss.file}${miss.change ? describeChange(miss.change) : ""}`,
67
+ );
37
68
  }
38
69
 
39
70
  /**
package/src/types.ts CHANGED
@@ -29,8 +29,13 @@ export interface RecordingRule {
29
29
  match: {
30
30
  /** The method, such as `POST`. */
31
31
  method?: string;
32
- /** The start of the URL, such as `https://ai-gateway.vercel.sh/`. */
33
- url?: 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[];
34
39
  /** Header values. Names are lowercase. */
35
40
  headers?: Record<string, string>;
36
41
  };
@@ -46,6 +51,19 @@ export interface RecordingRule {
46
51
  values?: ValuePatterns;
47
52
  }
48
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
+
49
67
  /** A request that a rule matched, but that had no recording. */
50
68
  export interface RecordingMiss {
51
69
  rule: string;
@@ -53,6 +71,12 @@ export interface RecordingMiss {
53
71
  session?: string | undefined;
54
72
  /** The key of the recording that the request needed. */
55
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;
56
80
  }
57
81
 
58
82
  /** The totals of a run. */
@@ -73,8 +97,21 @@ export interface RecordingStats {
73
97
  * recorded. Use this to find traffic that a rule misses.
74
98
  */
75
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>>>;
76
105
  }
77
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
+
78
115
  /** The configuration of one run. The client sends it. */
79
116
  export interface RunConfig {
80
117
  /**
@@ -101,6 +138,20 @@ export interface RunConfig {
101
138
  * config, keeps them only in memory, and forgets them when the run ends.
102
139
  */
103
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;
104
155
  }
105
156
 
106
157
  /** A run that the service created. */
package/src/values.ts CHANGED
@@ -107,7 +107,7 @@ function compile(patterns: ValuePatterns): CompiledValues {
107
107
  * Run `fn` with a time limit. A slow pattern cannot stop itself, but the
108
108
  * `timeout` of `vm` stops any code that runs too long, also code outside
109
109
  * the context that the script calls. A timeout throws an error with HTTP
110
- * status 400.
110
+ * status 400 and `timedOut`.
111
111
  */
112
112
  export function withTimeLimit<T>(ms: number, fn: () => T): T {
113
113
  try {
@@ -118,7 +118,7 @@ export function withTimeLimit<T>(ms: number, fn: () => T): T {
118
118
  if ((error as { code?: string }).code === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
119
119
  throw Object.assign(
120
120
  new Error(`the value patterns took over ${ms} ms. Make them simpler.`),
121
- { status: 400 },
121
+ { status: 400, timedOut: true },
122
122
  );
123
123
  }
124
124
  throw error;
@@ -153,11 +153,16 @@ export type KnownValues = ReadonlyMap<string, string>;
153
153
  * model repeats the short commit id of a `git push` range in its reply,
154
154
  * and a later request sends that reply back. A pattern wins over a known
155
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.
156
160
  */
157
161
  export function extractValues(
158
162
  text: string,
159
163
  patterns: ValuePatterns | undefined,
160
164
  known?: KnownValues,
165
+ maxValues = Infinity,
161
166
  ): { text: string; values: RequestValues } {
162
167
  const values: RequestValues = new Map();
163
168
  if (!patterns || Object.keys(patterns).length === 0) {
@@ -170,20 +175,53 @@ export function extractValues(
170
175
  const search = hasKnown
171
176
  ? new RegExp(`${pattern.source}|(${literals([...known.keys()])})`, "g")
172
177
  : pattern;
173
- const result = text.replace(search, (...match: unknown[]) => {
174
- const value = match[0] as string;
175
- const name =
176
- hasKnown && match[knownGroup] !== undefined
177
- ? known.get(value)!
178
- : names[groups.findIndex((group) => match[group] !== undefined)]!;
179
- const list = values.get(name) ?? [];
180
- list.push(value);
181
- values.set(name, list);
182
- return `<<${name}>>`;
183
- });
184
- return { text: result, values };
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 };
185
212
  }
186
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
+
187
225
  const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
188
226
 
189
227
  /**
@@ -200,13 +238,13 @@ const literals = (values: string[]) =>
200
238
  /**
201
239
  * Write each value of the request that a recorded response repeats as
202
240
  * `<<name:n>>`. A value is replaced only where it is not part of a longer
203
- * word or number.
241
+ * word or number. A value that `canBeKnown()` refuses stays as it is.
204
242
  */
205
243
  export function templateValues(text: string, values: RequestValues): string {
206
244
  const placeholders = new Map<string, string>();
207
245
  for (const [name, list] of values) {
208
246
  list.forEach((value, index) => {
209
- if (!placeholders.has(value)) {
247
+ if (!placeholders.has(value) && canBeKnown(value)) {
210
248
  placeholders.set(value, `<<${name}:${index + 1}>>`);
211
249
  }
212
250
  });
@@ -19,6 +19,7 @@ import {
19
19
  type ProvidedContext,
20
20
  } from "vitest";
21
21
  import { connectRoach } from "./client.ts";
22
+ import { swapEnv } from "./config.ts";
22
23
 
23
24
  const provided = inject("roach");
24
25
  if (provided === undefined) {
@@ -36,12 +37,10 @@ function useRun(run: NonNullable<ProvidedContext["roach"]>): void {
36
37
  // The full name starts with the file, so tests of two files never share
37
38
  // a session.
38
39
  const session = await control.startSession(task.fullName);
39
- const previous = {
40
- HTTP_PROXY: process.env.HTTP_PROXY,
41
- HTTPS_PROXY: process.env.HTTPS_PROXY,
42
- };
43
- process.env.HTTP_PROXY = session.url;
44
- process.env.HTTPS_PROXY = session.url;
40
+ const restoreEnv = swapEnv({
41
+ HTTP_PROXY: session.url,
42
+ HTTPS_PROXY: session.url,
43
+ });
45
44
  const restoreProxy = http.setGlobalProxyFromEnv({
46
45
  HTTP_PROXY: session.url,
47
46
  HTTPS_PROXY: session.url,
@@ -49,10 +48,7 @@ function useRun(run: NonNullable<ProvidedContext["roach"]>): void {
49
48
  });
50
49
  onTestFinished(async ({ task: finished }) => {
51
50
  restoreProxy();
52
- for (const [name, value] of Object.entries(previous)) {
53
- if (value === undefined) delete process.env[name];
54
- else process.env[name] = value;
55
- }
51
+ restoreEnv();
56
52
  const { missed } = await session.end(finished.result?.state === "pass");
57
53
  // `replay` mode failed these requests, so nothing went live. The test
58
54
  // must fail, also when it recovered from the error.
package/src/vitest.ts CHANGED
@@ -19,10 +19,12 @@ import tls from "node:tls";
19
19
  import type { TestProject } from "vitest/node";
20
20
  import { startRemoteRun } from "./client.ts";
21
21
  import {
22
+ DEFAULT_NO_PROXY,
22
23
  DEFAULT_SERVICE_URL,
23
24
  readEnv,
24
25
  readRoachFile,
25
26
  runConfigOf,
27
+ swapEnv,
26
28
  } from "./config.ts";
27
29
  import { writeRunReport } from "./report.ts";
28
30
  import type { RemoteRun } from "./types.ts";
@@ -44,9 +46,6 @@ declare module "vitest" {
44
46
  export async function startRoach(
45
47
  project: Pick<TestProject, "provide">,
46
48
  ): Promise<() => Promise<void>> {
47
- if (typeof http.setGlobalProxyFromEnv !== "function") {
48
- throw new Error("Roach needs Node 24.14 or later for vitest");
49
- }
50
49
  const token = readEnv("ROACH_TOKEN");
51
50
  delete process.env.ROACH_TOKEN;
52
51
  const file = await readRoachFile();
@@ -55,7 +54,11 @@ export async function startRoach(
55
54
  project.provide("roach", null);
56
55
  return async () => {};
57
56
  }
58
- const noProxy = file.noProxy ?? "localhost,127.0.0.1,::1";
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;
59
62
  const run = await startRemoteRun(
60
63
  { url: readEnv("ROACH_URL") ?? DEFAULT_SERVICE_URL, token },
61
64
  config,
@@ -65,11 +68,8 @@ export async function startRoach(
65
68
  if (process.env.GITHUB_ACTIONS) {
66
69
  process.stdout.write(`::add-mask::${run.token}\n`);
67
70
  }
68
- const previousEnv = Object.keys(run.env).map(
69
- (name) => [name, process.env[name]] as const,
70
- );
71
71
  // Test workers and their child processes start later and inherit these.
72
- Object.assign(process.env, run.env);
72
+ const restoreEnv = swapEnv(run.env);
73
73
  // This process started before them, so it reads neither the proxy nor
74
74
  // the CA file. Set both here.
75
75
  const previousCa = tls.getCACertificates("default");
@@ -94,10 +94,7 @@ export async function startRoach(
94
94
  } finally {
95
95
  restoreProxy();
96
96
  tls.setDefaultCACertificates(previousCa);
97
- for (const [name, value] of previousEnv) {
98
- if (value === undefined) delete process.env[name];
99
- else process.env[name] = value;
100
- }
97
+ restoreEnv();
101
98
  }
102
99
  };
103
100
  }