import { createElement } from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sanitizeLearningRuntimeAnchor } from '@/lib/learning-runtime-anchor'; import { matchesLearningCloudProgress } from '@/pages/Learning/CoursePlayer'; import { LearningCoursePlayer } from '@/pages/Learning/CoursePlayer'; import { useAuthStore } from '@/stores/auth'; import type { LearningClassroomPayload } from '../../shared/learning'; const getLearningPlayerUrlMock = vi.hoisted(() => vi.fn()); const readLearningClassroomMock = vi.hoisted(() => vi.fn()); const fetchLearningProgressMock = vi.hoisted(() => vi.fn()); const saveLearningProgressMock = vi.hoisted(() => vi.fn()); const askLearningAgentMock = vi.hoisted(() => vi.fn()); const transcribeLearningSpeechMock = vi.hoisted(() => vi.fn()); const streamLearningRuntimeMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/learning', () => ({ getLearningPlayerUrl: getLearningPlayerUrlMock, readLearningClassroom: readLearningClassroomMock, fetchLearningProgress: fetchLearningProgressMock, saveLearningProgress: saveLearningProgressMock, askLearningAgent: askLearningAgentMock, transcribeLearningSpeech: transcribeLearningSpeechMock, streamLearningRuntime: streamLearningRuntimeMock, })); function classroomPayload(moduleId = 'module-a'): LearningClassroomPayload { const modules = ['module-a', 'module-b', 'module-c'].map((id, index) => ({ moduleId: id, title: `Module ${String.fromCharCode(65 + index)}`, summary: null, sceneCount: 1, contentHash: `${index + 1}`.repeat(64), })); const selectedModule = modules.find((module) => module.moduleId === moduleId)!; return { courseId: 'course-1', courseContentHash: 'a'.repeat(64), contentHash: 'a'.repeat(64), moduleId, moduleContentHash: selectedModule.contentHash, modules, classroom: { stage: { title: selectedModule.title }, scenes: [] }, }; } function renderPlayer() { return render(createElement( MemoryRouter, { initialEntries: ['/learning/course/course-1'] }, createElement( Routes, null, createElement(Route, { path: '/learning/course/:courseId', element: createElement(LearningCoursePlayer), }), createElement(Route, { path: '/learning', element: createElement('div', { 'data-testid': 'learning-library' }), }), ), )); } describe('Learning CoursePlayer runtime boundary', () => { beforeEach(() => { getLearningPlayerUrlMock.mockReset(); readLearningClassroomMock.mockReset(); fetchLearningProgressMock.mockReset(); saveLearningProgressMock.mockReset(); askLearningAgentMock.mockReset(); transcribeLearningSpeechMock.mockReset(); streamLearningRuntimeMock.mockReset(); window.localStorage.clear(); useAuthStore.setState({ user: { username: 'account-a', userId: 'account-a', tenantId: null, deptId: null, authorities: [], }, }); getLearningPlayerUrlMock.mockResolvedValue('http://127.0.0.1:43123/player/'); readLearningClassroomMock.mockResolvedValue(classroomPayload()); fetchLearningProgressMock.mockResolvedValue([]); saveLearningProgressMock.mockResolvedValue({}); }); it('copies only sceneId and sceneOrder from the untrusted iframe anchor', () => { expect(sanitizeLearningRuntimeAnchor({ sceneId: 'scene-3', sceneOrder: 3, courseId: 'iframe-course', contentHash: 'iframe-hash', moduleId: 'iframe-module', moduleContentHash: 'iframe-module-hash', actionIndex: 8, })).toEqual({ sceneId: 'scene-3', sceneOrder: 3 }); }); it('drops invalid or empty anchors instead of forwarding arbitrary context', () => { expect(sanitizeLearningRuntimeAnchor({ sceneId: '', sceneOrder: -1, arbitrary: true })).toBeUndefined(); expect(sanitizeLearningRuntimeAnchor(['scene-1'])).toBeUndefined(); }); it('restores single-course cloud progress stored under the server main module', () => { expect(matchesLearningCloudProgress( { courseId: 'course-1', contentHash: 'a'.repeat(64), moduleId: 'main' }, 'course-1', { courseContentHash: 'a'.repeat(64), moduleId: null, modules: [{}] }, )).toBe(true); expect(matchesLearningCloudProgress( { courseId: 'course-1', contentHash: 'a'.repeat(64), moduleId: 'other' }, 'course-1', { courseContentHash: 'a'.repeat(64), moduleId: null, modules: [{}] }, )).toBe(false); }); it('loads the iframe player and sends only the verified classroom payload after its ready bridge event', async () => { renderPlayer(); const iframe = await screen.findByTitle('Makelore 互动课程'); expect(iframe).toHaveAttribute('src', 'http://127.0.0.1:43123/player/'); expect(readLearningClassroomMock).toHaveBeenCalledWith('course-1'); const playerWindow = (iframe as HTMLIFrameElement).contentWindow!; const postMessage = vi.spyOn(playerWindow, 'postMessage'); fireEvent.load(iframe); act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindow, origin: 'http://attacker.invalid', data: { type: 'makelore:player:ready' }, })); }); expect(postMessage).not.toHaveBeenCalled(); act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindow, origin: 'http://127.0.0.1:43123', data: { type: 'makelore:player:ready' }, })); }); await waitFor(() => expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: 'makelore:course:load', courseId: 'course-1', moduleId: 'module-a', }), 'http://127.0.0.1:43123', )); expect(postMessage.mock.calls.every((call) => call[1] !== '*')).toBe(true); fireEvent.click(screen.getByRole('button', { name: '返回课程库' })); expect(await screen.findByTestId('learning-library')).toBeInTheDocument(); }); it('requires the current iframe source and exact origin before invoking a privileged bridge', async () => { askLearningAgentMock.mockResolvedValue({ text: 'answer' }); renderPlayer(); const iframe = await screen.findByTitle('Makelore 互动课程'); const playerWindow = (iframe as HTMLIFrameElement).contentWindow!; fireEvent.load(iframe); const request = { type: 'makelore:agent:request', requestId: 'request-1', request: { message: 'Help me' }, }; act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindow, origin: 'http://attacker.invalid', data: request, })); window.dispatchEvent(new MessageEvent('message', { source: window, origin: 'http://127.0.0.1:43123', data: request, })); }); expect(askLearningAgentMock).not.toHaveBeenCalled(); act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindow, origin: 'http://127.0.0.1:43123', data: request, })); }); await waitFor(() => expect(askLearningAgentMock).toHaveBeenCalledWith(expect.objectContaining({ courseId: 'course-1', contentHash: 'a'.repeat(64), message: 'Help me', }))); }); it('does not deliver an asynchronous reply after the iframe navigates', async () => { let resolveAgent!: (value: { text: string }) => void; askLearningAgentMock.mockReturnValue(new Promise((resolve) => { resolveAgent = resolve; })); renderPlayer(); const iframe = await screen.findByTitle('Makelore 互动课程'); const playerWindow = (iframe as HTMLIFrameElement).contentWindow!; const postMessage = vi.spyOn(playerWindow, 'postMessage'); fireEvent.load(iframe); act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindow, origin: 'http://127.0.0.1:43123', data: { type: 'makelore:agent:request', requestId: 'request-before-navigation', request: { message: 'Help me' }, }, })); }); await waitFor(() => expect(askLearningAgentMock).toHaveBeenCalledTimes(1)); fireEvent.load(iframe); expect(await screen.findByRole('alert')).toHaveTextContent('课程播放器已离开受信任页面'); await act(async () => { resolveAgent({ text: 'stale answer' }); }); expect(postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ type: 'makelore:agent:response' }), expect.anything(), ); }); it('permanently rejects every bridge capability after the iframe navigates', async () => { askLearningAgentMock.mockResolvedValue({ text: 'answer' }); transcribeLearningSpeechMock.mockResolvedValue({ text: 'transcript' }); renderPlayer(); const iframe = await screen.findByTitle('Makelore 互动课程'); const playerWindow = (iframe as HTMLIFrameElement).contentWindow!; const postMessage = vi.spyOn(playerWindow, 'postMessage'); fireEvent.load(iframe); act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindow, origin: 'http://127.0.0.1:43123', data: { type: 'makelore:player:ready' }, })); }); await waitFor(() => expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: 'makelore:course:load' }), 'http://127.0.0.1:43123', )); postMessage.mockClear(); fireEvent.load(iframe); expect(await screen.findByRole('alert')).toHaveTextContent('课程播放器已离开受信任页面'); act(() => { const dispatch = (data: unknown) => window.dispatchEvent(new MessageEvent('message', { source: playerWindow, origin: 'http://127.0.0.1:43123', data, })); dispatch({ type: 'makelore:player:ready' }); dispatch({ type: 'makelore:player:loaded' }); dispatch({ type: 'makelore:agent:request', requestId: 'agent-after-nav', request: { message: 'Help' } }); dispatch({ type: 'makelore:transcription:request', requestId: 'asr-after-nav', audio: new ArrayBuffer(4), }); dispatch({ type: 'makelore:runtime:request', requestId: 'runtime-after-nav', capability: 'quiz-grade', context: { anchor: { sceneId: 'scene-1' } }, body: {}, }); dispatch({ type: 'makelore:progress', sceneOrder: 2, completed: false }); }); await act(async () => Promise.resolve()); expect(askLearningAgentMock).not.toHaveBeenCalled(); expect(transcribeLearningSpeechMock).not.toHaveBeenCalled(); expect(streamLearningRuntimeMock).not.toHaveBeenCalled(); expect(saveLearningProgressMock).not.toHaveBeenCalled(); expect(postMessage).not.toHaveBeenCalled(); expect(window.localStorage.getItem( `makelore.learning.progress.account-a.course-1.${'1'.repeat(64)}`, )).toBeNull(); }); it('partitions local progress and reloads the complete player session when the account changes', async () => { getLearningPlayerUrlMock .mockResolvedValueOnce('http://127.0.0.1:43123/player-a/') .mockResolvedValue('http://127.0.0.1:43124/player-b/'); renderPlayer(); const iframeA = await screen.findByTitle('Makelore 互动课程'); const playerWindowA = (iframeA as HTMLIFrameElement).contentWindow!; fireEvent.load(iframeA); act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindowA, origin: 'http://127.0.0.1:43123', data: { type: 'makelore:progress', sceneOrder: 7, actionIndex: 3, completed: false }, })); }); await waitFor(() => expect(saveLearningProgressMock).toHaveBeenCalledTimes(1)); expect(window.localStorage.getItem( `makelore.learning.progress.account-a.course-1.${'1'.repeat(64)}`, )).toContain('"sceneOrder":7'); expect(window.localStorage.getItem( `makelore.learning.progress.account-b.course-1.${'1'.repeat(64)}`, )).toBeNull(); act(() => { useAuthStore.setState({ user: { username: 'account-b', userId: 'account-b', tenantId: null, deptId: null, authorities: [], }, }); }); await waitFor(() => expect(getLearningPlayerUrlMock).toHaveBeenCalledTimes(2)); await waitFor(() => expect(readLearningClassroomMock).toHaveBeenCalledTimes(2)); const iframeB = await waitFor(() => { const iframe = screen.getByTitle('Makelore 互动课程'); expect(iframe).toHaveAttribute('src', 'http://127.0.0.1:43124/player-b/'); return iframe; }); expect(iframeB).not.toBe(iframeA); const playerWindowB = (iframeB as HTMLIFrameElement).contentWindow!; const postMessageB = vi.spyOn(playerWindowB, 'postMessage'); fireEvent.load(iframeB); act(() => { window.dispatchEvent(new MessageEvent('message', { source: playerWindowB, origin: 'http://127.0.0.1:43124', data: { type: 'makelore:player:ready' }, })); window.dispatchEvent(new MessageEvent('message', { source: playerWindowA, origin: 'http://127.0.0.1:43123', data: { type: 'makelore:progress', sceneOrder: 9, completed: true }, })); }); await waitFor(() => expect(postMessageB).toHaveBeenCalledWith( expect.objectContaining({ type: 'makelore:course:load', progress: null }), 'http://127.0.0.1:43124', )); expect(saveLearningProgressMock).toHaveBeenCalledTimes(1); expect(getLearningPlayerUrlMock).toHaveBeenCalledTimes(2); expect(readLearningClassroomMock).toHaveBeenCalledTimes(2); expect(fetchLearningProgressMock).toHaveBeenCalledTimes(2); }); it('ignores a stale module response that finishes after the latest selection', async () => { let resolveModuleB!: (payload: LearningClassroomPayload) => void; let resolveModuleC!: (payload: LearningClassroomPayload) => void; const moduleB = new Promise((resolve) => { resolveModuleB = resolve; }); const moduleC = new Promise((resolve) => { resolveModuleC = resolve; }); readLearningClassroomMock.mockImplementation((_courseId: string, moduleId?: string) => { if (!moduleId) return Promise.resolve(classroomPayload()); if (moduleId === 'module-b') return moduleB; if (moduleId === 'module-c') return moduleC; throw new Error(`Unexpected module ${moduleId}`); }); renderPlayer(); fireEvent.click(await screen.findByRole('button', { name: '2. Module B' })); fireEvent.click(screen.getByRole('button', { name: '3. Module C' })); await act(async () => { resolveModuleC(classroomPayload('module-c')); }); await waitFor(() => expect(screen.getByRole('button', { name: '3. Module C' })).toHaveAttribute('aria-current', 'page')); await act(async () => { resolveModuleB(classroomPayload('module-b')); }); expect(screen.getByRole('button', { name: '3. Module C' })).toHaveAttribute('aria-current', 'page'); expect(screen.getByRole('button', { name: '2. Module B' })).not.toHaveAttribute('aria-current'); }); });