feat: productionize learning engine and classroom
This commit is contained in:
213
OpenMAIC/tests/lib/makelore-runtime/browser-bridge.test.ts
Normal file
213
OpenMAIC/tests/lib/makelore-runtime/browser-bridge.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
74
OpenMAIC/tests/lib/makelore-runtime/health.test.ts
Normal file
74
OpenMAIC/tests/lib/makelore-runtime/health.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const DIRECTORY_ENV = [
|
||||
'CLASSROOM_DATA_DIR',
|
||||
'CLASSROOM_JOBS_DIR',
|
||||
'COURSE_FRAMEWORK_DIR',
|
||||
'COURSE_MANIFEST_DIR',
|
||||
'COURSEWARE_DATA_DIR',
|
||||
'COURSEWARE_BUNDLE_DIR',
|
||||
'LEARNING_COURSE_PACKAGE_DIR',
|
||||
'LEARNING_COURSE_STORE_DIR',
|
||||
] as const;
|
||||
|
||||
const TTS_ENV = [
|
||||
'TTS_OPENAI_API_KEY',
|
||||
'TTS_AZURE_API_KEY',
|
||||
'TTS_GLM_API_KEY',
|
||||
'TTS_QWEN_API_KEY',
|
||||
'TTS_VOXCPM_API_KEY',
|
||||
'TTS_DOUBAO_API_KEY',
|
||||
'TTS_ELEVENLABS_API_KEY',
|
||||
'TTS_MINIMAX_API_KEY',
|
||||
'TTS_LEMONADE_BASE_URL',
|
||||
] as const;
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function configureEngine() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'openmaic-learning-health-'));
|
||||
temporaryRoots.push(root);
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server');
|
||||
vi.stubEnv('LEARNING_ENGINE_TOKEN', 'test-runtime-token');
|
||||
vi.stubEnv('OPENAI_API_KEY', 'test-llm-key');
|
||||
DIRECTORY_ENV.forEach((name) => vi.stubEnv(name, path.join(root, name.toLowerCase())));
|
||||
TTS_ENV.forEach((name) => vi.stubEnv(name, ''));
|
||||
}
|
||||
|
||||
describe('Learning Engine readiness', () => {
|
||||
it('fails closed when default narration has no managed TTS provider', async () => {
|
||||
await configureEngine();
|
||||
const { inspectLearningReadiness } = await import('@/lib/makelore-runtime/health');
|
||||
|
||||
const readiness = await inspectLearningReadiness();
|
||||
|
||||
expect(readiness.ready).toBe(false);
|
||||
expect(readiness.checks.llmProvider).toEqual({ ok: true });
|
||||
expect(readiness.checks.ttsProvider).toEqual({
|
||||
ok: false,
|
||||
detail: 'No enabled server-managed TTS provider is configured',
|
||||
});
|
||||
});
|
||||
|
||||
it('becomes ready when both LLM and TTS providers are managed', async () => {
|
||||
await configureEngine();
|
||||
vi.stubEnv('TTS_OPENAI_API_KEY', 'test-tts-key');
|
||||
vi.resetModules();
|
||||
const { inspectLearningReadiness } = await import('@/lib/makelore-runtime/health');
|
||||
|
||||
const readiness = await inspectLearningReadiness();
|
||||
|
||||
expect(readiness.ready).toBe(true);
|
||||
expect(readiness.checks.ttsProvider).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
33
OpenMAIC/tests/lib/makelore-runtime/progress-events.test.ts
Normal file
33
OpenMAIC/tests/lib/makelore-runtime/progress-events.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
emitMakelorePlaybackProgress,
|
||||
MAKELORE_PLAYBACK_PROGRESS_EVENT,
|
||||
} from '@/lib/makelore-runtime/progress-events';
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__MAKELORE_OFFLINE_PLAYER__;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe('offline player playback progress events', () => {
|
||||
it('publishes action-level and completion state only in the offline player', () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, listener);
|
||||
const detail = {
|
||||
sceneId: 'scene-2',
|
||||
sceneOrder: 2,
|
||||
actionIndex: 4,
|
||||
completed: true,
|
||||
};
|
||||
|
||||
emitMakelorePlaybackProgress(detail);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
window.__MAKELORE_OFFLINE_PLAYER__ = true;
|
||||
emitMakelorePlaybackProgress(detail);
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect((listener.mock.calls[0]![0] as CustomEvent).detail).toEqual(detail);
|
||||
|
||||
window.removeEventListener(MAKELORE_PLAYBACK_PROGRESS_EVENT, listener);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user