11// Byteplus tests cover video generation provider plugin behavior.
2- import {
3- getProviderHttpMocks ,
4- installProviderHttpMockCleanup ,
5- } from "openclaw/plugin-sdk/provider-http-test-mocks" ;
62import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts" ;
7- import { beforeAll , describe , expect , it , vi } from "vitest" ;
8-
9- const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks ( ) ;
3+ import { afterEach , beforeAll , describe , expect , it , vi } from "vitest" ;
4+
5+ // Submit/poll transport is mocked locally so each test can inject the BytePlus task JSON
6+ // bodies, while readProviderJsonResponse is kept REAL (via importActual) so the byte-bounded
7+ // reader actually streams and cancels oversized bodies under test instead of a stub.
8+ const { postJsonRequestMock, fetchWithTimeoutMock, resolveApiKeyForProviderMock } = vi . hoisted (
9+ ( ) => ( {
10+ postJsonRequestMock : vi . fn ( ) ,
11+ fetchWithTimeoutMock : vi . fn ( ) ,
12+ resolveApiKeyForProviderMock : vi . fn ( async ( ) => ( { apiKey : "provider-key" } ) ) ,
13+ } ) ,
14+ ) ;
15+
16+ vi . mock ( "openclaw/plugin-sdk/provider-auth-runtime" , ( ) => ( {
17+ resolveApiKeyForProvider : resolveApiKeyForProviderMock ,
18+ } ) ) ;
19+
20+ vi . mock ( "openclaw/plugin-sdk/provider-http" , async ( importActual ) => {
21+ const actual = await importActual < typeof import ( "openclaw/plugin-sdk/provider-http" ) > ( ) ;
22+ const resolveTimeoutMs = ( timeoutMs : unknown ) : number =>
23+ typeof timeoutMs === "function" ? ( timeoutMs ( ) as number ) : ( ( timeoutMs as number ) ?? 60_000 ) ;
24+ return {
25+ // REAL byte-bounded JSON reader under test — not stubbed.
26+ readProviderJsonResponse : actual . readProviderJsonResponse ,
27+ postJsonRequest : postJsonRequestMock ,
28+ fetchProviderOperationResponse : async ( params : {
29+ url : string ;
30+ init ?: RequestInit ;
31+ timeoutMs ?: unknown ;
32+ fetchFn : typeof fetch ;
33+ } ) => fetchWithTimeoutMock ( params . url , params . init ?? { } , resolveTimeoutMs ( params . timeoutMs ) ) ,
34+ fetchProviderDownloadResponse : async ( params : {
35+ url : string ;
36+ init ?: RequestInit ;
37+ timeoutMs ?: unknown ;
38+ fetchFn : typeof fetch ;
39+ } ) => fetchWithTimeoutMock ( params . url , params . init ?? { } , resolveTimeoutMs ( params . timeoutMs ) ) ,
40+ assertOkOrThrowHttpError : async ( ) => { } ,
41+ createProviderOperationDeadline : ( {
42+ label,
43+ timeoutMs,
44+ } : {
45+ label : string ;
46+ timeoutMs ?: number ;
47+ } ) => ( { label, timeoutMs } ) ,
48+ createProviderOperationTimeoutResolver :
49+ ( { defaultTimeoutMs } : { defaultTimeoutMs : number } ) =>
50+ ( ) =>
51+ defaultTimeoutMs ,
52+ resolveProviderOperationTimeoutMs : ( { defaultTimeoutMs } : { defaultTimeoutMs : number } ) =>
53+ defaultTimeoutMs ,
54+ resolveProviderHttpRequestConfig : ( params : {
55+ baseUrl ?: string ;
56+ defaultBaseUrl : string ;
57+ allowPrivateNetwork ?: boolean ;
58+ defaultHeaders ?: Record < string , string > ;
59+ } ) => ( {
60+ baseUrl : params . baseUrl ?? params . defaultBaseUrl ,
61+ allowPrivateNetwork : params . allowPrivateNetwork === true ,
62+ headers : new Headers ( params . defaultHeaders ) ,
63+ dispatcherPolicy : undefined ,
64+ } ) ,
65+ waitProviderOperationPollInterval : async ( ) => { } ,
66+ } ;
67+ } ) ;
1068
1169let buildBytePlusVideoGenerationProvider : typeof import ( "./video-generation-provider.js" ) . buildBytePlusVideoGenerationProvider ;
1270
1371beforeAll ( async ( ) => {
1472 ( { buildBytePlusVideoGenerationProvider } = await import ( "./video-generation-provider.js" ) ) ;
1573} ) ;
1674
17- installProviderHttpMockCleanup ( ) ;
75+ afterEach ( ( ) => {
76+ postJsonRequestMock . mockReset ( ) ;
77+ fetchWithTimeoutMock . mockReset ( ) ;
78+ resolveApiKeyForProviderMock . mockClear ( ) ;
79+ } ) ;
1880
1981function mockSuccessfulBytePlusTask ( params ?: { model ?: string } ) {
2082 postJsonRequestMock . mockResolvedValue ( {
21- response : {
22- json : async ( ) => ( {
23- id : "task_123" ,
24- } ) ,
25- } ,
83+ response : streamedJsonResponse ( {
84+ id : "task_123" ,
85+ } ) ,
2686 release : vi . fn ( async ( ) => { } ) ,
2787 } ) ;
2888 fetchWithTimeoutMock
29- . mockResolvedValueOnce ( {
30- json : async ( ) => ( {
89+ . mockResolvedValueOnce (
90+ streamedJsonResponse ( {
3191 id : "task_123" ,
3292 status : "succeeded" ,
3393 content : {
3494 video_url : "https://example.com/byteplus.mp4" ,
3595 } ,
3696 model : params ?. model ?? "seedance-1-0-lite-t2v-250428" ,
3797 } ) ,
38- } )
98+ )
3999 . mockResolvedValueOnce ( {
40100 headers : new Headers ( { "content-type" : "video/webm" } ) ,
41101 arrayBuffer : async ( ) => Buffer . from ( "webm-bytes" ) ,
@@ -77,6 +137,53 @@ function streamedVideoResponse(bytes: string): Response {
77137 ) ;
78138}
79139
140+ // BytePlus submit/poll task JSON is now read through the byte-bounded reader, so the
141+ // mocked responses must expose a real readable body (not just a json() shortcut).
142+ function streamedJsonResponse ( payload : unknown ) : Response {
143+ return new Response (
144+ new ReadableStream ( {
145+ start ( controller ) {
146+ controller . enqueue ( new TextEncoder ( ) . encode ( JSON . stringify ( payload ) ) ) ;
147+ controller . close ( ) ;
148+ } ,
149+ } ) ,
150+ { status : 200 , headers : { "content-type" : "application/json" } } ,
151+ ) ;
152+ }
153+
154+ // Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the
155+ // bounded reader cancels the stream mid-flight; if the cap were removed the reader would
156+ // buffer the whole advertised payload before parsing. Tracks how many bytes were pulled
157+ // and whether the stream was canceled so callers can assert the body was not fully read.
158+ function makeOversizedJsonStream ( ) : {
159+ body : ReadableStream < Uint8Array > ;
160+ maxBytes : number ;
161+ totalBytes : number ;
162+ state : { bytesPulled : number ; canceled : boolean } ;
163+ } {
164+ const maxBytes = 16 * 1024 * 1024 ; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES.
165+ const ONE_MIB = 1024 * 1024 ;
166+ const TOTAL_CHUNKS = 32 ; // 32 MiB advertised body, double the cap.
167+ const chunk = new Uint8Array ( ONE_MIB ) ;
168+ const state = { bytesPulled : 0 , canceled : false } ;
169+ let pulled = 0 ;
170+ const body = new ReadableStream < Uint8Array > ( {
171+ pull ( controller ) {
172+ if ( pulled >= TOTAL_CHUNKS ) {
173+ controller . close ( ) ;
174+ return ;
175+ }
176+ pulled += 1 ;
177+ state . bytesPulled += chunk . length ;
178+ controller . enqueue ( chunk ) ;
179+ } ,
180+ cancel ( ) {
181+ state . canceled = true ;
182+ } ,
183+ } ) ;
184+ return { body, maxBytes, totalBytes : TOTAL_CHUNKS * ONE_MIB , state } ;
185+ }
186+
80187describe ( "byteplus video generation provider" , ( ) => {
81188 it ( "declares explicit mode capabilities" , ( ) => {
82189 expectExplicitVideoGenerationCapabilities ( buildBytePlusVideoGenerationProvider ( ) ) ;
@@ -110,21 +217,19 @@ describe("byteplus video generation provider", () => {
110217
111218 it ( "rejects generated video downloads that exceed the configured media cap" , async ( ) => {
112219 postJsonRequestMock . mockResolvedValue ( {
113- response : {
114- json : async ( ) => ( { id : "task_too_large" } ) ,
115- } ,
220+ response : streamedJsonResponse ( { id : "task_too_large" } ) ,
116221 release : vi . fn ( async ( ) => { } ) ,
117222 } ) ;
118223 fetchWithTimeoutMock
119- . mockResolvedValueOnce ( {
120- json : async ( ) => ( {
224+ . mockResolvedValueOnce (
225+ streamedJsonResponse ( {
121226 id : "task_too_large" ,
122227 status : "succeeded" ,
123228 content : {
124229 video_url : "https://example.com/too-large.mp4" ,
125230 } ,
126231 } ) ,
127- } )
232+ )
128233 . mockResolvedValueOnce ( streamedVideoResponse ( "too-large" ) ) ;
129234
130235 const provider = buildBytePlusVideoGenerationProvider ( ) ;
@@ -222,24 +327,22 @@ describe("byteplus video generation provider", () => {
222327
223328 it ( "drops malformed response duration metadata" , async ( ) => {
224329 postJsonRequestMock . mockResolvedValue ( {
225- response : {
226- json : async ( ) => ( {
227- id : "task_123" ,
228- } ) ,
229- } ,
330+ response : streamedJsonResponse ( {
331+ id : "task_123" ,
332+ } ) ,
230333 release : vi . fn ( async ( ) => { } ) ,
231334 } ) ;
232335 fetchWithTimeoutMock
233- . mockResolvedValueOnce ( {
234- json : async ( ) => ( {
336+ . mockResolvedValueOnce (
337+ streamedJsonResponse ( {
235338 id : "task_123" ,
236339 status : "succeeded" ,
237340 content : {
238341 video_url : "https://example.com/byteplus.mp4" ,
239342 } ,
240343 duration : 1.5 ,
241344 } ) ,
242- } )
345+ )
243346 . mockResolvedValueOnce ( {
244347 headers : new Headers ( { "content-type" : "video/mp4" } ) ,
245348 arrayBuffer : async ( ) => Buffer . from ( "mp4-bytes" ) ,
@@ -259,11 +362,15 @@ describe("byteplus video generation provider", () => {
259362 it ( "reports malformed create JSON with a provider-owned error" , async ( ) => {
260363 const release = vi . fn ( async ( ) => { } ) ;
261364 postJsonRequestMock . mockResolvedValue ( {
262- response : {
263- json : async ( ) => {
264- throw new SyntaxError ( "bad json" ) ;
265- } ,
266- } ,
365+ response : new Response (
366+ new ReadableStream ( {
367+ start ( controller ) {
368+ controller . enqueue ( new TextEncoder ( ) . encode ( "{ not valid json" ) ) ;
369+ controller . close ( ) ;
370+ } ,
371+ } ) ,
372+ { status : 200 , headers : { "content-type" : "application/json" } } ,
373+ ) ,
267374 release,
268375 } ) ;
269376
@@ -281,19 +388,17 @@ describe("byteplus video generation provider", () => {
281388
282389 it ( "rejects status responses missing a task status" , async ( ) => {
283390 postJsonRequestMock . mockResolvedValue ( {
284- response : {
285- json : async ( ) => ( { id : "task_missing_status" } ) ,
286- } ,
391+ response : streamedJsonResponse ( { id : "task_missing_status" } ) ,
287392 release : vi . fn ( async ( ) => { } ) ,
288393 } ) ;
289- fetchWithTimeoutMock . mockResolvedValueOnce ( {
290- json : async ( ) => ( {
394+ fetchWithTimeoutMock . mockResolvedValueOnce (
395+ streamedJsonResponse ( {
291396 id : "task_missing_status" ,
292397 content : {
293398 video_url : "https://example.com/byteplus.mp4" ,
294399 } ,
295400 } ) ,
296- } ) ;
401+ ) ;
297402
298403 const provider = buildBytePlusVideoGenerationProvider ( ) ;
299404 await expect (
@@ -308,18 +413,16 @@ describe("byteplus video generation provider", () => {
308413
309414 it ( "rejects malformed completed content" , async ( ) => {
310415 postJsonRequestMock . mockResolvedValue ( {
311- response : {
312- json : async ( ) => ( { id : "task_malformed_content" } ) ,
313- } ,
416+ response : streamedJsonResponse ( { id : "task_malformed_content" } ) ,
314417 release : vi . fn ( async ( ) => { } ) ,
315418 } ) ;
316- fetchWithTimeoutMock . mockResolvedValueOnce ( {
317- json : async ( ) => ( {
419+ fetchWithTimeoutMock . mockResolvedValueOnce (
420+ streamedJsonResponse ( {
318421 id : "task_malformed_content" ,
319422 status : "succeeded" ,
320423 content : [ "https://example.com/byteplus.mp4" ] ,
321424 } ) ,
322- } ) ;
425+ ) ;
323426
324427 const provider = buildBytePlusVideoGenerationProvider ( ) ;
325428 await expect (
@@ -331,4 +434,61 @@ describe("byteplus video generation provider", () => {
331434 } ) ,
332435 ) . rejects . toThrow ( "BytePlus video generation completed with malformed content" ) ;
333436 } ) ;
437+
438+ it ( "bounds the submit task JSON body and cancels an oversized stream" , async ( ) => {
439+ const stream = makeOversizedJsonStream ( ) ;
440+ const release = vi . fn ( async ( ) => { } ) ;
441+ postJsonRequestMock . mockResolvedValue ( {
442+ response : new Response ( stream . body , {
443+ status : 200 ,
444+ headers : { "content-type" : "application/json" } ,
445+ } ) ,
446+ release,
447+ } ) ;
448+
449+ const provider = buildBytePlusVideoGenerationProvider ( ) ;
450+ await expect (
451+ provider . generateVideo ( {
452+ provider : "byteplus" ,
453+ model : "seedance-1-0-lite-t2v-250428" ,
454+ prompt : "oversized submit response" ,
455+ cfg : { } ,
456+ } ) ,
457+ ) . rejects . toThrow (
458+ `BytePlus video generation failed: JSON response exceeds ${ stream . maxBytes } bytes` ,
459+ ) ;
460+ expect ( stream . state . canceled ) . toBe ( true ) ;
461+ // Only the bounded prefix is pulled, never the full advertised stream.
462+ expect ( stream . state . bytesPulled ) . toBeLessThan ( stream . totalBytes ) ;
463+ // The submit request must still be released even though the body overflowed.
464+ expect ( release ) . toHaveBeenCalledOnce ( ) ;
465+ } ) ;
466+
467+ it ( "bounds the poll status JSON body and cancels an oversized stream" , async ( ) => {
468+ postJsonRequestMock . mockResolvedValue ( {
469+ response : streamedJsonResponse ( { id : "task_oversized_poll" } ) ,
470+ release : vi . fn ( async ( ) => { } ) ,
471+ } ) ;
472+ const stream = makeOversizedJsonStream ( ) ;
473+ fetchWithTimeoutMock . mockResolvedValueOnce (
474+ new Response ( stream . body , {
475+ status : 200 ,
476+ headers : { "content-type" : "application/json" } ,
477+ } ) ,
478+ ) ;
479+
480+ const provider = buildBytePlusVideoGenerationProvider ( ) ;
481+ await expect (
482+ provider . generateVideo ( {
483+ provider : "byteplus" ,
484+ model : "seedance-1-0-lite-t2v-250428" ,
485+ prompt : "oversized poll response" ,
486+ cfg : { } ,
487+ } ) ,
488+ ) . rejects . toThrow (
489+ `BytePlus video status request failed: JSON response exceeds ${ stream . maxBytes } bytes` ,
490+ ) ;
491+ expect ( stream . state . canceled ) . toBe ( true ) ;
492+ expect ( stream . state . bytesPulled ) . toBeLessThan ( stream . totalBytes ) ;
493+ } ) ;
334494} ) ;
0 commit comments