diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index a93d3f3747fa..37231749c5c4 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -63,6 +63,24 @@ function streamedVideoResponse(bytes: string): Response { ); } +// Response.json keeps object fixtures on the standard Response body path so create/poll +// reads exercise the byte-bounded reader instead of an unbounded res.json(). +function streamedJsonResponse(payload: unknown): Response { + return Response.json(payload); +} + +function streamedRawResponse(text: string): Response { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }), + { headers: { "content-type": "application/json" } }, + ); +} + describe("runway video generation provider", () => { it("declares explicit mode capabilities", () => { expectExplicitVideoGenerationCapabilities(buildRunwayVideoGenerationProvider()); @@ -70,22 +88,19 @@ describe("runway video generation provider", () => { it("submits a text-to-video task, polls it, and downloads the output", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ - id: "task-1", - }), - }, + response: streamedJsonResponse({ + id: "task-1", + }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task-1", status: "SUCCEEDED", output: ["https://example.com/out.mp4"], }), - headers: new Headers(), - }) + ) .mockResolvedValueOnce({ arrayBuffer: async () => Buffer.from("mp4-bytes"), headers: new Headers({ "content-type": "video/webm" }), @@ -130,20 +145,17 @@ describe("runway video generation provider", () => { it("rejects generated video downloads that exceed the configured media cap", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task-too-large" }), - }, + response: streamedJsonResponse({ id: "task-too-large" }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task-too-large", status: "SUCCEEDED", output: ["https://example.com/out.mp4"], }), - headers: new Headers(), - }) + ) .mockResolvedValueOnce(streamedVideoResponse("too-large")); const provider = buildRunwayVideoGenerationProvider(); @@ -159,20 +171,17 @@ describe("runway video generation provider", () => { it("does not round malformed duration values into create requests", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task-duration" }), - }, + response: streamedJsonResponse({ id: "task-duration" }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task-duration", status: "SUCCEEDED", output: ["https://example.com/out.mp4"], }), - headers: new Headers(), - }) + ) .mockResolvedValueOnce({ arrayBuffer: async () => Buffer.from("mp4-bytes"), headers: new Headers({ "content-type": "video/mp4" }), @@ -194,20 +203,17 @@ describe("runway video generation provider", () => { it("accepts local image buffers by converting them into data URIs", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task-2" }), - }, + response: streamedJsonResponse({ id: "task-2" }), release: vi.fn(async () => {}), }); fetchWithTimeoutMock - .mockResolvedValueOnce({ - json: async () => ({ + .mockResolvedValueOnce( + streamedJsonResponse({ id: "task-2", status: "SUCCEEDED", output: ["https://example.com/out.mp4"], }), - headers: new Headers(), - }) + ) .mockResolvedValueOnce({ arrayBuffer: async () => Buffer.from("mp4-bytes"), headers: new Headers({ "content-type": "video/mp4" }), @@ -250,11 +256,7 @@ describe("runway video generation provider", () => { it("reports malformed create JSON with a provider-owned error", async () => { const release = vi.fn(async () => {}); postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => { - throw new SyntaxError("bad json"); - }, - }, + response: streamedRawResponse("{ not json"), release, }); @@ -272,18 +274,15 @@ describe("runway video generation provider", () => { it("rejects status responses missing a task status", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task-missing-status" }), - }, + response: streamedJsonResponse({ id: "task-missing-status" }), release: vi.fn(async () => {}), }); - fetchWithTimeoutMock.mockResolvedValueOnce({ - json: async () => ({ + fetchWithTimeoutMock.mockResolvedValueOnce( + streamedJsonResponse({ id: "task-missing-status", output: ["https://example.com/out.mp4"], }), - headers: new Headers(), - }); + ); const provider = buildRunwayVideoGenerationProvider(); await expect( @@ -298,19 +297,16 @@ describe("runway video generation provider", () => { it("rejects malformed completed output URLs", async () => { postJsonRequestMock.mockResolvedValue({ - response: { - json: async () => ({ id: "task-malformed-output" }), - }, + response: streamedJsonResponse({ id: "task-malformed-output" }), release: vi.fn(async () => {}), }); - fetchWithTimeoutMock.mockResolvedValueOnce({ - json: async () => ({ + fetchWithTimeoutMock.mockResolvedValueOnce( + streamedJsonResponse({ id: "task-malformed-output", status: "SUCCEEDED", output: "https://example.com/out.mp4", }), - headers: new Headers(), - }); + ); const provider = buildRunwayVideoGenerationProvider(); await expect( diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index 59a63c8e485a..8b82a9111c5b 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -9,6 +9,7 @@ import { fetchProviderDownloadResponse, fetchProviderOperationResponse, postJsonRequest, + readProviderJsonResponse, resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, waitProviderOperationPollInterval, @@ -65,16 +66,13 @@ const VIDEO_MODELS = new Set(["gen4_aleph"]); const RUNWAY_TEXT_ASPECT_RATIOS = ["16:9", "9:16"] as const; const RUNWAY_EDIT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "21:9"] as const; -async function readRunwayJsonResponse( - response: Pick, - label: string, -): Promise { - let payload: unknown; - try { - payload = await response.json(); - } catch (cause) { - throw new Error(`${label}: malformed JSON response`, { cause }); - } +async function readRunwayJsonResponse(response: Response, label: string): Promise { + // Runway submit/poll task bodies are read through the shared byte-bounded reader + // (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint + // that streams an unbounded JSON body cannot force the runtime to buffer the whole + // payload before parsing. Overflow cancels the stream and throws a bounded error; + // malformed JSON keeps the existing `${label}: malformed JSON response` wrapping. + const payload = await readProviderJsonResponse(response, label); if (!isRecord(payload)) { throw new Error(`${label}: malformed JSON response`); }