diff --git a/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts new file mode 100644 index 0000000000..425653e6f5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const responses = (source: string) => + analyzeJavaScriptSemantics(source) + .requestOperations.filter(({ kind }) => kind === "response-consumer") + .map(({ method, endpoint, resolution }) => ({ + method, + endpoint, + resolution, + })); + +it("keeps the endpoint of a uniquely consumed request response", () => { + expect( + responses(` + async function load() { + const a = await fetch("https://example.test/a"); + const b = await fetch("https://example.test/b"); + return [await a.json(), await b.text()]; + } + `), + ).toEqual([ + { + method: "json", + endpoint: "https://example.test/a", + resolution: "complete", + }, + { + method: "text", + endpoint: "https://example.test/b", + resolution: "complete", + }, + ]); +}); + +it("leaves an ambiguous response consumer without an endpoint", () => { + expect( + responses(` + async function load(flag) { + let response = await fetch("https://example.test/a"); + if (flag) response = await fetch("https://example.test/b"); + return response.json(); + } + `), + ).toEqual([{ method: "json", endpoint: null, resolution: "partial" }]); +}); + +it("leaves a response replaced by a non-request value without an endpoint", () => { + expect( + responses(` + async function load(cachedResponse) { + let response = await fetch("https://example.test/a"); + response = cachedResponse; + return response.json(); + } + `), + ).toEqual([expect.objectContaining({ method: "json", endpoint: null })]); +}); diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index c6d47dfd21..6d1de8325e 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -270,6 +270,18 @@ const collectResponseConsumers = ( ? [] : (requestsByBinding.get(binding.bindingId) ?? []); if (linked.length === 0) return; + // A consumer of exactly one request through its own result binding + // reads that request's response, so it keeps the request's endpoint. + const uniqueRequest = + linked.length === 1 && + !linked[0]?.operation.projectedResultBindings.some( + ({ bindingId }) => bindingId === binding?.bindingId, + ) + ? linked[0] + : undefined; + // Any other write (assignment, update, loop or destructuring target) can + // replace the response before this read, so its endpoint stays unknown. + const soleWrite = binding?.initializers.length === 1; output.push({ requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind: "response-consumer", @@ -287,15 +299,9 @@ const collectResponseConsumers = ( linkedRequestIds: linked .map(({ operation }) => operation.requestId) .sort(compareUnicodeCodePoints), - endpoint: null, + endpoint: soleWrite ? (uniqueRequest?.operation.endpoint ?? null) : null, fields: [], - resolution: - linked.length === 1 && - !linked[0]?.operation.projectedResultBindings.some( - ({ bindingId }) => bindingId === binding?.bindingId, - ) - ? "complete" - : "partial", + resolution: uniqueRequest === undefined ? "partial" : "complete", }); }); return output;