@specific.dev/spectest 0.43.0 → 0.45.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.
@@ -62,6 +62,20 @@ export {
62
62
  type AwsOptions,
63
63
  type LambdaOptions,
64
64
  } from "./aws.js";
65
+ // Third-party providers, each answering at its own real endpoints. Named
66
+ // for the provider rather than for sign-in, because each one's surface can
67
+ // grow past auth (Gmail and Calendar for `google`, the REST API for
68
+ // `github`, Graph for `microsoft`).
69
+ export {
70
+ type ProviderOptions,
71
+ type ProviderUser,
72
+ type OAuthClient,
73
+ } from "./emulate/service.js";
74
+ export { google, type GoogleOptions } from "./google.js";
75
+ export { github, type GithubOptions } from "./github.js";
76
+ export { apple, type AppleOptions } from "./apple.js";
77
+ export { microsoft, type MicrosoftOptions } from "./microsoft.js";
78
+ export { okta, type OktaOptions } from "./okta.js";
65
79
  export {
66
80
  replayFake,
67
81
  type ReplayFakeOptions,
@@ -0,0 +1,89 @@
1
+ // `microsoft()` — Microsoft, emulated inside the environment, answering at
2
+ // its real endpoints.
3
+ //
4
+ // Today that is Sign in with Microsoft (Entra ID) plus the part of Graph an
5
+ // app reads straight after: discovery, the account picker, the token
6
+ // exchange, `/oidc/userinfo` and `/v1.0/me`. The component is named for the
7
+ // provider rather than for the feature — Graph is already claimed, and
8
+ // widening what it serves is a table entry.
9
+
10
+ import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
11
+ import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
12
+
13
+ export interface MicrosoftOptions extends ProviderOptions {
14
+ /**
15
+ * Directory (tenant) the app signs in against — whatever its authority
16
+ * URL uses. It appears in the issuer and in every endpoint path, so a
17
+ * client that checks the issuer sees what it expects. Default `"common"`.
18
+ */
19
+ tenantId?: string;
20
+ }
21
+
22
+ function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
23
+ const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
24
+ return {
25
+ name: "microsoft",
26
+ module: "@emulators/microsoft",
27
+ pluginExport: "microsoftPlugin",
28
+ baseUrl: "https://login.microsoftonline.com",
29
+ hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
30
+ // The emulator has no tenant, so its issuer is the bare host. A client
31
+ // that checks the issuer against the authority it configured would
32
+ // reject that; the re-issue step corrects it.
33
+ issuer: `${authority}/v2.0`,
34
+ discovery: {
35
+ issuer: `${authority}/v2.0`,
36
+ authorization_endpoint: `${authority}/oauth2/v2.0/authorize`,
37
+ token_endpoint: `${authority}/oauth2/v2.0/token`,
38
+ jwks_uri: `${authority}/discovery/v2.0/keys`,
39
+ end_session_endpoint: `${authority}/oauth2/v2.0/logout`,
40
+ userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo",
41
+ },
42
+ jwksPath: "^/discovery/v2\\.0/keys$",
43
+ rewrites: [
44
+ // Real Entra paths carry the tenant; the emulator's do not. The
45
+ // discovery route is the exception — it handles the tenant itself.
46
+ { from: "^/[^/]+/(oauth2/v2\\.0/.*)$", to: "/$1" },
47
+ { from: "^/[^/]+/(discovery/v2\\.0/keys)$", to: "/$1" },
48
+ ],
49
+ fallbackUser: {
50
+ login: users[0]!.email,
51
+ id: 1,
52
+ scopes: ["openid", "email", "profile", "User.Read"],
53
+ },
54
+ seed: {
55
+ users: users.map((u) => ({ email: u.email, name: u.name })),
56
+ ...oauthClients(opts.client),
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Microsoft, answering at its real endpoints. Drop into
63
+ * `environment.services`:
64
+ *
65
+ * ```ts
66
+ * import { microsoft } from "@specific.dev/spectest/components";
67
+ *
68
+ * services: {
69
+ * microsoft: microsoft({
70
+ * tenantId: "11111111-2222-3333-4444-555555555555",
71
+ * users: [{ email: "bob@example.com", name: "Bob Example" }],
72
+ * client: {
73
+ * clientId: "6f1a2b3c-…",
74
+ * clientSecret: "secret",
75
+ * redirectUris: ["https://app.test/callback/microsoft"],
76
+ * },
77
+ * }),
78
+ * app: { …, dependsOn: ["microsoft"] },
79
+ * }
80
+ * ```
81
+ *
82
+ * The app keeps its production configuration — discovery at
83
+ * `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
84
+ * and the account read back from `https://graph.microsoft.com/v1.0/me`.
85
+ */
86
+ export function microsoft(opts: MicrosoftOptions = {}) {
87
+ const users = resolveUsers("microsoft", opts.users);
88
+ return emulatorService(spec(users, opts));
89
+ }
@@ -0,0 +1,93 @@
1
+ // `okta()` — Okta, emulated inside the environment, answering at your org's
2
+ // real domain.
3
+ //
4
+ // Today that is Okta sign-in: discovery, the account picker, the token
5
+ // exchange, userinfo, introspection and revocation, on both the org
6
+ // authorization server (`/oauth2/v1/…`) and a custom one
7
+ // (`/oauth2/default/…`). The component is named for the provider rather
8
+ // than for the feature — the backing emulator also carries the users,
9
+ // groups and apps management API.
10
+ //
11
+ // Okta has no fixed hostname: every organization gets its own domain, so
12
+ // `domain` is required where the other providers need nothing.
13
+
14
+ import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
15
+ import {
16
+ emulatorService,
17
+ familyName,
18
+ givenName,
19
+ oauthClients,
20
+ resolveUsers,
21
+ } from "./emulate/service.js";
22
+
23
+ export interface OktaOptions extends ProviderOptions {
24
+ /** The org's domain, e.g. `"dev-12345.okta.com"`. */
25
+ domain: string;
26
+ }
27
+
28
+ /** Okta's picker identifies an account by its opaque Okta id, so the id has
29
+ * to be seeded rather than generated — otherwise nothing on the page can be
30
+ * mapped back to an address. */
31
+ function oktaId(email: string): string {
32
+ return `u-${email.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
33
+ }
34
+
35
+ function spec(users: ProviderUser[], opts: OktaOptions): ProviderSpec {
36
+ const domain = opts.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
37
+ return {
38
+ name: "okta",
39
+ module: "@emulators/okta",
40
+ pluginExport: "oktaPlugin",
41
+ baseUrl: `https://${domain}`,
42
+ hosts: [domain],
43
+ // Okta's issuer is per authorization server and the emulator already
44
+ // builds it from the base URL, which here is the real org domain.
45
+ jwksPath: "^/oauth2/(?:[^/]+/)?v1/keys$",
46
+ aliases: Object.fromEntries(users.map((u) => [oktaId(u.email), u.email])),
47
+ fallbackUser: { login: users[0]!.email, id: 1, scopes: ["openid", "profile", "email"] },
48
+ seed: {
49
+ users: users.map((u) => ({
50
+ okta_id: oktaId(u.email),
51
+ // An Okta login is the address, not the GitHub-style handle that
52
+ // `login` carries — it comes back as `preferred_username`.
53
+ login: u.email,
54
+ email: u.email,
55
+ first_name: givenName(u),
56
+ last_name: familyName(u),
57
+ display_name: u.name,
58
+ })),
59
+ ...oauthClients(opts.client),
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Okta, answering at your org's real domain. Drop into
66
+ * `environment.services`:
67
+ *
68
+ * ```ts
69
+ * import { okta } from "@specific.dev/spectest/components";
70
+ *
71
+ * services: {
72
+ * okta: okta({
73
+ * domain: "dev-12345.okta.com",
74
+ * users: [{ email: "alice@example.com", name: "Alice Example" }],
75
+ * client: {
76
+ * clientId: "0oaexample",
77
+ * clientSecret: "secret",
78
+ * redirectUris: ["https://app.test/callback/okta"],
79
+ * },
80
+ * }),
81
+ * app: { …, dependsOn: ["okta"] },
82
+ * }
83
+ * ```
84
+ *
85
+ * The app keeps its production configuration — discovery at
86
+ * `https://dev-12345.okta.com/.well-known/openid-configuration`, and
87
+ * everything it names.
88
+ */
89
+ export function okta(opts: OktaOptions) {
90
+ const users = resolveUsers("okta", opts.users);
91
+ if (!opts.domain) throw new Error("okta(): `domain` is required (e.g. \"dev-12345.okta.com\")");
92
+ return emulatorService(spec(users, opts));
93
+ }
package/src/daemon.ts CHANGED
@@ -57,6 +57,12 @@ import {
57
57
  sanitizeSegment,
58
58
  } from "./harness/volume-paths.js";
59
59
  import { pollUntilReady } from "./harness/ready-poll.js";
60
+ import {
61
+ isTextualContentType,
62
+ looksBinary,
63
+ omittedBody,
64
+ parseContentLength,
65
+ } from "./harness/http-body.js";
60
66
  import { encodeRegistry } from "./harness/names-registry.js";
61
67
  import {
62
68
  HOP_BY_HOP_HEADERS,
@@ -120,6 +126,7 @@ import {
120
126
  stopRecording,
121
127
  truncateUtf8,
122
128
  type TestEvent,
129
+ type OmittedBody,
123
130
  } from "./recorder.js";
124
131
  import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
125
132
  import type { Wrapped, WrappedResponse } from "./inspect.js";
@@ -3800,16 +3807,33 @@ function installFetchWrapper(): () => void {
3800
3807
  const reqBody = describeRequestBody(input, init);
3801
3808
  try {
3802
3809
  const res = await original(input as RequestInfo, init);
3803
- let responseBody: string | undefined;
3810
+ let responseBody: string | OmittedBody | undefined;
3804
3811
  let responseBodyTruncated: boolean | undefined;
3805
- try {
3806
- const cloned = res.clone();
3807
- const text = await cloned.text();
3808
- const t = truncateUtf8(text);
3809
- responseBody = t.value;
3810
- responseBodyTruncated = t.truncated;
3811
- } catch {
3812
- // Binary or unreadable body — leave undefined.
3812
+ // The reply the test gets is untouched: everything here reads a clone,
3813
+ // and a body known to be binary is not read at all. See
3814
+ // ./harness/http-body.ts for why a blanket `.text()` was wrong.
3815
+ const contentType = res.headers.get("content-type");
3816
+ const contentLength = () => parseContentLength(res.headers.get("content-length"));
3817
+ const textual = isTextualContentType(contentType);
3818
+ if (textual === false) {
3819
+ responseBody = omittedBody("binary", contentType, contentLength());
3820
+ } else {
3821
+ try {
3822
+ const cloned = res.clone();
3823
+ const text = await cloned.text();
3824
+ if (textual === undefined && looksBinary(text)) {
3825
+ // No content type (or a multipart one), and the bytes say this
3826
+ // was never text.
3827
+ responseBody = omittedBody("binary", contentType, contentLength());
3828
+ } else {
3829
+ const t = truncateUtf8(text);
3830
+ responseBody = t.value;
3831
+ responseBodyTruncated = t.truncated;
3832
+ }
3833
+ } catch {
3834
+ // The stream failed, or something had already consumed the body.
3835
+ responseBody = omittedBody("unreadable", contentType, contentLength());
3836
+ }
3813
3837
  }
3814
3838
  const seq = recordHttp({
3815
3839
  method,
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ isTextualContentType,
5
+ looksBinary,
6
+ omittedBody,
7
+ parseContentLength,
8
+ } from "./http-body";
9
+
10
+ describe("isTextualContentType", () => {
11
+ test("records text types", () => {
12
+ for (const ct of [
13
+ "text/plain",
14
+ "text/html; charset=utf-8",
15
+ "TEXT/CSS",
16
+ "application/json",
17
+ "application/problem+json",
18
+ "application/vnd.api+json",
19
+ "application/xml",
20
+ "application/x-ndjson",
21
+ "application/x-www-form-urlencoded",
22
+ "application/javascript",
23
+ ]) {
24
+ expect(isTextualContentType(ct)).toBe(true);
25
+ }
26
+ });
27
+
28
+ /** An SVG is an image by name and text by content. The suffix rule must
29
+ * win, or the recorder stops keeping a body a user can read. */
30
+ test("keeps svg, which is text under an image type", () => {
31
+ expect(isTextualContentType("image/svg+xml")).toBe(true);
32
+ });
33
+
34
+ test("refuses binary types", () => {
35
+ for (const ct of [
36
+ "image/png",
37
+ "image/jpeg",
38
+ "application/octet-stream",
39
+ "application/pdf",
40
+ "application/zip",
41
+ "audio/mpeg",
42
+ "video/mp4",
43
+ "font/woff2",
44
+ ]) {
45
+ expect(isTextualContentType(ct)).toBe(false);
46
+ }
47
+ });
48
+
49
+ /** Both of these must read the body and look at it: absent says nothing,
50
+ * and a multipart body holds text fields and file parts together. */
51
+ test("gives no answer without a usable type", () => {
52
+ expect(isTextualContentType(null)).toBeUndefined();
53
+ expect(isTextualContentType(undefined)).toBeUndefined();
54
+ expect(isTextualContentType("")).toBeUndefined();
55
+ expect(isTextualContentType(" ")).toBeUndefined();
56
+ expect(isTextualContentType("multipart/form-data; boundary=x")).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe("looksBinary", () => {
61
+ test("passes ordinary text, including empty and unicode", () => {
62
+ expect(looksBinary("")).toBe(false);
63
+ expect(looksBinary('{"ok":true}')).toBe(false);
64
+ expect(looksBinary("héllo — ok ✓")).toBe(false);
65
+ // A lone replacement character is not enough. A text reply with broken
66
+ // encoding still belongs in the timeline.
67
+ expect(looksBinary("caf\ufffd")).toBe(false);
68
+ });
69
+
70
+ /** The exact shape that reached production: a PNG decoded by `.text()`. */
71
+ test("catches a decoded PNG", () => {
72
+ const png = "\ufffdPNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR";
73
+ expect(looksBinary(png)).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe("omittedBody", () => {
78
+ test("carries the type and the size as values", () => {
79
+ expect(omittedBody("binary", "image/png", 70)).toEqual({
80
+ reason: "binary",
81
+ contentType: "image/png",
82
+ bytes: 70,
83
+ });
84
+ });
85
+
86
+ test("drops the charset parameter", () => {
87
+ expect(omittedBody("binary", "image/png; charset=binary", 1).contentType).toBe("image/png");
88
+ });
89
+
90
+ /** A missing key is what "the server never said" looks like. A zero or an
91
+ * empty string would read as a fact the recorder does not have. */
92
+ test("omits what it does not know", () => {
93
+ expect(omittedBody("binary", "application/pdf", undefined)).toEqual({
94
+ reason: "binary",
95
+ contentType: "application/pdf",
96
+ });
97
+ expect(omittedBody("binary", null, 12)).toEqual({ reason: "binary", bytes: 12 });
98
+ expect(omittedBody("unreadable", null, null)).toEqual({ reason: "unreadable" });
99
+ });
100
+
101
+ test("keeps a declared zero-byte body", () => {
102
+ expect(omittedBody("binary", "image/png", 0).bytes).toBe(0);
103
+ });
104
+ });
105
+
106
+ describe("parseContentLength", () => {
107
+ test("reads a length", () => {
108
+ expect(parseContentLength("95")).toBe(95);
109
+ expect(parseContentLength(" 95 ")).toBe(95);
110
+ expect(parseContentLength("0")).toBe(0);
111
+ });
112
+
113
+ test("refuses anything it cannot trust", () => {
114
+ for (const v of [null, undefined, "", "abc", "-1", "1.5", "9".repeat(30)]) {
115
+ expect(parseContentLength(v)).toBeUndefined();
116
+ }
117
+ });
118
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * What to record for an HTTP response body.
3
+ *
4
+ * The fetch recorder used to call `Response.text()` on every reply. That is
5
+ * correct for text and wrong for bytes. `text()` decodes as UTF-8 and it
6
+ * does **not** throw on binary: each undecodable byte becomes U+FFFD and
7
+ * each NUL byte survives as U+0000. So a PNG was recorded as a string that
8
+ * can no longer rebuild the image, and that string was then stored, shipped
9
+ * to the CLI, and drawn in the dashboard as noise. The `catch` that was
10
+ * supposed to cover this case never ran.
11
+ *
12
+ * The response is never changed by any of this — the recorder reads a clone,
13
+ * and an image reply reaches the test exactly as the server sent it. Only
14
+ * the recorded description changes: `responseBody` carries an
15
+ * {@link OmittedBody} naming and measuring the reply, in place of the text
16
+ * it would otherwise hold.
17
+ *
18
+ * Pure, so the boundary cases are tested rather than inferred.
19
+ */
20
+
21
+ import type { OmittedBody } from "../recorder.js";
22
+
23
+ /**
24
+ * Is this content type text?
25
+ *
26
+ * `true` = record the body. `false` = record a placeholder and do not even
27
+ * read the body. `undefined` = the type does not say (it is absent, or it is
28
+ * `multipart/*`, which mixes both), so the caller must read the body and
29
+ * examine it with [`looksBinary`].
30
+ */
31
+ export function isTextualContentType(contentType: string | null | undefined): boolean | undefined {
32
+ const essence = contentTypeEssence(contentType);
33
+ if (!essence) return undefined;
34
+ // Structured-syntax suffixes come first: `image/svg+xml` is text, and its
35
+ // type alone would call it an image.
36
+ if (essence.endsWith("+json") || essence.endsWith("+xml") || essence.endsWith("+yaml")) {
37
+ return true;
38
+ }
39
+ if (essence.startsWith("text/")) return true;
40
+ if (TEXTUAL_TYPES.has(essence)) return true;
41
+ // A multipart body holds text fields and file parts together. Only the
42
+ // bytes can say which one this is.
43
+ if (essence.startsWith("multipart/")) return undefined;
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * Does decoded text hold bytes that were never text?
49
+ *
50
+ * Only U+0000 counts. It cannot appear in a body that a server meant as
51
+ * text, and it is also the one character Postgres refuses inside a `jsonb`
52
+ * string, so a payload that holds it fails the event insert.
53
+ *
54
+ * A count of U+FFFD would find more binaries, and it would also condemn a
55
+ * text reply that carries a few characters of broken encoding. This runs
56
+ * only where the content type gave no answer, so the cheap test is enough.
57
+ */
58
+ export function looksBinary(text: string): boolean {
59
+ return text.includes("\u0000");
60
+ }
61
+
62
+ /**
63
+ * What the body field carries when the body itself was not recorded.
64
+ *
65
+ * Separate fields, not a sentence: a reader can group by content type, sum
66
+ * the bytes, or count what was dropped. A rendered string like
67
+ * "[binary body: image/png, 70 bytes]" can do none of that, and every
68
+ * consumer would have to parse it back apart.
69
+ *
70
+ * It replaces the body rather than sitting beside it, so the two states
71
+ * cannot both be set and there is one key to read.
72
+ */
73
+ export function omittedBody(
74
+ reason: OmittedBody["reason"],
75
+ contentType?: string | null,
76
+ byteLength?: number | null,
77
+ ): OmittedBody {
78
+ const out: OmittedBody = { reason };
79
+ const essence = contentTypeEssence(contentType);
80
+ if (essence) out.contentType = essence;
81
+ if (typeof byteLength === "number" && Number.isFinite(byteLength) && byteLength >= 0) {
82
+ out.bytes = byteLength;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** `Content-Length` as a number, or `undefined` if it is absent or unusable. */
88
+ export function parseContentLength(value: string | null | undefined): number | undefined {
89
+ if (!value) return undefined;
90
+ const n = Number(value.trim());
91
+ return Number.isSafeInteger(n) && n >= 0 ? n : undefined;
92
+ }
93
+
94
+ /** `image/png; charset=x` → `image/png`. Empty string → `undefined`. */
95
+ function contentTypeEssence(contentType: string | null | undefined): string | undefined {
96
+ const essence = (contentType ?? "").split(";")[0]?.trim().toLowerCase();
97
+ return essence ? essence : undefined;
98
+ }
99
+
100
+ /**
101
+ * Text types whose name does not say so. Everything else is caught by
102
+ * `text/` or by a `+json`/`+xml`/`+yaml` suffix.
103
+ */
104
+ const TEXTUAL_TYPES = new Set([
105
+ "application/json",
106
+ "application/xml",
107
+ "application/yaml",
108
+ "application/x-yaml",
109
+ "application/javascript",
110
+ "application/x-javascript",
111
+ "application/ecmascript",
112
+ "application/graphql",
113
+ "application/graphql-response+json",
114
+ "application/x-ndjson",
115
+ "application/ndjson",
116
+ "application/jsonl",
117
+ "application/x-www-form-urlencoded",
118
+ "application/x-sh",
119
+ "application/sql",
120
+ "application/csp-report",
121
+ "application/problem+json",
122
+ ]);
package/src/recorder.ts CHANGED
@@ -206,6 +206,32 @@ export interface S3Event extends BaseEvent {
206
206
  error?: string;
207
207
  }
208
208
 
209
+ /**
210
+ * A body that was not captured, described in place of the body itself.
211
+ *
212
+ * This is the object form of `requestBody` / `responseBody`: the field holds
213
+ * the text when the body was text, and this when it was not. One key, and
214
+ * the two states cannot both be set. A reader tells them apart with
215
+ * `typeof body === "string"` (`jsonb_typeof(...)` in SQL).
216
+ *
217
+ * A body that is not text is described rather than transcribed. The recorder
218
+ * used to force one into a string with `Response.text()`, which decodes
219
+ * lossily and cannot be reversed — see `harness/http-body.ts`. The fields are
220
+ * separate values, not a sentence, so a reader can group by content type,
221
+ * sum the bytes, or find every reply that was dropped.
222
+ */
223
+ export interface OmittedBody {
224
+ /** `binary` — the body was not text. `unreadable` — the stream failed, or
225
+ * something had already consumed it. */
226
+ reason: "binary" | "unreadable";
227
+ /** The content type the server declared, without its parameters. Absent
228
+ * when the server declared none. */
229
+ contentType?: string;
230
+ /** Size from `Content-Length`. Absent when the server declared none — the
231
+ * body is never read to count it. */
232
+ bytes?: number;
233
+ }
234
+
209
235
  export interface HttpEvent extends BaseEvent {
210
236
  kind: "http";
211
237
  method: string;
@@ -214,7 +240,10 @@ export interface HttpEvent extends BaseEvent {
214
240
  requestBody?: string;
215
241
  requestBodyTruncated?: boolean;
216
242
  status?: number;
217
- responseBody?: string;
243
+ /** The response body as text, or an {@link OmittedBody} describing what
244
+ * was there when it was not text. */
245
+ responseBody?: string | OmittedBody;
246
+ /** Only meaningful for a text body. */
218
247
  responseBodyTruncated?: boolean;
219
248
  durationMs: number;
220
249
  /** Set if the request threw (network error, abort, etc.). */
@@ -259,7 +288,10 @@ export interface KubeEvent extends BaseEvent {
259
288
  requestBody?: string;
260
289
  requestBodyTruncated?: boolean;
261
290
  status?: number;
262
- responseBody?: string;
291
+ /** The response body as text, or an {@link OmittedBody} describing what
292
+ * was there when it was not text. */
293
+ responseBody?: string | OmittedBody;
294
+ /** Only meaningful for a text body. */
263
295
  responseBodyTruncated?: boolean;
264
296
  durationMs: number;
265
297
  error?: string;