1- import { describe , it , expect , beforeEach , afterEach } from "vitest" ;
1+ import { describe , it , expect , beforeEach , afterEach , vi } from "vitest" ;
22import { mkdtempSync , writeFileSync , mkdirSync , rmSync } from "node:fs" ;
33import { tmpdir } from "node:os" ;
44import { join } from "node:path" ;
55import { loadConfig , startFromConfig } from "../config-loader.js" ;
66import type { AimockConfig } from "../config-loader.js" ;
7+ import type { RecordedTimings } from "../types.js" ;
8+ import { Logger } from "../logger.js" ;
79
810function makeTmpDir ( ) : string {
911 return mkdtempSync ( join ( tmpdir ( ) , "config-loader-test-" ) ) ;
@@ -32,6 +34,47 @@ function writeFixtureFile(dir: string, name = "fixtures.json"): string {
3234 return filePath ;
3335}
3436
37+ // Content is long enough to split into several chunks so per-chunk delays are observable
38+ function writeStreamFixtureFile (
39+ dir : string ,
40+ recordedTimings ?: RecordedTimings ,
41+ name = "stream-fixtures.json" ,
42+ ) : string {
43+ const filePath = join ( dir , name ) ;
44+ writeFileSync (
45+ filePath ,
46+ JSON . stringify ( {
47+ fixtures : [
48+ {
49+ match : { userMessage : "hello" } ,
50+ response : { content : "Hello world from the streaming config test!" } ,
51+ ...( recordedTimings ? { recordedTimings } : { } ) ,
52+ } ,
53+ ] ,
54+ } ) ,
55+ "utf-8" ,
56+ ) ;
57+ return filePath ;
58+ }
59+
60+ // Streams a completion and counts SSE frames. Without stream: true there is no SSE and no
61+ // chunk delays, so the timing assertions below would pass whether or not the options are wired.
62+ async function streamChunkCount ( url : string ) : Promise < number > {
63+ const resp = await fetch ( `${ url } /v1/chat/completions` , {
64+ method : "POST" ,
65+ headers : { "Content-Type" : "application/json" } ,
66+ body : JSON . stringify ( {
67+ model : "gpt-4" ,
68+ stream : true ,
69+ messages : [ { role : "user" , content : "hello" } ] ,
70+ } ) ,
71+ } ) ;
72+ const body = await resp . text ( ) ;
73+ return body
74+ . split ( "\n\n" )
75+ . filter ( ( frame ) => frame . startsWith ( "data: " ) && ! frame . includes ( "[DONE]" ) ) . length ;
76+ }
77+
3578describe ( "loadConfig" , ( ) => {
3679 let tmpDir : string ;
3780
@@ -156,6 +199,108 @@ describe("startFromConfig", () => {
156199 expect ( resp . status ) . toBe ( 500 ) ;
157200 } ) ;
158201
202+ it ( "with llm.chunkSize, the response is split into more chunks" , async ( ) => {
203+ const fixturePath = writeStreamFixtureFile ( tmpDir ) ;
204+
205+ const small = await startFromConfig ( { llm : { fixtures : fixturePath , chunkSize : 5 } } ) ;
206+ cleanups . push ( ( ) => small . llmock . stop ( ) ) ;
207+ const smallChunks = await streamChunkCount ( small . url ) ;
208+
209+ const large = await startFromConfig ( { llm : { fixtures : fixturePath , chunkSize : 1000 } } ) ;
210+ cleanups . push ( ( ) => large . llmock . stop ( ) ) ;
211+ const largeChunks = await streamChunkCount ( large . url ) ;
212+
213+ expect ( smallChunks ) . toBeGreaterThan ( largeChunks ) ;
214+ } ) ;
215+
216+ it ( "with llm.latency, streamed chunks are delayed" , async ( ) => {
217+ const fixturePath = writeStreamFixtureFile ( tmpDir ) ;
218+ const config : AimockConfig = {
219+ llm : { fixtures : fixturePath , chunkSize : 5 , latency : 100 } ,
220+ } ;
221+ const { llmock, url } = await startFromConfig ( config ) ;
222+ cleanups . push ( ( ) => llmock . stop ( ) ) ;
223+
224+ const start = Date . now ( ) ;
225+ const chunks = await streamChunkCount ( url ) ;
226+ const elapsed = Date . now ( ) - start ;
227+
228+ // 100ms per chunk across >= 5 chunks. Asserted as a lower bound so a slow
229+ // machine can never fail it; without the passthrough latency is 0.
230+ expect ( chunks ) . toBeGreaterThanOrEqual ( 5 ) ;
231+ expect ( elapsed ) . toBeGreaterThanOrEqual ( 250 ) ;
232+ } ) ;
233+
234+ it ( "with llm.replaySpeed, recorded timings replay faster" , async ( ) => {
235+ // 500ms TTFT + 4 x 500ms inter-chunk = ~2500ms at 1x speed
236+ const fixturePath = writeStreamFixtureFile ( tmpDir , {
237+ ttftMs : 500 ,
238+ interChunkDelaysMs : [ 500 , 500 , 500 , 500 ] ,
239+ totalDurationMs : 2500 ,
240+ } ) ;
241+ const config : AimockConfig = {
242+ llm : { fixtures : fixturePath , chunkSize : 5 , replaySpeed : 100 } ,
243+ } ;
244+ const { llmock, url } = await startFromConfig ( config ) ;
245+ cleanups . push ( ( ) => llmock . stop ( ) ) ;
246+
247+ const start = Date . now ( ) ;
248+ await streamChunkCount ( url ) ;
249+ const elapsed = Date . now ( ) - start ;
250+
251+ // At 100x this lands in ~25ms; the bound is deliberately generous so only a
252+ // dropped passthrough (which costs the full ~2500ms) can fail it.
253+ expect ( elapsed ) . toBeLessThan ( 1000 ) ;
254+ } ) ;
255+
256+ it ( "with non-positive llm.replaySpeed, warns and ignores it" , async ( ) => {
257+ const fixturePath = writeStreamFixtureFile ( tmpDir ) ;
258+ // Spy on the logger the code path actually uses rather than console, so the
259+ // assertion verifies the intended warn call without coupling to the
260+ // "[aimock]" prefix or the console transport.
261+ const warn = vi . spyOn ( Logger . prototype , "warn" ) . mockImplementation ( ( ) => { } ) ;
262+
263+ // 0 looks like "no delay" but hits the `speed > 0` guard in calculateDelay,
264+ // which would replay at full recorded speed.
265+ const { llmock } = await startFromConfig ( {
266+ llm : { fixtures : fixturePath , replaySpeed : 0 } ,
267+ } ) ;
268+ cleanups . push ( ( ) => llmock . stop ( ) ) ;
269+
270+ expect ( warn ) . toHaveBeenCalledWith (
271+ expect . stringContaining ( "llm.replaySpeed must be a positive number" ) ,
272+ ) ;
273+ warn . mockRestore ( ) ;
274+ } ) ;
275+
276+ it ( "with llm.logLevel debug, the server logs debug output that silent suppresses" , async ( ) => {
277+ const fixturePath = writeFixtureFile ( tmpDir ) ;
278+ // The server logger writes debug/info lines via console.log; capture them so
279+ // we can assert the configured level actually reaches the running server.
280+ const log = vi . spyOn ( console , "log" ) . mockImplementation ( ( ) => { } ) ;
281+
282+ // A matched request emits a "Fixture matched" debug line, but only when the
283+ // configured logLevel is high enough — so it is a direct probe of the passthrough.
284+ async function debugLineCount ( logLevel : "debug" | "silent" ) : Promise < number > {
285+ log . mockClear ( ) ;
286+ const { llmock, url } = await startFromConfig ( {
287+ llm : { fixtures : fixturePath , logLevel } ,
288+ } ) ;
289+ cleanups . push ( ( ) => llmock . stop ( ) ) ;
290+ await fetch ( `${ url } /v1/chat/completions` , {
291+ method : "POST" ,
292+ headers : { "Content-Type" : "application/json" } ,
293+ body : JSON . stringify ( { model : "gpt-4" , messages : [ { role : "user" , content : "hello" } ] } ) ,
294+ } ) ;
295+ return log . mock . calls . filter ( ( args ) => String ( args [ 1 ] ) . startsWith ( "Fixture matched" ) ) . length ;
296+ }
297+
298+ expect ( await debugLineCount ( "debug" ) ) . toBeGreaterThan ( 0 ) ;
299+ expect ( await debugLineCount ( "silent" ) ) . toBe ( 0 ) ;
300+
301+ log . mockRestore ( ) ;
302+ } ) ;
303+
159304 it ( "with mcp tools config, MCPMock created and tools/list works" , async ( ) => {
160305 const config : AimockConfig = {
161306 mcp : {
@@ -649,11 +794,8 @@ describe("startFromConfig", () => {
649794 {
650795 pattern : "stream" ,
651796 events : [
652- {
653- kind : "status-update" ,
654- taskId : "t1" ,
655- status : { state : "working" , message : { parts : [ { text : "streaming..." } ] } } ,
656- } ,
797+ { type : "status" , state : "TASK_STATE_WORKING" } ,
798+ { type : "artifact" , parts : [ { text : "streaming..." } ] , name : "out" } ,
657799 ] ,
658800 delayMs : 0 ,
659801 } ,
@@ -670,6 +812,28 @@ describe("startFromConfig", () => {
670812 expect ( cardRes . status ) . toBe ( 200 ) ;
671813 const card = await cardRes . json ( ) ;
672814 expect ( card . name ) . toBe ( "stream-agent" ) ;
815+
816+ // Verify the streaming task actually streams the configured events over SSE
817+ const streamRes = await fetch ( `${ url } /a2a` , {
818+ method : "POST" ,
819+ headers : { "Content-Type" : "application/json" } ,
820+ body : JSON . stringify ( {
821+ jsonrpc : "2.0" ,
822+ method : "SendStreamingMessage" ,
823+ params : { message : { parts : [ { text : "please stream" } ] } } ,
824+ id : 1 ,
825+ } ) ,
826+ } ) ;
827+ expect ( streamRes . status ) . toBe ( 200 ) ;
828+ expect ( streamRes . headers . get ( "content-type" ) ) . toBe ( "text/event-stream" ) ;
829+ const streamBody = await streamRes . text ( ) ;
830+ const events = streamBody
831+ . split ( "\n\n" )
832+ . filter ( ( frame ) => frame . startsWith ( "data: " ) )
833+ . map ( ( frame ) => JSON . parse ( frame . slice ( "data: " . length ) ) ) ;
834+ expect ( events . length ) . toBe ( 2 ) ;
835+ expect ( events [ 0 ] . result . task . status . state ) . toBe ( "TASK_STATE_WORKING" ) ;
836+ expect ( events [ 1 ] . result . artifact . parts [ 0 ] . text ) . toBe ( "streaming..." ) ;
673837 } ) ;
674838
675839 it ( "with a2a custom path, mounts at specified path for tasks" , async ( ) => {
0 commit comments