Files
openmaic/OpenMAIC/tests/lib/makelore-runtime/browser-bridge.test.ts

214 lines
8.4 KiB
TypeScript

// @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<string, unknown>) {
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<string, unknown> };
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<string, unknown>;
};
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();
});
});