386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
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<LearningClassroomPayload>((resolve) => { resolveModuleB = resolve; });
|
|
const moduleC = new Promise<LearningClassroomPayload>((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');
|
|
});
|
|
});
|