feat: productionize learning engine and classroom

This commit is contained in:
inman
2026-08-16 21:44:52 +08:00
parent 2d04197f3f
commit ba35adfbfa
124 changed files with 9071 additions and 796 deletions

View File

@@ -0,0 +1,266 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CourseRecord } from '@/lib/course-framework/types';
import type { Scene, Stage } from '@/lib/types/stage';
const teacher = {
id: 'teacher-1',
name: '麦洛老师',
role: 'teacher',
persona: '耐心讲解',
avatar: '/avatars/teacher.png',
color: '#3b82f6',
priority: 1,
};
function classroom(classroomId: string, title: string, text: string) {
const stage = {
id: classroomId,
name: title,
createdAt: 1,
updatedAt: 1,
generatedAgentConfigs: [teacher],
} as Stage;
const scenes = [
{
id: `${classroomId}-scene`,
stageId: classroomId,
title,
order: 1,
type: 'slide',
content: {
type: 'slide',
canvas: {
id: `${classroomId}-canvas`,
viewportSize: 100,
viewportRatio: 1.6,
theme: { color: '#fff', backgroundColor: '#000', fontName: 'sans-serif' },
elements: [
{
id: `${classroomId}-text`,
type: 'text',
content: `<p>${text}</p>`,
left: 0,
top: 0,
width: 100,
height: 20,
rotate: 0,
defaultFontName: 'sans-serif',
defaultColor: '#000',
},
],
},
},
actions: [
{
id: `${classroomId}-speech`,
type: 'speech',
text,
audioId: `${classroomId}-missing-audio`,
},
],
createdAt: 1,
updatedAt: 1,
},
] as unknown as Scene[];
return { id: classroomId, stage, scenes };
}
function largeCourseRecord(): CourseRecord {
return {
id: 'large-1',
status: 'completed',
requirement: '学习 Python',
enableTTS: false,
interactiveMode: false,
framework: {
courseTitle: 'Python 系统课',
languageDirective: 'zh-CN',
targetAudience: '零基础学习者',
summary: '从变量到循环',
courseGoals: [],
continuityContract: {
terminology: [],
teachingStyle: '渐进',
difficultyProgression: '由浅入深',
assessmentStrategy: '练习',
},
modules: [],
},
modules: [
{
index: 1,
title: '变量',
description: '变量基础',
status: 'succeeded',
classroomId: 'classroom-1',
},
{
index: 2,
title: '循环',
description: '循环基础',
status: 'succeeded',
classroomId: 'classroom-2',
},
],
createdAt: '2026-08-16T00:00:00.000Z',
updatedAt: '2026-08-16T00:00:00.000Z',
} as CourseRecord;
}
describe.sequential('expanded aggregate learning package', () => {
let root = '';
const env = {
data: process.env.LEARNING_DATA_DIR,
classrooms: process.env.CLASSROOM_DATA_DIR,
packages: process.env.LEARNING_COURSE_PACKAGE_DIR,
courses: process.env.LEARNING_COURSE_STORE_DIR,
};
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'learning-aggregate-'));
process.env.LEARNING_DATA_DIR = root;
process.env.CLASSROOM_DATA_DIR = join(root, 'classrooms');
process.env.LEARNING_COURSE_PACKAGE_DIR = join(root, 'packages');
process.env.LEARNING_COURSE_STORE_DIR = join(root, 'courses');
vi.resetModules();
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
for (const [name, value] of Object.entries(env)) {
const key = {
data: 'LEARNING_DATA_DIR',
classrooms: 'CLASSROOM_DATA_DIR',
packages: 'LEARNING_COURSE_PACKAGE_DIR',
courses: 'LEARNING_COURSE_STORE_DIR',
}[name]!;
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
vi.resetModules();
});
it('writes course.json plus expanded stable module roots and resolves module knowledge', async () => {
const { persistClassroom } = await import('@/lib/server/classroom-storage');
await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine');
await persistClassroom(
classroom('classroom-2', '模块二', '循环用于重复执行代码'),
'http://engine',
);
const course = largeCourseRecord();
const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize');
const frozen = await finalizeLargeLearningCourse({
jobId: 'large-1',
course,
baseUrl: 'http://engine',
});
const { readUniqueMakeloreCoursePackage } = await import('@/lib/makelore-course/package');
const bytes = await readUniqueMakeloreCoursePackage('large-1');
expect(bytes).not.toBeNull();
const zip = await JSZip.loadAsync(bytes!);
expect(zip.file('course.json')).not.toBeNull();
expect(zip.file('aggregate.json')).toBeNull();
expect(Object.keys(zip.files).some((name) => name.endsWith('/courseware.zip'))).toBe(false);
for (const moduleId of ['large-1_m1', 'large-1_m2']) {
expect(zip.file(`modules/${moduleId}/manifest.json`)).not.toBeNull();
expect(zip.file(`modules/${moduleId}/bundle.json`)).not.toBeNull();
expect(zip.file(`modules/${moduleId}/knowledge/knowledge.json`)).not.toBeNull();
expect(zip.file(`modules/${moduleId}/quiz/quiz.json`)).not.toBeNull();
}
const courseJson = JSON.parse(await zip.file('course.json')!.async('string')) as {
contentHash: string;
modules: Array<{ moduleId: string; path: string }>;
};
expect(courseJson.contentHash).toBe(frozen.contentHash);
expect(courseJson.modules).toEqual([
expect.objectContaining({ moduleId: 'large-1_m1', path: 'modules/large-1_m1/' }),
expect.objectContaining({ moduleId: 'large-1_m2', path: 'modules/large-1_m2/' }),
]);
const { loadMakeloreCourse, resolveMakeloreCourseModule, courseKnowledge } =
await import('@/lib/makelore-runtime/course-store');
const loaded = await loadMakeloreCourse('large-1', frozen.contentHash);
expect(loaded).toMatchObject({ mode: 'large', moduleCount: 2 });
const secondHash = (frozen.courseStructure!.modules as Array<{ contentHash: string }>)[1]!
.contentHash;
const selected = await resolveMakeloreCourseModule(loaded!, {
moduleId: 'large-1_m2',
moduleContentHash: secondHash,
});
expect(selected).not.toBeNull();
expect(courseKnowledge(selected!).knowledge.scenes[0]!.text).toContain('循环用于重复执行代码');
});
it('reuses only the exact frozen source digest and never overwrites an immutable course id', async () => {
const { persistClassroom } = await import('@/lib/server/classroom-storage');
await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine');
await persistClassroom(
classroom('classroom-2', '模块二', '循环用于重复执行代码'),
'http://engine',
);
const course = largeCourseRecord();
const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize');
const first = await finalizeLargeLearningCourse({
jobId: 'large-1',
course,
baseUrl: 'http://engine',
});
expect(first.sourceDigest).toMatch(/^[0-9a-f]{64}$/);
const firstBytes = await readFile(join(root, 'packages', 'large-1.zip'));
const reused = await finalizeLargeLearningCourse({
jobId: 'large-1',
course,
baseUrl: 'http://engine',
});
expect(reused).toEqual(first);
await persistClassroom(
classroom('classroom-2', '模块二', '循环与条件分支共同控制程序流程'),
'http://engine',
);
await expect(finalizeLargeLearningCourse({
jobId: 'large-1',
course,
baseUrl: 'http://engine',
})).rejects.toThrow('已冻结');
await expect(readFile(join(root, 'packages', 'large-1.zip'))).resolves.toEqual(firstBytes);
await expect(
finalizeLargeLearningCourse({
jobId: 'large-1',
course: { ...course, status: 'generating' },
baseUrl: 'http://engine',
}),
).rejects.toThrow('Large course is not complete');
});
it('never treats a legacy frozen record without a source digest as rebuildable', async () => {
const { persistClassroom } = await import('@/lib/server/classroom-storage');
await persistClassroom(classroom('classroom-1', '模块一', '变量用于保存数据'), 'http://engine');
await persistClassroom(
classroom('classroom-2', '模块二', '循环用于重复执行代码'),
'http://engine',
);
const course = largeCourseRecord();
const { finalizeLargeLearningCourse } = await import('@/lib/makelore-course/finalize');
await finalizeLargeLearningCourse({ jobId: 'large-1', course, baseUrl: 'http://engine' });
const packagePath = join(root, 'packages', 'large-1.zip');
const recordPath = join(root, 'courses', 'large-1.json');
const frozenBytes = await readFile(packagePath);
const record = JSON.parse(await readFile(recordPath, 'utf8')) as Record<string, unknown>;
delete record.sourceDigest;
await writeFile(recordPath, JSON.stringify(record));
await expect(
finalizeLargeLearningCourse({ jobId: 'large-1', course, baseUrl: 'http://engine' }),
).rejects.toThrow('已冻结');
await expect(readFile(packagePath)).resolves.toEqual(frozenBytes);
});
});

View File

@@ -0,0 +1,54 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import JSZip from 'jszip';
import { afterEach, describe, expect, it } from 'vitest';
import {
packageUniqueMakeloreCourse,
persistUniqueMakeloreCourse,
readUniqueMakeloreCoursePackage,
} from '@/lib/makelore-course/package';
import { loadMakeloreCourse } from '@/lib/makelore-runtime/course-store';
import { validateCoursePackageDescriptor } from '../../../packages/@makelore/learning-contracts/src/index';
import type { Scene, Stage } from '@/lib/types/stage';
describe('unique Makelore course package', () => {
let root = '';
const previousPackageRoot = process.env.MAKELORE_COURSE_PACKAGE_DIR;
const previousStoreRoot = process.env.MAKELORE_COURSE_STORE_DIR;
afterEach(async () => {
if (root) await rm(root, { recursive: true, force: true });
if (previousPackageRoot === undefined) delete process.env.MAKELORE_COURSE_PACKAGE_DIR;
else process.env.MAKELORE_COURSE_PACKAGE_DIR = previousPackageRoot;
if (previousStoreRoot === undefined) delete process.env.MAKELORE_COURSE_STORE_DIR;
else process.env.MAKELORE_COURSE_STORE_DIR = previousStoreRoot;
});
it('freezes classroom, quiz and knowledge without a product version', async () => {
const stage = {
id: 'stage-1', name: '变量入门', createdAt: Date.now(), updatedAt: Date.now(),
generatedAgentConfigs: [{ id: 'teacher-1', name: 'AI Teacher', role: 'teacher', persona: '耐心讲解', avatar: '/avatars/teacher.png', color: '#3b82f6', priority: 10 }],
} as Stage;
const scenes = [{
id: 'scene-1', stageId: stage.id, title: '变量是什么', order: 1, type: 'interactive',
content: { type: 'interactive', html: '<!doctype html><html><head></head><body><button>试一试</button><script>document.querySelector("button").onclick=()=>document.body.dataset.clicked="1"</script></body></html>' },
createdAt: Date.now(), updatedAt: Date.now(),
}] as Scene[];
const course = await packageUniqueMakeloreCourse({ courseId: 'course-1', stage, scenes });
const zip = await JSZip.loadAsync(course.bytes);
const descriptor = JSON.parse(await zip.file('descriptor.json')!.async('string'));
expect(descriptor).toMatchObject({ courseId: 'course-1', formatVersion: 1, sceneCount: 1 });
expect(validateCoursePackageDescriptor(descriptor)).toMatchObject({ ok: true, errors: [] });
expect(descriptor).not.toHaveProperty('version');
expect(zip.file('classroom.json')).not.toBeNull();
expect(zip.file('knowledge/knowledge.json')).not.toBeNull();
root = await mkdtemp(join(tmpdir(), 'makelore-course-package-'));
process.env.MAKELORE_COURSE_PACKAGE_DIR = join(root, 'packages');
process.env.MAKELORE_COURSE_STORE_DIR = join(root, 'courses');
await persistUniqueMakeloreCourse('course-1', course);
await expect(readUniqueMakeloreCoursePackage('course-1')).resolves.toEqual(course.bytes);
await expect(loadMakeloreCourse('course-1', course.contentHash)).resolves.toMatchObject({ title: '变量入门' });
});
});

View 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();
});
});

View 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 });
});
});

View 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);
});
});

View File

@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installOpsBrowserBoundary } from '@/lib/ops/api-fetch';
const AUTH_STORAGE_KEY = 'works-square.operations.auth.v1';
describe('installOpsBrowserBoundary', () => {
let nativeFetch: ReturnType<typeof vi.fn>;
let cleanup: (() => void) | undefined;
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE', 'ops');
vi.stubEnv('NEXT_PUBLIC_OPENMAIC_BASE_PATH', '/learning-ops');
window.localStorage.clear();
nativeFetch = vi.fn(async () => new Response(null, { status: 204 }));
window.fetch = nativeFetch as unknown as typeof window.fetch;
});
afterEach(() => {
cleanup?.();
cleanup = undefined;
window.localStorage.clear();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('maps root-relative API requests to the Ops basePath and adds the Works bearer token', async () => {
window.localStorage.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({
accessToken: 'works-access-token',
expiresAt: Date.now() + 60_000,
}),
);
cleanup = installOpsBrowserBoundary();
await window.fetch('/api/server-providers', {
method: 'POST',
headers: { 'X-Request-Id': 'request-1' },
});
expect(nativeFetch).toHaveBeenCalledTimes(1);
const [input, init] = nativeFetch.mock.calls[0] as [RequestInfo | URL, RequestInit];
expect(input).toBe('/learning-ops/api/server-providers');
expect(init.method).toBe('POST');
const headers = new Headers(init.headers);
expect(headers.get('Authorization')).toBe('Bearer works-access-token');
expect(headers.get('X-Request-Id')).toBe('request-1');
});
it('leaves external URLs and unrelated root-relative paths unchanged', async () => {
cleanup = installOpsBrowserBoundary();
await window.fetch('https://example.test/api/courses');
await window.fetch('/courses/course-1', { method: 'GET' });
expect(nativeFetch).toHaveBeenNthCalledWith(1, 'https://example.test/api/courses', {});
expect(nativeFetch).toHaveBeenNthCalledWith(2, '/courses/course-1', { method: 'GET' });
});
it('restores unwrapped fetch behavior when cleaned up', async () => {
cleanup = installOpsBrowserBoundary();
const wrappedFetch = window.fetch;
cleanup();
cleanup = undefined;
expect(window.fetch).not.toBe(wrappedFetch);
await window.fetch('/api/server-providers');
expect(nativeFetch).toHaveBeenCalledTimes(1);
expect(nativeFetch.mock.calls[0]?.[0]).toBe('/api/server-providers');
});
it.each([
{
label: 'expired session',
value: JSON.stringify({ accessToken: 'expired-token', expiresAt: Date.now() - 1 }),
},
{ label: 'malformed session JSON', value: '{not-json' },
{ label: 'session without a string token', value: JSON.stringify({ accessToken: 123 }) },
])('does not add Authorization for a $label', async ({ value }) => {
window.localStorage.setItem(AUTH_STORAGE_KEY, value);
cleanup = installOpsBrowserBoundary();
await window.fetch('/api/server-providers');
const [, init] = nativeFetch.mock.calls[0] as [RequestInfo | URL, RequestInit];
expect(new Headers(init.headers).has('Authorization')).toBe(false);
});
});

View File

@@ -56,6 +56,14 @@ describe('patchHtmlForIframe', () => {
expect(out.indexOf('</head>')).toBeLessThan(out.indexOf('<div>'));
});
it('blocks network access for interactive HTML in the Makelore offline player', () => {
const out = patchHtmlForIframe('<html><head></head><body></body></html>', { offline: true });
expect(out).toContain('data-makelore-offline-csp');
expect(out).toContain("connect-src 'none'");
expect(out).toContain("frame-src 'none'");
expect(out.indexOf('data-makelore-offline-csp')).toBeLessThan(out.indexOf('<body>'));
});
it('does not treat head-like text in comments or scripts as the document head', () => {
const authoredScript = 'const template = "<head>"; window.__template = template;';
const html = `<!doctype html><!-- <head> --><html><body><script>${authoredScript}</script></body></html>`;