@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/README.md +644 -130
- package/package.json +4 -35
- package/src/action.ts +5 -3
- package/src/client.ts +33 -10
- package/src/config.ts +52 -5
- package/src/report.ts +38 -7
- package/src/types.ts +53 -2
- package/src/values.ts +54 -16
- package/src/vitest-setup.ts +6 -10
- package/src/vitest.ts +9 -12
- package/src/addresses.ts +0 -103
- package/src/certificates.ts +0 -169
- package/src/cli.ts +0 -35
- package/src/gcs.ts +0 -145
- package/src/recorder.ts +0 -464
- package/src/recordings.ts +0 -42
- package/src/request-key.ts +0 -165
- package/src/secrets.ts +0 -66
- package/src/server.ts +0 -544
- package/src/service.ts +0 -572
- package/src/store.ts +0 -47
- package/src/streams.ts +0 -115
- package/src/usage.ts +0 -120
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/roach",
|
|
3
|
-
"version": "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
|
-
"
|
|
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
|
-
*
|
|
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 =
|
|
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("
|
|
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
|
|
5
|
-
* returns `env`, the variables that send the
|
|
6
|
-
* the run. `connectRoach()` controls a run
|
|
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
|
|
73
|
+
const send = url.startsWith("https:") ? httpsRequest : httpRequest;
|
|
63
74
|
return new Promise((resolve, reject) => {
|
|
64
|
-
const 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
|
|
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 (
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
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(
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
});
|
package/src/vitest-setup.ts
CHANGED
|
@@ -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
|
|
40
|
-
HTTP_PROXY:
|
|
41
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
if (value === undefined) delete process.env[name];
|
|
99
|
-
else process.env[name] = value;
|
|
100
|
-
}
|
|
97
|
+
restoreEnv();
|
|
101
98
|
}
|
|
102
99
|
};
|
|
103
100
|
}
|