// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchMakeloreLearningAgent, installMakeloreRuntimeFetchBridge, transcribeMakeloreLearningAudio, } from '@/lib/makelore-runtime/browser-bridge'; function courseContext() { return { courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId: 'course-1_m1', moduleContentHash: 'b'.repeat(64), }; } function bridgeEvent(data: Record) { window.dispatchEvent(new MessageEvent('message', { source: window.parent, data })); } describe('Makelore browser Agent bridge', () => { it('sends only the exact course context, learner turns and current scene to the parent', async () => { window.__MAKELORE_COURSE_CONTEXT__ = { courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId: null, moduleContentHash: 'a'.repeat(64), }; const post = vi.spyOn(window.parent, 'postMessage'); const responsePromise = fetchMakeloreLearningAgent({ messages: [ { role: 'assistant', parts: [{ type: 'text', text: '欢迎' }] }, { role: 'user', parts: [{ type: 'text', text: '这一页是什么意思?' }] }, ], storeState: { currentSceneId: 'scene-2', scenes: [{ id: 'scene-2', order: 2, title: '变量' }], }, }, new AbortController().signal); const sent = post.mock.calls[0][0] as { requestId: string; request: Record }; expect(sent.request).toMatchObject({ courseId: 'course-1', contentHash: 'a'.repeat(64), message: '这一页是什么意思?', anchor: { sceneId: 'scene-2', sceneOrder: 2, sceneTitle: '变量' }, }); window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'makelore:agent:response', requestId: sent.requestId, ok: true, text: '变量用于保存数据。' }, })); const response = await responsePromise; const body = await response.text(); expect(body).toContain('agent_start'); expect(body).toContain('变量用于保存数据。'); expect(body).toContain('cue_user'); post.mockRestore(); }); it('moves recorded audio to the trusted parent without calling a local API', async () => { window.__MAKELORE_COURSE_CONTEXT__ = { courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId: null, moduleContentHash: 'a'.repeat(64), }; const post = vi.spyOn(window.parent, 'postMessage'); const audio = { type: 'audio/webm', arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, } as Blob; const transcriptionPromise = transcribeMakeloreLearningAudio(audio, { language: 'zh-CN' }); await vi.waitFor(() => expect(post).toHaveBeenCalled()); const sent = post.mock.calls[0][0] as { requestId: string; audio: ArrayBuffer; language: string }; expect(new Uint8Array(sent.audio)).toEqual(new Uint8Array([1, 2, 3])); expect(sent.language).toBe('zh-CN'); window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'makelore:transcription:response', requestId: sent.requestId, ok: true, text: '什么是变量' }, })); await expect(transcriptionPromise).resolves.toBe('什么是变量'); post.mockRestore(); }); }); describe('Makelore scoped runtime fetch bridge', () => { const originalFetch = window.fetch; afterEach(() => { window.fetch = originalFetch; delete window.__MAKELORE_OFFLINE_PLAYER__; delete window.__MAKELORE_COURSE_CONTEXT__; vi.restoreAllMocks(); }); function install(timeoutMs = 1_000) { window.__MAKELORE_OFFLINE_PLAYER__ = true; window.__MAKELORE_COURSE_CONTEXT__ = courseContext(); const native = vi.fn(async () => new Response('native')); window.fetch = native as typeof window.fetch; const uninstall = installMakeloreRuntimeFetchBridge({ timeoutMs, getAnchor: () => ({ sceneId: 'learning_course-1_course-1_m1_s2', sceneOrder: 3 }), }); return { native, uninstall }; } it('intercepts only exact allowlisted relative POSTs and reconstructs a streamed Response', async () => { const { native, uninstall } = install(); const post = vi.spyOn(window.parent, 'postMessage'); const responsePromise = window.fetch('/api/pbl/v2/instructor', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': 'must-not-cross' }, body: JSON.stringify({ project: { id: 'p1' }, userMessage: '请继续' }), }); const sent = post.mock.calls[0]![0] as { requestId: string; capability: string; body: unknown; context: Record; }; expect(sent).toMatchObject({ capability: 'pbl/v2/instructor', body: { project: { id: 'p1' }, userMessage: '请继续' }, context: { courseId: 'course-1', courseContentHash: 'a'.repeat(64), moduleId: 'course-1_m1', moduleContentHash: 'b'.repeat(64), anchor: { sceneId: 'learning_course-1_course-1_m1_s2', sceneOrder: 3 }, }, }); expect(JSON.stringify(sent)).not.toContain('must-not-cross'); bridgeEvent({ type: 'makelore:runtime:start', requestId: sent.requestId, status: 200, contentType: 'text/event-stream', }); const response = await responsePromise; bridgeEvent({ type: 'makelore:runtime:chunk', requestId: sent.requestId, chunk: new TextEncoder().encode('data: one\n\n'), }); bridgeEvent({ type: 'makelore:runtime:chunk', requestId: sent.requestId, chunk: new TextEncoder().encode('data: two\n\n'), }); bridgeEvent({ type: 'makelore:runtime:end', requestId: sent.requestId }); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toBe('text/event-stream'); await expect(response.text()).resolves.toBe('data: one\n\ndata: two\n\n'); await window.fetch('/api/pbl/v2/instructor?debug=1', { method: 'POST' }); await window.fetch('/api/pbl/v2/instructor', { method: 'GET' }); await window.fetch('/api/parse-pdf', { method: 'POST' }); expect(native).toHaveBeenCalledTimes(3); uninstall(); }); it('isolates concurrent request ids and aborts one request without affecting another', async () => { const { uninstall } = install(); const post = vi.spyOn(window.parent, 'postMessage'); const firstAbort = new AbortController(); const first = window.fetch('/api/quiz-grade', { method: 'POST', signal: firstAbort.signal, body: JSON.stringify({ question: 'q1' }), }); const second = window.fetch('/api/quiz-grade', { method: 'POST', body: JSON.stringify({ question: 'q2' }), }); const firstId = (post.mock.calls[0]![0] as { requestId: string }).requestId; const secondId = (post.mock.calls[1]![0] as { requestId: string }).requestId; expect(firstId).not.toBe(secondId); firstAbort.abort(); await expect(first).rejects.toMatchObject({ name: 'AbortError' }); bridgeEvent({ type: 'makelore:runtime:start', requestId: secondId, status: 201, contentType: 'application/json' }); const response = await second; bridgeEvent({ type: 'makelore:runtime:chunk', requestId: firstId, chunk: new Uint8Array([9]) }); bridgeEvent({ type: 'makelore:runtime:chunk', requestId: secondId, chunk: new TextEncoder().encode('{"ok":true}') }); bridgeEvent({ type: 'makelore:runtime:end', requestId: secondId }); expect(response.status).toBe(201); await expect(response.json()).resolves.toEqual({ ok: true }); uninstall(); }); it('times out pending requests, and uninstall restores native fetch', async () => { const { native, uninstall } = install(5); const pending = window.fetch('/api/quiz-grade', { method: 'POST', body: JSON.stringify({ question: 'q' }), }); await expect(pending).rejects.toThrow('超时'); uninstall(); uninstall(); await window.fetch('/api/quiz-grade', { method: 'POST' }); expect(native).toHaveBeenCalledOnce(); }); it('does not install outside the offline player', async () => { const native = vi.fn(async () => new Response('native')); window.fetch = native as typeof window.fetch; const uninstall = installMakeloreRuntimeFetchBridge({ getAnchor: () => null }); await window.fetch('/api/quiz-grade', { method: 'POST' }); expect(native).toHaveBeenCalledOnce(); uninstall(); }); });