@replayio-app-building/netlify-recorder 0.6.0 → 0.7.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/dist/index.d.ts +33 -9
- package/dist/index.js +297 -15
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,9 @@ interface RequestContext {
|
|
|
34
34
|
startTime: number;
|
|
35
35
|
/** Restores original globals (fetch, process.env). Called automatically by finishRequest. */
|
|
36
36
|
cleanup: () => void;
|
|
37
|
+
/** For v2 Request inputs, a promise that resolves to the request body text.
|
|
38
|
+
* finishRequest awaits this before building the blob so POST bodies are captured. */
|
|
39
|
+
_v2BodyPromise?: Promise<string | undefined>;
|
|
37
40
|
}
|
|
38
41
|
interface CapturedData {
|
|
39
42
|
networkCalls: NetworkCall[];
|
|
@@ -54,6 +57,10 @@ interface EnvRead {
|
|
|
54
57
|
value: string | undefined;
|
|
55
58
|
timestamp: number;
|
|
56
59
|
}
|
|
60
|
+
interface HandlerResponse {
|
|
61
|
+
statusCode: number;
|
|
62
|
+
body?: string;
|
|
63
|
+
}
|
|
57
64
|
interface BlobData {
|
|
58
65
|
requestInfo: RequestInfo;
|
|
59
66
|
capturedData: CapturedData;
|
|
@@ -61,6 +68,8 @@ interface BlobData {
|
|
|
61
68
|
commitSha: string;
|
|
62
69
|
startTime: number;
|
|
63
70
|
endTime: number;
|
|
71
|
+
/** The response returned to the client, used to detect replay mismatches. */
|
|
72
|
+
handlerResponse?: HandlerResponse;
|
|
64
73
|
}
|
|
65
74
|
interface FinishRequestCallbacks {
|
|
66
75
|
/** Uploads serialized captured data and returns the blob URL. */
|
|
@@ -116,19 +125,20 @@ interface EnsureRecordingOptions {
|
|
|
116
125
|
|
|
117
126
|
/**
|
|
118
127
|
* Called at the beginning of a Netlify handler execution.
|
|
119
|
-
* Accepts
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
128
|
+
* Accepts either a Netlify Functions v1 event (HandlerEvent with httpMethod,
|
|
129
|
+
* path, etc.) or a v2 Web API Request. Extracts all request metadata
|
|
130
|
+
* internally, installs interceptors on globalThis.fetch and process.env to
|
|
131
|
+
* capture outbound network calls and environment variable reads, and returns
|
|
132
|
+
* a request context used by finishRequest.
|
|
123
133
|
*
|
|
124
134
|
* When running inside createRequestRecording (replay mode), the replay
|
|
125
135
|
* interceptors are already installed. In that case we skip installing
|
|
126
136
|
* capture interceptors to avoid overwriting the replay layer (which would
|
|
127
137
|
* also crash on Node v16 where Headers/Response don't exist).
|
|
128
138
|
*/
|
|
129
|
-
declare function startRequest(event: NetlifyEvent): RequestContext;
|
|
139
|
+
declare function startRequest(event: NetlifyEvent | /* v2 Request */ Record<string, unknown>): RequestContext;
|
|
130
140
|
|
|
131
|
-
interface
|
|
141
|
+
interface FullHandlerResponse {
|
|
132
142
|
statusCode: number;
|
|
133
143
|
headers?: Record<string, string>;
|
|
134
144
|
body?: string;
|
|
@@ -148,7 +158,7 @@ interface FinishRequestOptions {
|
|
|
148
158
|
* uploads it as a JSON blob via the provided callback,
|
|
149
159
|
* stores the request metadata, and sets the X-Replay-Request-Id header.
|
|
150
160
|
*/
|
|
151
|
-
declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response:
|
|
161
|
+
declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
|
|
152
162
|
|
|
153
163
|
/**
|
|
154
164
|
* Creates `FinishRequestCallbacks` that send captured data to a remote
|
|
@@ -206,6 +216,20 @@ declare function ensureRequestRecording(requestId: string, options: EnsureRecord
|
|
|
206
216
|
*/
|
|
207
217
|
declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
|
|
208
218
|
|
|
219
|
+
interface RecordingResult {
|
|
220
|
+
/** Whether a response mismatch was detected between capture and replay. */
|
|
221
|
+
responseMismatch: boolean;
|
|
222
|
+
/** Details about the mismatch, if any. */
|
|
223
|
+
mismatchDetails?: string;
|
|
224
|
+
/** The response produced during replay. */
|
|
225
|
+
replayResponse?: HandlerResponse;
|
|
226
|
+
/** The original response captured in the blob. */
|
|
227
|
+
capturedResponse?: HandlerResponse;
|
|
228
|
+
/** Whether some recorded network calls were not consumed during replay. */
|
|
229
|
+
unconsumedNetworkCalls: boolean;
|
|
230
|
+
/** Details about unconsumed network calls, if any. */
|
|
231
|
+
unconsumedNetworkDetails?: string;
|
|
232
|
+
}
|
|
209
233
|
/**
|
|
210
234
|
* Called from within a container (running under replay-node) to create a Replay recording.
|
|
211
235
|
* Installs replay-mode interceptors that return pre-recorded responses, then executes
|
|
@@ -214,7 +238,7 @@ declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
|
|
|
214
238
|
* Accepts either a blob URL (fetched at runtime) or pre-parsed BlobData (avoids needing
|
|
215
239
|
* globalThis.fetch, which is missing in replay-node's Node v16 environment).
|
|
216
240
|
*/
|
|
217
|
-
declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<
|
|
241
|
+
declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
|
|
218
242
|
|
|
219
243
|
/**
|
|
220
244
|
* Options for spawning a recording container from a blob URL.
|
|
@@ -252,4 +276,4 @@ interface SpawnRecordingContainerOptions {
|
|
|
252
276
|
*/
|
|
253
277
|
declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
|
|
254
278
|
|
|
255
|
-
export { type BlobData, type CapturedData, type ContainerInfraConfig, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type NetlifyEvent, type NetworkCall, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRequestRecording, ensureRequestRecording, finishRequest, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
|
|
279
|
+
export { type BlobData, type CapturedData, type ContainerInfraConfig, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse, type NetlifyEvent, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRequestRecording, ensureRequestRecording, finishRequest, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
|
package/dist/index.js
CHANGED
|
@@ -131,14 +131,71 @@ function installEnvironmentInterceptor(mode, reads) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
// src/startRequest.ts
|
|
134
|
-
function
|
|
134
|
+
function isWebApiRequest(event) {
|
|
135
|
+
if (!event || typeof event !== "object") return false;
|
|
136
|
+
const obj = event;
|
|
137
|
+
return typeof obj.httpMethod === "undefined" && typeof obj.method === "string" && typeof obj.url === "string";
|
|
138
|
+
}
|
|
139
|
+
function extractFromWebRequest(req) {
|
|
140
|
+
const r = req;
|
|
141
|
+
const headers = {};
|
|
142
|
+
if (r.headers && typeof r.headers.forEach === "function") {
|
|
143
|
+
r.headers.forEach((value, key) => {
|
|
144
|
+
headers[key] = value;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
let pathname = "/";
|
|
148
|
+
let rawQuery = "";
|
|
149
|
+
let queryStringParameters = null;
|
|
150
|
+
try {
|
|
151
|
+
const parsed = new URL(r.url);
|
|
152
|
+
pathname = parsed.pathname;
|
|
153
|
+
rawQuery = parsed.search ? parsed.search.slice(1) : "";
|
|
154
|
+
if (parsed.searchParams) {
|
|
155
|
+
const params = {};
|
|
156
|
+
parsed.searchParams.forEach((v, k) => {
|
|
157
|
+
params[k] = v;
|
|
158
|
+
});
|
|
159
|
+
if (Object.keys(params).length > 0) queryStringParameters = params;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
pathname = r.url;
|
|
163
|
+
}
|
|
164
|
+
const requestInfo = {
|
|
165
|
+
method: r.method,
|
|
166
|
+
url: pathname,
|
|
167
|
+
headers,
|
|
168
|
+
body: void 0,
|
|
169
|
+
// filled asynchronously below
|
|
170
|
+
queryStringParameters,
|
|
171
|
+
multiValueQueryStringParameters: null,
|
|
172
|
+
rawUrl: r.url,
|
|
173
|
+
rawQuery,
|
|
174
|
+
isBase64Encoded: false
|
|
175
|
+
};
|
|
176
|
+
let bodyPromise = Promise.resolve(void 0);
|
|
177
|
+
if (r.body !== null && r.body !== void 0 && typeof r.clone === "function") {
|
|
178
|
+
try {
|
|
179
|
+
const clone = r.clone();
|
|
180
|
+
if (typeof clone.text === "function") {
|
|
181
|
+
bodyPromise = clone.text().then(
|
|
182
|
+
(text) => text || void 0,
|
|
183
|
+
() => void 0
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { requestInfo, bodyPromise };
|
|
190
|
+
}
|
|
191
|
+
function extractFromV1Event(event) {
|
|
135
192
|
const headers = {};
|
|
136
193
|
for (const [key, value] of Object.entries(event.headers)) {
|
|
137
194
|
if (value !== void 0) {
|
|
138
195
|
headers[key] = value;
|
|
139
196
|
}
|
|
140
197
|
}
|
|
141
|
-
|
|
198
|
+
return {
|
|
142
199
|
method: event.httpMethod,
|
|
143
200
|
url: event.path,
|
|
144
201
|
headers,
|
|
@@ -149,6 +206,17 @@ function startRequest(event) {
|
|
|
149
206
|
rawQuery: event.rawQuery,
|
|
150
207
|
isBase64Encoded: event.isBase64Encoded
|
|
151
208
|
};
|
|
209
|
+
}
|
|
210
|
+
function startRequest(event) {
|
|
211
|
+
let requestInfo;
|
|
212
|
+
let bodyPromise;
|
|
213
|
+
if (isWebApiRequest(event)) {
|
|
214
|
+
const extracted = extractFromWebRequest(event);
|
|
215
|
+
requestInfo = extracted.requestInfo;
|
|
216
|
+
bodyPromise = extracted.bodyPromise;
|
|
217
|
+
} else {
|
|
218
|
+
requestInfo = extractFromV1Event(event);
|
|
219
|
+
}
|
|
152
220
|
const capturedData = { networkCalls: [], envReads: [] };
|
|
153
221
|
const isReplay = globalThis.__REPLAY_RECORDING_MODE__ === true;
|
|
154
222
|
let cleanup;
|
|
@@ -167,7 +235,8 @@ function startRequest(event) {
|
|
|
167
235
|
requestInfo,
|
|
168
236
|
capturedData,
|
|
169
237
|
startTime: Date.now(),
|
|
170
|
-
cleanup
|
|
238
|
+
cleanup,
|
|
239
|
+
_v2BodyPromise: bodyPromise
|
|
171
240
|
};
|
|
172
241
|
}
|
|
173
242
|
|
|
@@ -318,12 +387,25 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
318
387
|
const commitSha = rawCommitSha;
|
|
319
388
|
const branchName = rawBranchName;
|
|
320
389
|
const repositoryUrl = rawRepositoryUrl;
|
|
390
|
+
if (requestContext._v2BodyPromise && !requestContext.requestInfo.body) {
|
|
391
|
+
try {
|
|
392
|
+
const bodyText = await requestContext._v2BodyPromise;
|
|
393
|
+
if (bodyText) {
|
|
394
|
+
requestContext.requestInfo.body = bodyText;
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
}
|
|
398
|
+
}
|
|
321
399
|
const rawBlobData = {
|
|
322
400
|
requestInfo: requestContext.requestInfo,
|
|
323
401
|
capturedData: requestContext.capturedData,
|
|
324
402
|
commitSha,
|
|
325
403
|
startTime: requestContext.startTime,
|
|
326
|
-
endTime: Date.now()
|
|
404
|
+
endTime: Date.now(),
|
|
405
|
+
handlerResponse: {
|
|
406
|
+
statusCode: response.statusCode,
|
|
407
|
+
body: response.body
|
|
408
|
+
}
|
|
327
409
|
};
|
|
328
410
|
const blobData = redactBlobData(rawBlobData);
|
|
329
411
|
const blobContent = JSON.stringify(blobData);
|
|
@@ -675,34 +757,233 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
675
757
|
};
|
|
676
758
|
g.File = FileShim;
|
|
677
759
|
}
|
|
760
|
+
if (typeof g.Headers === "undefined") {
|
|
761
|
+
const HeadersShim = class Headers {
|
|
762
|
+
_map;
|
|
763
|
+
constructor(init) {
|
|
764
|
+
this._map = /* @__PURE__ */ new Map();
|
|
765
|
+
if (init) {
|
|
766
|
+
const entries = Array.isArray(init) ? init : Object.entries(init);
|
|
767
|
+
for (const [k, v] of entries) this._map.set(k.toLowerCase(), String(v));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
get(name) {
|
|
771
|
+
return this._map.get(name.toLowerCase()) ?? null;
|
|
772
|
+
}
|
|
773
|
+
has(name) {
|
|
774
|
+
return this._map.has(name.toLowerCase());
|
|
775
|
+
}
|
|
776
|
+
set(name, value) {
|
|
777
|
+
this._map.set(name.toLowerCase(), String(value));
|
|
778
|
+
}
|
|
779
|
+
delete(name) {
|
|
780
|
+
this._map.delete(name.toLowerCase());
|
|
781
|
+
}
|
|
782
|
+
forEach(cb) {
|
|
783
|
+
this._map.forEach((v, k) => cb(v, k, this));
|
|
784
|
+
}
|
|
785
|
+
entries() {
|
|
786
|
+
return this._map.entries();
|
|
787
|
+
}
|
|
788
|
+
keys() {
|
|
789
|
+
return this._map.keys();
|
|
790
|
+
}
|
|
791
|
+
values() {
|
|
792
|
+
return this._map.values();
|
|
793
|
+
}
|
|
794
|
+
[Symbol.iterator]() {
|
|
795
|
+
return this._map.entries();
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
g.Headers = HeadersShim;
|
|
799
|
+
}
|
|
800
|
+
if (typeof g.Request === "undefined") {
|
|
801
|
+
const H = g.Headers;
|
|
802
|
+
const RequestShim = class Request {
|
|
803
|
+
method;
|
|
804
|
+
url;
|
|
805
|
+
headers;
|
|
806
|
+
_body;
|
|
807
|
+
constructor(input, init) {
|
|
808
|
+
this.url = input;
|
|
809
|
+
this.method = String(init?.method ?? "GET");
|
|
810
|
+
this.headers = new H(init?.headers);
|
|
811
|
+
this._body = init?.body ?? null;
|
|
812
|
+
}
|
|
813
|
+
async text() {
|
|
814
|
+
return this._body ?? "";
|
|
815
|
+
}
|
|
816
|
+
async json() {
|
|
817
|
+
return JSON.parse(this._body ?? "null");
|
|
818
|
+
}
|
|
819
|
+
clone() {
|
|
820
|
+
return new RequestShim(this.url, {
|
|
821
|
+
method: this.method,
|
|
822
|
+
headers: Object.fromEntries(this.headers.entries()),
|
|
823
|
+
body: this._body ?? void 0
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
g.Request = RequestShim;
|
|
828
|
+
}
|
|
829
|
+
if (typeof g.Response === "undefined") {
|
|
830
|
+
const H = g.Headers;
|
|
831
|
+
const ResponseShim = class Response {
|
|
832
|
+
status;
|
|
833
|
+
statusText;
|
|
834
|
+
headers;
|
|
835
|
+
ok;
|
|
836
|
+
_body;
|
|
837
|
+
constructor(body, init) {
|
|
838
|
+
this._body = body ?? null;
|
|
839
|
+
this.status = init?.status ?? 200;
|
|
840
|
+
this.statusText = init?.statusText ?? "";
|
|
841
|
+
this.headers = new H(init?.headers);
|
|
842
|
+
this.ok = this.status >= 200 && this.status < 300;
|
|
843
|
+
}
|
|
844
|
+
async text() {
|
|
845
|
+
return this._body ?? "";
|
|
846
|
+
}
|
|
847
|
+
async json() {
|
|
848
|
+
return JSON.parse(this._body ?? "null");
|
|
849
|
+
}
|
|
850
|
+
clone() {
|
|
851
|
+
return new ResponseShim(this._body, {
|
|
852
|
+
status: this.status,
|
|
853
|
+
statusText: this.statusText,
|
|
854
|
+
headers: Object.fromEntries(this.headers.entries())
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
g.Response = ResponseShim;
|
|
859
|
+
}
|
|
860
|
+
const result = { responseMismatch: false, unconsumedNetworkCalls: false };
|
|
678
861
|
try {
|
|
679
862
|
const handlerModule = await import(handlerPath);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
863
|
+
let handler;
|
|
864
|
+
let isV2 = false;
|
|
865
|
+
let current = handlerModule;
|
|
866
|
+
for (let depth = 0; depth < 6 && current && typeof current === "object"; depth++) {
|
|
867
|
+
const obj = current;
|
|
868
|
+
if (typeof obj.handler === "function") {
|
|
869
|
+
handler = obj.handler;
|
|
870
|
+
isV2 = false;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
if (typeof obj.default === "function") {
|
|
874
|
+
handler = obj.default;
|
|
875
|
+
isV2 = true;
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
if (obj.default && typeof obj.default === "object") {
|
|
879
|
+
current = obj.default;
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
if (!handler && typeof handlerModule === "function") {
|
|
885
|
+
handler = handlerModule;
|
|
886
|
+
isV2 = true;
|
|
887
|
+
}
|
|
888
|
+
if (!handler) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`Could not resolve handler from ${handlerPath}. Module exports: [${Object.keys(handlerModule).join(", ")}]`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
let rawResult;
|
|
894
|
+
if (isV2) {
|
|
895
|
+
const url = requestInfo.rawUrl ?? `https://localhost${requestInfo.url}`;
|
|
896
|
+
const reqInit = {
|
|
897
|
+
method: requestInfo.method,
|
|
898
|
+
headers: requestInfo.headers
|
|
899
|
+
};
|
|
900
|
+
if (requestInfo.body && requestInfo.method !== "GET" && requestInfo.method !== "HEAD") {
|
|
901
|
+
reqInit.body = requestInfo.body;
|
|
902
|
+
}
|
|
903
|
+
const RequestCtor = globalThis.Request;
|
|
904
|
+
if (!RequestCtor) {
|
|
905
|
+
throw new Error(
|
|
906
|
+
"Handler uses Netlify Functions v2 signature but Request is not available in this environment. Ensure undici or a Request polyfill is installed."
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
const req = new RequestCtor(url, reqInit);
|
|
910
|
+
const context = { geo: {}, ip: "127.0.0.1", requestId: "replay", server: { region: "local" } };
|
|
911
|
+
const response = await handler(req, context);
|
|
912
|
+
if (response && typeof response === "object" && typeof response.status === "number" && typeof response.text === "function") {
|
|
913
|
+
const res = response;
|
|
914
|
+
rawResult = {
|
|
915
|
+
statusCode: res.status,
|
|
916
|
+
body: await res.text()
|
|
917
|
+
};
|
|
918
|
+
} else {
|
|
919
|
+
rawResult = response;
|
|
920
|
+
}
|
|
921
|
+
} else {
|
|
922
|
+
rawResult = await handler({
|
|
923
|
+
httpMethod: requestInfo.method,
|
|
924
|
+
path: requestInfo.url,
|
|
925
|
+
headers: requestInfo.headers,
|
|
926
|
+
body: requestInfo.body ?? null,
|
|
927
|
+
queryStringParameters: requestInfo.queryStringParameters ?? null,
|
|
928
|
+
multiValueQueryStringParameters: requestInfo.multiValueQueryStringParameters ?? null,
|
|
929
|
+
rawUrl: requestInfo.rawUrl ?? requestInfo.url,
|
|
930
|
+
rawQuery: requestInfo.rawQuery ?? "",
|
|
931
|
+
isBase64Encoded: requestInfo.isBase64Encoded ?? false
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
if (blobData.handlerResponse) {
|
|
935
|
+
const replayRes = rawResult;
|
|
936
|
+
const replayResponse = {
|
|
937
|
+
statusCode: replayRes?.statusCode ?? 0,
|
|
938
|
+
body: replayRes?.body
|
|
939
|
+
};
|
|
940
|
+
result.replayResponse = replayResponse;
|
|
941
|
+
result.capturedResponse = blobData.handlerResponse;
|
|
942
|
+
const statusMismatch = replayResponse.statusCode !== blobData.handlerResponse.statusCode;
|
|
943
|
+
const bodyMismatch = replayResponse.body !== blobData.handlerResponse.body;
|
|
944
|
+
if (statusMismatch || bodyMismatch) {
|
|
945
|
+
result.responseMismatch = true;
|
|
946
|
+
const details = [];
|
|
947
|
+
if (statusMismatch) {
|
|
948
|
+
details.push(
|
|
949
|
+
`Status code: captured=${blobData.handlerResponse.statusCode}, replay=${replayResponse.statusCode}`
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
if (bodyMismatch) {
|
|
953
|
+
const capturedPreview = (blobData.handlerResponse.body ?? "").slice(0, 200);
|
|
954
|
+
const replayPreview = (replayResponse.body ?? "").slice(0, 200);
|
|
955
|
+
details.push(
|
|
956
|
+
`Body differs: captured=${JSON.stringify(capturedPreview)}, replay=${JSON.stringify(replayPreview)}`
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
result.mismatchDetails = details.join("; ");
|
|
960
|
+
console.error(`RESPONSE_MISMATCH: The replay response differs from the captured response.`);
|
|
961
|
+
console.error(` ${result.mismatchDetails}`);
|
|
962
|
+
} else {
|
|
963
|
+
console.log(` [response-check] Replay response matches captured response.`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
691
966
|
} finally {
|
|
692
967
|
const consumed = networkHandle.consumedCount();
|
|
693
968
|
const total = networkHandle.totalCount();
|
|
694
969
|
if (consumed < total) {
|
|
970
|
+
result.unconsumedNetworkCalls = true;
|
|
971
|
+
const details = [
|
|
972
|
+
`Consumed ${consumed} of ${total} calls. ${total - consumed} call(s) were never replayed.`
|
|
973
|
+
];
|
|
695
974
|
console.error(
|
|
696
975
|
`ERROR: Not all recorded network calls were consumed during replay. Consumed ${consumed} of ${total} calls. ${total - consumed} call(s) were never replayed.`
|
|
697
976
|
);
|
|
698
977
|
for (let i = consumed; i < total; i++) {
|
|
699
978
|
const call = blobData.capturedData.networkCalls[i];
|
|
700
979
|
if (call) {
|
|
980
|
+
details.push(`Unconsumed: ${call.method} ${call.url} => ${call.responseStatus}`);
|
|
701
981
|
console.error(
|
|
702
982
|
` Unconsumed call ${i + 1}/${total}: ${call.method} ${call.url} => ${call.responseStatus}`
|
|
703
983
|
);
|
|
704
984
|
}
|
|
705
985
|
}
|
|
986
|
+
result.unconsumedNetworkDetails = details.join("; ");
|
|
706
987
|
} else {
|
|
707
988
|
console.log(` [network-replay] All ${total} recorded network call(s) were consumed.`);
|
|
708
989
|
}
|
|
@@ -710,6 +991,7 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
710
991
|
networkHandle.restore();
|
|
711
992
|
envHandle.restore();
|
|
712
993
|
}
|
|
994
|
+
return result;
|
|
713
995
|
}
|
|
714
996
|
export {
|
|
715
997
|
createRequestRecording,
|