// @vitest-environment node import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AdmZip from 'adm-zip'; import { createLearningCourseLibrary } from '@electron/services/learning-course-library'; import { createLearningPlayerServer } from '@electron/services/learning-player-server'; describe('Learning course library', () => { let root: string | null = null; const fetchImpl = vi.fn(); const getAccessToken = vi.fn(); beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'makelore-learning-library-')); fetchImpl.mockReset(); getAccessToken.mockReset(); getAccessToken.mockResolvedValue('works-token'); }); afterEach(async () => { if (root) await rm(root, { recursive: true, force: true }); root = null; }); function course(bytes: Uint8Array, overrides: Record = {}) { return { id: 'course-1', origin: 'ops_large', status: 'published', title: 'Python 基础入门', summary: null, language: 'zh-CN', contentHash: 'a'.repeat(64), archiveSha256: createHash('sha256').update(bytes).digest('hex'), archiveBytes: bytes.byteLength, formatVersion: 1, minPlayerVersion: '1.0.0', sceneCount: 10, capabilities: { sceneKinds: ['slide', 'interactive', 'quiz'], hasAudio: false, hasWhiteboard: true, hasAgent: true }, createdAt: '2026-08-16T00:00:00.000Z', publishedAt: '2026-08-16T00:00:00.000Z', ...overrides, }; } it('streams and installs a package only after hash and size verification', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]); const metadata = course(archive); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200, headers: { 'Content-Type': 'application/zip' } })); const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example', now: () => new Date('2026-08-16T08:00:00.000Z'), }); const installed = await library.download('course-1'); expect(new Uint8Array(await readFile(installed.archivePath))).toEqual(archive); expect(installed).toMatchObject({ schemaVersion: 1, installedAt: '2026-08-16T08:00:00.000Z' }); expect(installed.course).not.toHaveProperty('version'); await expect(library.listInstalled()).resolves.toEqual([installed]); expect(fetchImpl).toHaveBeenNthCalledWith(2, 'https://square.example/api/learning/courses/course-1/download', expect.objectContaining({ headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' }, })); }); it('removes partial files when the archive digest does not match', async () => { const expected = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1]); const tampered = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 2]); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(expected)), { status: 200 })) .mockResolvedValueOnce(new Response(tampered, { status: 200 })); const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); await expect(library.download('course-1')).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' }); await expect(library.listInstalled()).resolves.toEqual([]); }); it('does not send the Works Square token to an object-storage redirect', async () => { const archive = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 9]); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(null, { status: 307, headers: { Location: 'https://objects.example/course.zip' } })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); await library.download('course-1'); expect(fetchImpl).toHaveBeenNthCalledWith(3, new URL('https://objects.example/course.zip'), { headers: { Accept: 'application/zip' }, redirect: 'follow', }); }); it('reads the frozen classroom payload from an installed package', async () => { const zip = new AdmZip(); zip.addFile('classroom.json', Buffer.from(JSON.stringify({ stage: { id: 'stage-local', name: 'Python 基础入门' }, scenes: Array.from({ length: 10 }, (_, index) => ({ id: `scene-${index}`, order: index, type: 'slide' })), }))); const archive = zip.toBuffer(); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(course(archive)), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const installed = await library.download('course-1'); expect(await readFile(installed.archivePath)).toEqual(archive); expect(JSON.parse(new AdmZip(installed.archivePath).readAsText('classroom.json')).stage.id).toBe('stage-local'); await expect(library.readClassroom('course-1')).resolves.toMatchObject({ courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId: null, moduleContentHash: 'a'.repeat(64), modules: [{ moduleId: 'main', title: 'Python 基础入门', sceneCount: 10 }], classroom: { stage: { id: 'stage-local' }, scenes: expect.any(Array) }, }); }); it('accepts large-course metadata and expands a selected frozen module for the production Stage', async () => { const moduleContentHash = 'b'.repeat(64); const moduleId = 'python-intro'; const moduleRoot = `modules/${moduleId}/`; const zip = new AdmZip(); zip.addFile('course.json', Buffer.from(JSON.stringify({ schemaVersion: 1, modules: [{ moduleId, title: 'Python 入门模块', summary: '变量、表达式与第一次练习', sceneCount: 1, contentHash: moduleContentHash, path: `modules/${moduleId}`, }], }))); zip.addFile(`${moduleRoot}manifest.json`, Buffer.from(JSON.stringify({ formatVersion: 1, exportedAt: '2026-08-16T00:00:00.000Z', appVersion: '1.0.0', stage: { name: 'Python 入门模块', language: 'zh-CN', interactiveMode: true, createdAt: 1, updatedAt: 2, }, agents: [{ name: '麦洛老师', role: 'teacher', persona: '循序渐进地讲解', avatar: '/avatars/teacher.png', color: '#3b82f6', priority: 0, }], scenes: [{ type: 'slide', title: '变量是什么', order: 0, content: { type: 'slide', imageId: 'diagram' }, actions: [{ type: 'speech', audioRef: 'audio/line.mp3', text: '变量用于保存数据。' }], }], mediaIndex: { 'audio/line.mp3': { type: 'audio', mimeType: 'audio/mpeg' }, 'media/diagram.png': { type: 'generated', mimeType: 'image/png' }, }, }))); zip.addFile(`${moduleRoot}bundle.json`, Buffer.from(JSON.stringify({ meta: { coursewareId: moduleId, version: 1, stageName: 'Python 入门模块', sceneCount: 1, contentHash: moduleContentHash, }, completeness: { complete: true }, }))); zip.addFile(`${moduleRoot}quiz/quiz.json`, Buffer.from(JSON.stringify({ quizzes: [] }))); zip.addFile(`${moduleRoot}knowledge/knowledge.json`, Buffer.from(JSON.stringify({ documents: [] }))); zip.addFile(`${moduleRoot}audio/line.mp3`, Buffer.from('audio-bytes')); zip.addFile(`${moduleRoot}media/diagram.png`, Buffer.from('image-bytes')); const archive = zip.toBuffer(); const metadata = course(archive, { sceneCount: 1, capabilities: { modular: true, orderedModules: true, productionStagePerModule: true }, modules: [{ moduleId, title: 'Python 入门模块', summary: '变量、表达式与第一次练习', sceneCount: 1, contentHash: moduleContentHash, }], }); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify(metadata), { status: 200 })) .mockResolvedValueOnce(new Response(archive, { status: 200 })); const library = createLearningCourseLibrary({ rootDirectory: root!, fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example', }); await library.download('course-1'); const payload = await library.readClassroom('course-1', moduleId); expect(payload).toMatchObject({ courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId, moduleContentHash, modules: [{ moduleId, title: 'Python 入门模块', sceneCount: 1, contentHash: moduleContentHash }], classroom: { stage: { id: `learning_course-1_${moduleId}`, name: 'Python 入门模块', interactiveMode: true, }, scenes: [{ title: '变量是什么', order: 0 }], }, }); expect(payload.classroom.scenes[0]).toMatchObject({ content: { imageId: `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}media/diagram.png`, }, actions: [{ type: 'speech', audioUrl: `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}audio/line.mp3`, }], }); // The same verified aggregate package registered by the consumer must be // able to serve the rewritten immutable media URL to the production Stage. const artifactRoot = join(root!, 'player-artifact'); const html = '
player
'; await mkdir(join(artifactRoot, '_next', 'static'), { recursive: true }); await mkdir(join(artifactRoot, 'avatars'), { recursive: true }); await mkdir(join(artifactRoot, 'public'), { recursive: true }); await writeFile(join(artifactRoot, 'index.html'), html); await writeFile(join(artifactRoot, 'artifact.json'), JSON.stringify({ schemaVersion: 1, entrypoint: 'index.html', htmlSha256: createHash('sha256').update(html).digest('hex'), })); await writeFile(join(artifactRoot, '_next', 'static', 'app.js'), 'window.player = true'); await writeFile(join(artifactRoot, 'avatars', 'teacher.png'), 'avatar'); await writeFile(join(artifactRoot, 'public', 'openmaic-mark.png'), 'mark'); const player = await createLearningPlayerServer({ artifactRoot }); try { const response = await fetch(new URL( `/course-assets/course-1/${'a'.repeat(64)}/${moduleRoot}audio/line.mp3`, player.url, )); expect(response.status).toBe(200); expect(await response.text()).toBe('audio-bytes'); } finally { await player.close(); } }); });