diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 94ec0fc526..e4cef68096 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -26,9 +26,11 @@ interface VideoProgressStore { thresholdLogged: boolean; } +type WistiaPostMessageArg = string | number | Record; + interface WistiaPostMessageData { method: string; - args: Array>; + args: Array; } interface WistiaEventData { @@ -37,6 +39,18 @@ interface WistiaEventData { [key: string]: unknown; } +interface WistiaMessageProcessingContext { + lastKnownTime: number; + embedSrc: string; + videoId: string; + pageId?: string; +} + +interface WistiaMessageProcessingResult { + lastKnownTime: number; + eventDetails?: VideoEventDetails; +} + interface YouTubePlayer { getVideoUrl: () => string; getCurrentTime: () => number; @@ -96,11 +110,20 @@ const VIDEO_WATCH_THRESHOLD = 0.6; const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; -interface WatchedSegment { +export interface WatchedSegment { watchedSegmentStart: number; watchedSegmentEnd: number; } +const WISTIA_EVENT_TYPE_MAP: Record = { + play: "VIDEO_PLAY", + playing: "VIDEO_PLAY", + pause: "VIDEO_PAUSE", + paused: "VIDEO_PAUSE", + end: "VIDEO_ENDED", + ended: "VIDEO_ENDED", +}; + interface VideoProgressState { totalVideoDurationInSeconds: number | null; segments: WatchedSegment[]; @@ -175,26 +198,26 @@ function rewriteWistia(src: string): string | undefined { /** * Extract video ID from embed URL */ -function extractVideoId(embedSrc: string, pattern: RegExp): string | null { +export function extractVideoId(embedSrc: string, pattern: RegExp): string | null { const match = pattern.exec(embedSrc); return match ? match[1] : null; } // The video progress storage key is a combination of the user storage scope (logged-in or not logged in users) and the video id. This is to ensure that the video progress is scoped to the user. -function getVideoProgressStorageKey(userStorageScope: string, videoId: string): string { +export function getVideoProgressStorageKey(userStorageScope: string, videoId: string): string { return `${VIDEO_PROGRESS_STORAGE_PREFIX}${userStorageScope}:${videoId}`; } -function isValidNumber(value: unknown): value is number { +export function isValidNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } //clamp function is to ensure tat the value of video segments is between 0 and total video duration -function clampVideoProgressValue(value: number, min: number, max: number): number { +export function clampVideoProgressValue(value: number, min: number, max: number): number { return Math.max(min, Math.min(value, max)); } -function mergeSegments(segments: WatchedSegment[]): WatchedSegment[] { +export function mergeSegments(segments: WatchedSegment[]): WatchedSegment[] { if (segments.length === 0) return []; const sortedSegments = [...segments].sort((a, b) => a.watchedSegmentStart - b.watchedSegmentStart); @@ -218,19 +241,19 @@ function mergeSegments(segments: WatchedSegment[]): WatchedSegment[] { return mergedSegments; } -function getUniqueWatchedSeconds(segments: WatchedSegment[]): number { +export function getUniqueWatchedSeconds(segments: WatchedSegment[]): number { return segments.reduce( (total, segment) => total + Math.max(0, segment.watchedSegmentEnd - segment.watchedSegmentStart), 0, ); } -function getWatchPercent(uniqueWatchedSeconds: number, totalVideoDurationInSeconds: number): number { +export function getWatchPercent(uniqueWatchedSeconds: number, totalVideoDurationInSeconds: number): number { if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return 0; return uniqueWatchedSeconds / totalVideoDurationInSeconds; } -function loadVideoProgress(userStorageScope: string, videoId: string): VideoProgressStore | null { +export function loadVideoProgress(userStorageScope: string, videoId: string): VideoProgressStore | null { try { const localStorageVideoData = globalThis.localStorage?.getItem( getVideoProgressStorageKey(userStorageScope, videoId), @@ -257,7 +280,7 @@ function loadVideoProgress(userStorageScope: string, videoId: string): VideoProg } } -function createEmptyVideoProgressState(): VideoProgressState { +export function createEmptyVideoProgressState(): VideoProgressState { return { totalVideoDurationInSeconds: null, segments: [], @@ -268,7 +291,10 @@ function createEmptyVideoProgressState(): VideoProgressState { }; } -function createInitialVideoProgressState(userStorageScope: string | null, videoId: string | null): VideoProgressState { +export function createInitialVideoProgressState( + userStorageScope: string | null, + videoId: string | null, +): VideoProgressState { if (!userStorageScope || !videoId) { return createEmptyVideoProgressState(); } @@ -284,7 +310,7 @@ function createInitialVideoProgressState(userStorageScope: string | null, videoI }; } -function saveVideoProgress(userStorageScope: string | null, videoId: string, state: VideoProgressState): void { +export function saveVideoProgress(userStorageScope: string | null, videoId: string, state: VideoProgressState): void { if (!userStorageScope) return; try { const toStore: VideoProgressStore = { @@ -301,7 +327,7 @@ function saveVideoProgress(userStorageScope: string | null, videoId: string, sta /** * Log video events to the backend */ -async function logVideoEvent( +export async function logVideoEvent( eventDetails: VideoEventDetails, dispatch?: ReturnType, ): Promise { @@ -321,7 +347,7 @@ async function logVideoEvent( /** * Create video event details object */ -function createEventDetails( +export function createEventDetails( type: VideoEventDetails["type"], videoUrl: string, videoId: string, @@ -342,6 +368,107 @@ function createEventDetails( return details; } +export function updateWistiaTimeFromEventData(lastKnownTime: number, eventData: WistiaEventData): number { + if (typeof eventData.seconds === "number") { + return eventData.seconds; + } + if (typeof eventData.secondsWatched === "number") { + return eventData.secondsWatched; + } + return lastKnownTime; +} + +export function updateWistiaTimeFromArgs(lastKnownTime: number, args: Array): number { + if (typeof args[1] === "number") { + return args[1]; + } + if (typeof (args[1] as WistiaEventData)?.seconds === "number") { + return (args[1] as WistiaEventData).seconds as number; + } + return lastKnownTime; +} + +export function isWistiaTimeChangeEvent(eventName: string): boolean { + return eventName === "timechange" || eventName === "secondchange"; +} + +interface WistiaTriggerMessage { + eventName: string; + eventData: WistiaEventData; + args: WistiaPostMessageArg[]; +} + +function parseWistiaTriggerMessage(rawData: unknown): WistiaTriggerMessage | null { + const data: WistiaPostMessageData = + typeof rawData === "string" ? JSON.parse(rawData) : (rawData as WistiaPostMessageData); + if (data.method !== "_trigger" || !Array.isArray(data.args) || data.args.length === 0) { + return null; + } + + const rawEventName = data.args[0]; + if (typeof rawEventName !== "string") { + return null; + } + + return { + eventName: rawEventName, + eventData: (data.args[1] || {}) as WistiaEventData, + args: data.args, + }; +} + +function isWistiaMessageForIframe(event: MessageEvent, iframeContentWindow: Window | null): boolean { + return isValidWistiaOrigin(event.origin) && event.source === iframeContentWindow; +} + +function logWistiaMessageParseError(error: unknown): void { + if (process.env.NODE_ENV === "development" && error instanceof Error && !error.message.includes("not valid JSON")) { + console.warn("Error handling Wistia message:", error); + } +} + +export function processWistiaMessage( + origin: string, + rawData: unknown, + context: WistiaMessageProcessingContext, +): WistiaMessageProcessingResult { + if (!isValidWistiaOrigin(origin)) { + return { lastKnownTime: context.lastKnownTime }; + } + + const message = parseWistiaTriggerMessage(rawData); + if (!message) { + return { lastKnownTime: context.lastKnownTime }; + } + + const eventName = message.eventName.toLowerCase(); + if (isWistiaTimeChangeEvent(eventName)) { + return { + lastKnownTime: updateWistiaTimeFromArgs(context.lastKnownTime, message.args), + }; + } + + const nextKnownTime = updateWistiaTimeFromEventData(context.lastKnownTime, message.eventData); + const eventType = WISTIA_EVENT_TYPE_MAP[eventName]; + if (!eventType) { + return { lastKnownTime: nextKnownTime }; + } + + return { + lastKnownTime: nextKnownTime, + eventDetails: createEventDetails(eventType, context.embedSrc, context.videoId, { + pageId: context.pageId, + videoPosition: eventType === "VIDEO_ENDED" ? undefined : nextKnownTime, + }), + }; +} + +export function isValidWistiaOrigin(origin: string): boolean { + return VIDEO_PLATFORMS.WISTIA.allowedOrigins.some( + (allowed) => origin === allowed || origin.endsWith(".wistia.net") || origin.endsWith(".wistia.com"), + ); +} + export function pauseAllVideos(): void { const iframes = document.querySelectorAll("iframe"); iframes.forEach((iframe) => { @@ -385,10 +512,14 @@ export function IsaacVideo(props: IsaacVideoProps) { const progressReference = useRef( createInitialVideoProgressState(userStorageScope, canonicalVideoId), ); - - React.useEffect(() => { + const progressScopeAndVideoRef = useRef({ userStorageScope, canonicalVideoId }); + if ( + progressScopeAndVideoRef.current.userStorageScope !== userStorageScope || + progressScopeAndVideoRef.current.canonicalVideoId !== canonicalVideoId + ) { progressReference.current = createInitialVideoProgressState(userStorageScope, canonicalVideoId); - }, [canonicalVideoId, userStorageScope]); + progressScopeAndVideoRef.current = { userStorageScope, canonicalVideoId }; + } const setTotalVideoDurationIfPresent = useCallback( (totalVideoDurationInSeconds: number | null | undefined) => { @@ -438,12 +569,10 @@ export function IsaacVideo(props: IsaacVideoProps) { ...progressReference.current.segments, { watchedSegmentStart: clampedStart, watchedSegmentEnd: clampedEnd }, ]); - if (canonicalVideoId) { - saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); - } + saveVideoProgress(userStorageScope, videoId, progressReference.current); checkIf60PercentWatchedAndLog(videoId, videoUrl); }, - [canonicalVideoId, checkIf60PercentWatchedAndLog, userStorageScope], + [checkIf60PercentWatchedAndLog, userStorageScope], ); const startCurrentSegment = useCallback((segmentStart: number) => { @@ -510,29 +639,13 @@ export function IsaacVideo(props: IsaacVideoProps) { const iframe = wistiaIframeRef.current; - // Event type mapping for video events - const eventTypeMap: Record = { - play: "VIDEO_PLAY", - playing: "VIDEO_PLAY", - pause: "VIDEO_PAUSE", - paused: "VIDEO_PAUSE", - end: "VIDEO_ENDED", - ended: "VIDEO_ENDED", - }; - - const isValidWistiaOrigin = (origin: string): boolean => { - return VIDEO_PLATFORMS.WISTIA.allowedOrigins.some( - (allowed) => origin === allowed || origin.endsWith(".wistia.net") || origin.endsWith(".wistia.com"), - ); - }; - const updateTimeFromEventData = (eventData: WistiaEventData): number | null => { if (typeof eventData.seconds === "number") return eventData.seconds; if (typeof eventData.secondsWatched === "number") return eventData.secondsWatched; return null; }; - const updateTimeFromArgs = (args: Array>): number | null => { + const updateTimeFromArgs = (args: Array): number | null => { if (typeof args[1] === "number") { return args[1]; } else if (typeof (args[1] as WistiaEventData)?.seconds === "number") { @@ -554,7 +667,7 @@ export function IsaacVideo(props: IsaacVideoProps) { setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); } - const eventType = eventTypeMap[eventName.toLowerCase()]; + const eventType = WISTIA_EVENT_TYPE_MAP[eventName.toLowerCase()]; if (!eventType) return; if (eventType === "VIDEO_PLAY") { @@ -569,46 +682,36 @@ export function IsaacVideo(props: IsaacVideoProps) { logPlayerEvent(eventType, videoUrl, wistiaVideoId, eventType === "VIDEO_ENDED" ? undefined : eventTime); }; - const isTimeChangeEvent = (eventName: string): boolean => { - return eventName === "timechange" || eventName === "secondchange"; + const applyWistiaTimeChange = (args: WistiaPostMessageArg[], eventData: WistiaEventData): void => { + const currentTime = updateTimeFromArgs(args); + if (isValidNumber(currentTime)) { + updatePlaybackProgress(currentTime, embedSrc || "", wistiaVideoId); + } + const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); + if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { + setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); + } }; const handleWistiaMessage = (event: MessageEvent): void => { - if (!isValidWistiaOrigin(event.origin)) return; - - //Check to make sure the message is coming from the same origin as the iframe. This is to prevent XSS attacks, especially when we have multiple videos on the same page. - if (event.source !== iframe.contentWindow) return; + if (!isWistiaMessageForIframe(event, iframe.contentWindow)) { + return; + } try { - const data: WistiaPostMessageData = typeof event.data === "string" ? JSON.parse(event.data) : event.data; - - if (data.method !== "_trigger" || !Array.isArray(data.args) || data.args.length === 0) { + const message = parseWistiaTriggerMessage(event.data); + if (!message) { return; } - const eventName = data.args[0] as string; - const eventData = (data.args[1] || {}) as WistiaEventData; - - if (isTimeChangeEvent(eventName)) { - const currentTime = updateTimeFromArgs(data.args); - if (isValidNumber(currentTime)) { - updatePlaybackProgress(currentTime, embedSrc || "", wistiaVideoId); - } - const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); - if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { - setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); - } - } else { - handleVideoEvent(eventName, eventData); + if (isWistiaTimeChangeEvent(message.eventName)) { + applyWistiaTimeChange(message.args, message.eventData); + return; } + + handleVideoEvent(message.eventName, message.eventData); } catch (error) { - if ( - process.env.NODE_ENV === "development" && - error instanceof Error && - !error.message.includes("not valid JSON") - ) { - console.warn("Error handling Wistia message:", error); - } + logWistiaMessageParseError(error); } }; @@ -616,7 +719,6 @@ export function IsaacVideo(props: IsaacVideoProps) { const setupWistiaBindings = () => { if (iframe.contentWindow) { - // Bind to all the events we care about const eventsToTrack = ["play", "pause", "end", "timechange", "secondchange", "durationchange"]; eventsToTrack.forEach((eventName) => { iframe.contentWindow?.postMessage( diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index f7413f9ecf..2d8d3762c6 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,4 +1,40 @@ -import { rewrite } from "../../../../app/components/content/IsaacVideo"; +import { AxiosHeaders, type AxiosResponse } from "axios"; +import React, { useState } from "react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { jest } from "@jest/globals"; +import { mockUser } from "../../../../mocks/data"; +import { + clampVideoProgressValue, + createEmptyVideoProgressState, + createInitialVideoProgressState, + extractVideoId, + getUniqueWatchedSeconds, + getVideoProgressStorageKey, + getWatchPercent, + IsaacVideo, + isValidNumber, + isValidWistiaOrigin, + isWistiaTimeChangeEvent, + loadVideoProgress, + logVideoEvent, + mergeSegments, + pauseAllVideos, + processWistiaMessage, + rewrite, + saveVideoProgress, + updateWistiaTimeFromArgs, + updateWistiaTimeFromEventData, +} from "../../../../app/components/content/IsaacVideo"; +import { ACTION_TYPE, api } from "../../../../app/services"; +import { + STAGING_PAGE_VIDEO_IDS, + STAGING_VIDEO_TEST_PAGE_ID, + STAGING_WISTIA_VIDEOS, + STAGING_YOUTUBE_VIDEO, + stagingVideoTestPageDoc, +} from "../../../testPages/stagingVideoTestPage"; +import { renderTestEnvironment } from "../../../utils"; +import { requestCurrentUser, store } from "../../../../app/state"; describe("rewrite", () => { it("parses youtube url to iframe src", () => { @@ -15,3 +51,1315 @@ describe("rewrite", () => { ); }); }); + +type VideoEventDispatch = NonNullable[1]>; + +const voidAxiosResponse: AxiosResponse = { + data: undefined, + status: 200, + statusText: "OK", + headers: {}, + config: { headers: new AxiosHeaders() }, +}; + +const mockApiLoggerLog = () => jest.spyOn(api.logger, "log").mockResolvedValue(voidAxiosResponse); + +const createVideoEventDispatchMock = () => { + const dispatchMock = jest.fn(); + return { dispatchMock, dispatch: dispatchMock as unknown as VideoEventDispatch }; +}; + +describe("logVideoEvent", () => { + const eventDetails = { + type: "VIDEO_PLAY" as const, + videoId: "test123ABCde", + videoUrl: "https://www.youtube.com/watch?v=test123ABCde", + pageId: "page-1", + videoPosition: 10, + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("dispatches LOG_EVENT and calls the logger API when dispatch is provided", async () => { + const { dispatchMock, dispatch } = createVideoEventDispatchMock(); + const logSpy = mockApiLoggerLog(); + + await logVideoEvent(eventDetails, dispatch); + + expect(dispatchMock).toHaveBeenCalledWith({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); + expect(logSpy).toHaveBeenCalledWith(eventDetails); + }); + + it("calls only the logger API when dispatch is omitted", async () => { + const { dispatchMock } = createVideoEventDispatchMock(); + const logSpy = mockApiLoggerLog(); + + await logVideoEvent(eventDetails); + + expect(dispatchMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith(eventDetails); + }); + + it("does not throw when the logger API fails", async () => { + jest.spyOn(api.logger, "log").mockRejectedValue(new Error("network error")); + + await expect(logVideoEvent(eventDetails)).resolves.toBeUndefined(); + }); +}); + +describe("YouTube player handlers", () => { + const originalYT = globalThis.YT; + const youtubeSrc = "https://www.youtube.com/watch?v=test123ABCde"; + const youtubeVideoId = "test123ABCd"; + + interface CapturedYouTubePlayerConfig { + events?: { + onReady?: (event: { target: typeof mockPlayer; data: number }) => void; + onStateChange?: (event: { target: typeof mockPlayer; data: number }) => void; + }; + } + + let capturedPlayerConfig: CapturedYouTubePlayerConfig | null = null; + + const mockPlayer = { + getVideoUrl: () => youtubeSrc, + getCurrentTime: () => 30, + getDuration: () => 120, + }; + + function MockYTPlayer(_node: HTMLElement, config: CapturedYouTubePlayerConfig) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: mockPlayer, data: 0 }); + } + + const IsaacVideoHarness = () => ; + + const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + + beforeEach(() => { + capturedPlayerConfig = null; + mockApiLoggerLog(); + jest.spyOn(store, "dispatch"); + + globalThis.YT = { + Player: MockYTPlayer as unknown as NonNullable["Player"], + ready: (callback: () => void) => callback(), + PlayerState: { + PLAYING: 1, + PAUSED: 2, + ENDED: 0, + }, + }; + }); + + afterEach(() => { + globalThis.YT = originalYT; + jest.restoreAllMocks(); + }); + + const renderYouTubeVideo = () => { + renderTestEnvironment({ + role: "STUDENT", + PageComponent: IsaacVideoHarness, + initialRouteEntries: ["/"], + }); + }; + + it("registers onReady and onStateChange when the YouTube player is created", () => { + renderYouTubeVideo(); + + expect(capturedPlayerConfig?.events?.onReady).toBeDefined(); + expect(capturedPlayerConfig?.events?.onStateChange).toBeDefined(); + }); + + it("onReady captures video duration for later tracking events", async () => { + renderYouTubeVideo(); + await flushPromises(); + + await act(async () => { + capturedPlayerConfig?.events?.onStateChange?.({ target: mockPlayer, data: 1 }); + }); + await flushPromises(); + + expect(store.dispatch).toHaveBeenCalledWith({ + type: ACTION_TYPE.LOG_EVENT, + eventDetails: { + type: "VIDEO_PLAY", + videoId: youtubeVideoId, + videoUrl: youtubeSrc, + videoPosition: 30, + videoDurationSeconds: 120, + }, + }); + }); + + it.each([ + [1, "VIDEO_PLAY", 30], + [2, "VIDEO_PAUSE", 30], + [0, "VIDEO_ENDED", undefined], + ])("onStateChange maps player state %i to %s", async (playerState, expectedEventType, videoPosition) => { + renderYouTubeVideo(); + await flushPromises(); + + await act(async () => { + capturedPlayerConfig?.events?.onStateChange?.({ target: mockPlayer, data: playerState }); + }); + await flushPromises(); + + const expectedEventDetails: Record = { + type: expectedEventType, + videoId: youtubeVideoId, + videoUrl: youtubeSrc, + videoDurationSeconds: 120, + }; + if (videoPosition !== undefined) { + expectedEventDetails.videoPosition = videoPosition; + } + + expect(store.dispatch).toHaveBeenCalledWith({ + type: ACTION_TYPE.LOG_EVENT, + eventDetails: expectedEventDetails, + }); + }); + + it("onStateChange ignores unhandled player states", async () => { + renderYouTubeVideo(); + await flushPromises(); + + const dispatchMock = store.dispatch as jest.Mock; + dispatchMock.mockClear(); + + await act(async () => { + capturedPlayerConfig?.events?.onStateChange?.({ target: mockPlayer, data: 99 }); + }); + await flushPromises(); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); +}); + +describe("Wistia helpers", () => { + it("accepts only supported Wistia origins", () => { + expect(isValidWistiaOrigin("https://fast.wistia.net")).toBe(true); + expect(isValidWistiaOrigin("https://embed.wistia.com")).toBe(true); + expect(isValidWistiaOrigin("https://player.wistia.net")).toBe(true); + expect(isValidWistiaOrigin("https://youtube.com")).toBe(false); + }); + + it("identifies time update event names", () => { + expect(isWistiaTimeChangeEvent("timechange")).toBe(true); + expect(isWistiaTimeChangeEvent("secondchange")).toBe(true); + expect(isWistiaTimeChangeEvent("play")).toBe(false); + }); + + it("updates last known time from event payload and args payloads", () => { + expect(updateWistiaTimeFromEventData(5, { seconds: 12 })).toBe(12); + expect(updateWistiaTimeFromEventData(5, { secondsWatched: 9 })).toBe(9); + expect(updateWistiaTimeFromEventData(5, {})).toBe(5); + + expect(updateWistiaTimeFromArgs(5, ["timechange", 18])).toBe(18); + expect(updateWistiaTimeFromArgs(5, ["timechange", { seconds: 11 }])).toBe(11); + expect(updateWistiaTimeFromArgs(5, ["timechange", { notSeconds: 1 }])).toBe(5); + }); + + it("processes timechange messages by updating time without emitting a video event", () => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["timechange", 17] }), + { + lastKnownTime: 3, + embedSrc: "https://fast.wistia.net/embed/iframe/abc123", + videoId: "abc123", + pageId: "page-1", + }, + ); + + expect(result).toEqual({ lastKnownTime: 17 }); + }); + + it("processes play and ended messages into tracking event payloads", () => { + const playResult = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["play", { seconds: 21 }] }), + { + lastKnownTime: 3, + embedSrc: "https://fast.wistia.net/embed/iframe/abc123", + videoId: "abc123", + pageId: "page-1", + }, + ); + expect(playResult).toEqual({ + lastKnownTime: 21, + eventDetails: { + type: "VIDEO_PLAY", + videoId: "abc123", + videoUrl: "https://fast.wistia.net/embed/iframe/abc123", + pageId: "page-1", + videoPosition: 21, + }, + }); + + const endedResult = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["ended", { seconds: 60 }] }), + { + lastKnownTime: 21, + embedSrc: "https://fast.wistia.net/embed/iframe/abc123", + videoId: "abc123", + pageId: "page-1", + }, + ); + expect(endedResult).toEqual({ + lastKnownTime: 60, + eventDetails: { + type: "VIDEO_ENDED", + videoId: "abc123", + videoUrl: "https://fast.wistia.net/embed/iframe/abc123", + pageId: "page-1", + }, + }); + }); + + it("ignores unsupported origins and non-trigger messages", () => { + const originRejected = processWistiaMessage( + "https://youtube.com", + JSON.stringify({ method: "_trigger", args: ["play", { seconds: 12 }] }), + { + lastKnownTime: 7, + embedSrc: "https://fast.wistia.net/embed/iframe/abc123", + videoId: "abc123", + pageId: "page-1", + }, + ); + expect(originRejected).toEqual({ lastKnownTime: 7 }); + + const methodRejected = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "noop", args: ["play", { seconds: 12 }] }), + { + lastKnownTime: 7, + embedSrc: "https://fast.wistia.net/embed/iframe/abc123", + videoId: "abc123", + pageId: "page-1", + }, + ); + expect(methodRejected).toEqual({ lastKnownTime: 7 }); + }); +}); + +describe("isValidNumber", () => { + it("returns true for finite numbers", () => { + expect(isValidNumber(0)).toBe(true); + expect(isValidNumber(42.5)).toBe(true); + expect(isValidNumber(-1)).toBe(true); + }); + + it("returns false for non-finite or non-number values", () => { + expect(isValidNumber(Number.NaN)).toBe(false); + expect(isValidNumber(Infinity)).toBe(false); + expect(isValidNumber("10")).toBe(false); + expect(isValidNumber(null)).toBe(false); + expect(isValidNumber(undefined)).toBe(false); + }); +}); + +describe("clampVideoProgressValue", () => { + it("returns the value when it is within the inclusive range", () => { + expect(clampVideoProgressValue(5, 0, 10)).toBe(5); + expect(clampVideoProgressValue(-3, 0, 100)).toBe(0); + expect(clampVideoProgressValue(150, 0, 100)).toBe(100); + }); +}); + +describe("mergeSegments", () => { + it("returns an empty array when given no segments", () => { + expect(mergeSegments([])).toEqual([]); + }); + + it("returns a single segment unchanged", () => { + expect(mergeSegments([{ watchedSegmentStart: 2, watchedSegmentEnd: 8 }])).toEqual([ + { watchedSegmentStart: 2, watchedSegmentEnd: 8 }, + ]); + }); + + it("sorts segments by start time before merging", () => { + expect( + mergeSegments([ + { watchedSegmentStart: 20, watchedSegmentEnd: 30 }, + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + ]), + ).toEqual([ + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + { watchedSegmentStart: 20, watchedSegmentEnd: 30 }, + ]); + }); + + it("merges overlapping segments into one", () => { + expect( + mergeSegments([ + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + { watchedSegmentStart: 5, watchedSegmentEnd: 15 }, + ]), + ).toEqual([{ watchedSegmentStart: 0, watchedSegmentEnd: 15 }]); + }); + + it("merges segments separated by up to 0.5 seconds", () => { + expect( + mergeSegments([ + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + { watchedSegmentStart: 10.4, watchedSegmentEnd: 20 }, + ]), + ).toEqual([{ watchedSegmentStart: 0, watchedSegmentEnd: 20 }]); + }); + + it("keeps segments separated by more than 0.5 seconds apart", () => { + expect( + mergeSegments([ + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + { watchedSegmentStart: 30, watchedSegmentEnd: 40 }, + { watchedSegmentStart: 10.6, watchedSegmentEnd: 20 }, + ]), + ).toEqual([ + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + { watchedSegmentStart: 10.6, watchedSegmentEnd: 20 }, + { watchedSegmentStart: 30, watchedSegmentEnd: 40 }, + ]); + }); +}); + +describe("getUniqueWatchedSeconds", () => { + it("returns 0 for an empty segment list", () => { + expect(getUniqueWatchedSeconds([])).toBe(0); + }); + + it("sums the length of a single segment", () => { + expect(getUniqueWatchedSeconds([{ watchedSegmentStart: 5, watchedSegmentEnd: 15 }])).toBe(10); + }); + + it("sums lengths across multiple segments", () => { + expect( + getUniqueWatchedSeconds([ + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + { watchedSegmentStart: 20, watchedSegmentEnd: 25 }, + ]), + ).toBe(15); + }); + + it("ignores segments where end is before start", () => { + expect(getUniqueWatchedSeconds([{ watchedSegmentStart: 10, watchedSegmentEnd: 5 }])).toBe(0); + }); +}); + +describe("getWatchPercent", () => { + it("returns the ratio of watched seconds to total duration", () => { + expect(getWatchPercent(60, 100)).toBe(0.6); + expect(getWatchPercent(30, 120)).toBe(0.25); + }); + + it("returns 0 when total duration is zero, negative, or not a finite number", () => { + expect(getWatchPercent(60, 0)).toBe(0); + expect(getWatchPercent(60, -10)).toBe(0); + expect(getWatchPercent(60, Number.NaN)).toBe(0); + expect(getWatchPercent(60, Infinity)).toBe(0); + }); +}); + +describe("extractVideoId", () => { + it("extracts the first capture group when the pattern matches", () => { + expect(extractVideoId("https://www.youtube-nocookie.com/embed/test123ABCd", /embed\/([^?]+)/)).toBe("test123ABCd"); + expect(extractVideoId("https://fast.wistia.net/embed/iframe/glytlhepl5", /embed\/iframe\/([a-zA-Z0-9]+)/)).toBe( + "glytlhepl5", + ); + }); + + it("returns null when the pattern does not match", () => { + expect(extractVideoId("https://example.com/video", /embed\/([^?]+)/)).toBeNull(); + }); +}); + +describe("getVideoProgressStorageKey", () => { + it("builds a scoped localStorage key from user scope and video id", () => { + expect(getVideoProgressStorageKey("user-42", "abc123")).toBe("video-progress:user-42:abc123"); + }); +}); + +describe("createEmptyVideoProgressState", () => { + it("returns default progress tracking state", () => { + expect(createEmptyVideoProgressState()).toEqual({ + totalVideoDurationInSeconds: null, + segments: [], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: false, + }); + }); +}); + +describe("createInitialVideoProgressState", () => { + const userStorageScope = "user-1"; + const videoId = "vid-1"; + const storageKey = getVideoProgressStorageKey(userStorageScope, videoId); + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("returns empty state when user scope or video id is missing", () => { + expect(createInitialVideoProgressState(null, videoId)).toEqual(createEmptyVideoProgressState()); + expect(createInitialVideoProgressState(userStorageScope, null)).toEqual(createEmptyVideoProgressState()); + }); + + it("populates state from localStorage when progress exists", () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + totalVideoDurationInSeconds: 120, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 30 }], + thresholdLogged: true, + }), + ); + + expect(createInitialVideoProgressState(userStorageScope, videoId)).toEqual({ + totalVideoDurationInSeconds: 120, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 30 }], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: true, + }); + }); + + it("returns empty state when localStorage has no entry", () => { + expect(createInitialVideoProgressState(userStorageScope, videoId)).toEqual(createEmptyVideoProgressState()); + }); +}); + +describe("loadVideoProgress", () => { + const userStorageScope = "user-2"; + const videoId = "vid-2"; + const storageKey = getVideoProgressStorageKey(userStorageScope, videoId); + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("returns null when nothing is stored", () => { + expect(loadVideoProgress(userStorageScope, videoId)).toBeNull(); + }); + + it("parses and normalizes valid stored progress", () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + totalVideoDurationInSeconds: 90, + segments: [ + { watchedSegmentStart: 10, watchedSegmentEnd: 20 }, + { watchedSegmentStart: 0, watchedSegmentEnd: 5 }, + ], + thresholdLogged: false, + }), + ); + + expect(loadVideoProgress(userStorageScope, videoId)).toEqual({ + totalVideoDurationInSeconds: 90, + segments: [ + { watchedSegmentStart: 0, watchedSegmentEnd: 5 }, + { watchedSegmentStart: 10, watchedSegmentEnd: 20 }, + ], + thresholdLogged: false, + }); + }); + + it("merges overlapping segments when loading from storage", () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + totalVideoDurationInSeconds: 60, + segments: [ + { watchedSegmentStart: 0, watchedSegmentEnd: 15 }, + { watchedSegmentStart: 10, watchedSegmentEnd: 25 }, + ], + thresholdLogged: false, + }), + ); + + expect(loadVideoProgress(userStorageScope, videoId)?.segments).toEqual([ + { watchedSegmentStart: 0, watchedSegmentEnd: 25 }, + ]); + }); + + it("filters invalid segments and rejects invalid duration", () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + totalVideoDurationInSeconds: -5, + segments: [ + { watchedSegmentStart: 0, watchedSegmentEnd: 10 }, + { watchedSegmentStart: "bad", watchedSegmentEnd: 20 }, + ], + thresholdLogged: true, + }), + ); + + expect(loadVideoProgress(userStorageScope, videoId)).toEqual({ + totalVideoDurationInSeconds: null, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 10 }], + thresholdLogged: true, + }); + }); + + it("returns null for malformed JSON", () => { + localStorage.setItem(storageKey, "not-json"); + expect(loadVideoProgress(userStorageScope, videoId)).toBeNull(); + }); +}); + +const stagingWistiaMessageContext = (video: (typeof STAGING_WISTIA_VIDEOS)[number], lastKnownTime = 0) => ({ + lastKnownTime, + embedSrc: rewrite(video.src)!, + videoId: video.videoId, + pageId: STAGING_VIDEO_TEST_PAGE_ID, +}); + +describe("staging video test page — Wistia", () => { + it.each(STAGING_WISTIA_VIDEOS.map((v) => [v.videoId, v.src] as const))( + "rewrites staging Wistia embed src for %s", + (videoId, src) => { + const embedSrc = rewrite(src)!; + expect(embedSrc).toContain(`embed/iframe/${videoId}`); + expect(embedSrc).toContain("videoFoam=true"); + }, + ); + + it.each(STAGING_WISTIA_VIDEOS.map((v) => [v.videoId, v.src] as const))( + "extracts staging Wistia video id for %s", + (videoId, src) => { + const embedSrc = rewrite(src)!; + expect(extractVideoId(embedSrc, /embed\/iframe\/([a-zA-Z0-9]+)/)).toBe(videoId); + }, + ); + + it.each(STAGING_WISTIA_VIDEOS)("maps staging Wistia play to VIDEO_PLAY with pageId (%s)", (video) => { + const context = stagingWistiaMessageContext(video); + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["play", { seconds: 10, duration: 100 }] }), + context, + ); + + expect(result).toEqual({ + lastKnownTime: 10, + eventDetails: { + type: "VIDEO_PLAY", + videoId: video.videoId, + videoUrl: context.embedSrc, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + videoPosition: 10, + }, + }); + }); + + it.each(STAGING_WISTIA_VIDEOS)("maps staging Wistia pause to VIDEO_PAUSE with pageId (%s)", (video) => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["pause", { seconds: 25 }] }), + stagingWistiaMessageContext(video, 25), + ); + + expect(result.eventDetails).toMatchObject({ + type: "VIDEO_PAUSE", + videoId: video.videoId, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + videoPosition: 25, + }); + }); + + it.each(STAGING_WISTIA_VIDEOS)("updates time on staging timechange without logging play (%s)", (video) => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["timechange", 30] }), + stagingWistiaMessageContext(video, 20), + ); + + expect(result).toEqual({ lastKnownTime: 30 }); + expect(result.eventDetails).toBeUndefined(); + }); + + it.each(STAGING_WISTIA_VIDEOS)("maps staging Wistia ended to VIDEO_ENDED with pageId (%s)", (video) => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["ended", { seconds: 100 }] }), + stagingWistiaMessageContext(video, 100), + ); + + expect(result.eventDetails).toMatchObject({ + type: "VIDEO_ENDED", + videoId: video.videoId, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + }); + expect(result.eventDetails?.videoPosition).toBeUndefined(); + }); +}); + +describe("staging video test page — YouTube", () => { + const stagingYoutubeEmbedSrc = rewrite(STAGING_YOUTUBE_VIDEO.src)!; + const stagingYoutubeVideoId = extractVideoId(stagingYoutubeEmbedSrc, /embed\/([^?]+)/)!; + + it("rewrites the staging YouTube watch URL to a nocookie embed", () => { + expect(stagingYoutubeEmbedSrc).toContain("youtube-nocookie.com/embed/"); + expect(stagingYoutubeEmbedSrc).toContain(STAGING_YOUTUBE_VIDEO.videoId); + }); + + it("extracts the staging YouTube video id from the embed URL", () => { + expect(stagingYoutubeVideoId).toBe(STAGING_YOUTUBE_VIDEO.videoId); + }); + + describe("YouTube player handlers on staging video", () => { + const originalYT = globalThis.YT; + let capturedPlayerConfig: { + events?: { + onReady?: (event: { target: typeof mockPlayer; data: number }) => void; + onStateChange?: (event: { target: typeof mockPlayer; data: number }) => void; + }; + } | null = null; + + const mockPlayer = { + getVideoUrl: () => STAGING_YOUTUBE_VIDEO.src, + getCurrentTime: () => 30, + getDuration: () => 120, + }; + + function MockYTPlayer(_node: HTMLElement, config: NonNullable) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: mockPlayer, data: 0 }); + } + + const StagingYoutubeHarness = () => ( + + ); + + const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + + beforeEach(() => { + capturedPlayerConfig = null; + mockApiLoggerLog(); + jest.spyOn(store, "dispatch"); + globalThis.YT = { + Player: MockYTPlayer as unknown as NonNullable["Player"], + ready: (callback: () => void) => callback(), + PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 }, + }; + }); + + afterEach(() => { + globalThis.YT = originalYT; + jest.restoreAllMocks(); + }); + + const renderStagingYouTube = () => { + renderTestEnvironment({ + role: "STUDENT", + PageComponent: StagingYoutubeHarness, + initialRouteEntries: [`/pages/${STAGING_VIDEO_TEST_PAGE_ID}`], + }); + store.dispatch({ + type: ACTION_TYPE.DOCUMENT_RESPONSE_SUCCESS, + doc: stagingVideoTestPageDoc, + }); + }; + + it("registers onReady and onStateChange for the staging YouTube player", () => { + renderStagingYouTube(); + expect(capturedPlayerConfig?.events?.onReady).toBeDefined(); + expect(capturedPlayerConfig?.events?.onStateChange).toBeDefined(); + }); + + it("logs VIDEO_PLAY with staging pageId and video id on play", async () => { + renderStagingYouTube(); + await flushPromises(); + const dispatchMock = store.dispatch as jest.Mock; + dispatchMock.mockClear(); + + await act(async () => { + capturedPlayerConfig?.events?.onStateChange?.({ target: mockPlayer, data: 1 }); + }); + await flushPromises(); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: ACTION_TYPE.LOG_EVENT, + eventDetails: { + type: "VIDEO_PLAY", + videoId: stagingYoutubeVideoId, + videoUrl: STAGING_YOUTUBE_VIDEO.src, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + videoPosition: 30, + videoDurationSeconds: 120, + }, + }); + }); + + it.each([ + [2, "VIDEO_PAUSE", 30], + [0, "VIDEO_ENDED", undefined], + ])( + "onStateChange maps player state %i to %s for staging YouTube", + async (playerState, expectedEventType, videoPosition) => { + renderStagingYouTube(); + await flushPromises(); + const dispatchMock = store.dispatch as jest.Mock; + dispatchMock.mockClear(); + + await act(async () => { + capturedPlayerConfig?.events?.onStateChange?.({ target: mockPlayer, data: playerState }); + }); + await flushPromises(); + + const expectedEventDetails: Record = { + type: expectedEventType, + videoId: stagingYoutubeVideoId, + videoUrl: STAGING_YOUTUBE_VIDEO.src, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + videoDurationSeconds: 120, + }; + if (videoPosition !== undefined) { + expectedEventDetails.videoPosition = videoPosition; + } + + expect(dispatchMock).toHaveBeenCalledWith({ + type: ACTION_TYPE.LOG_EVENT, + eventDetails: expectedEventDetails, + }); + }, + ); + }); +}); + +describe("staging page progress persistence — multiple videos", () => { + const userStorageScope = "42"; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("uses a distinct localStorage key per video on the staging page", () => { + const keys = STAGING_PAGE_VIDEO_IDS.map((videoId) => getVideoProgressStorageKey(userStorageScope, videoId)); + expect(new Set(keys).size).toBe(STAGING_PAGE_VIDEO_IDS.length); + keys.forEach((key) => expect(key).toMatch(/^video-progress:42:/)); + }); + + it.each(STAGING_WISTIA_VIDEOS)("stores independent progress for staging Wistia video %s", (video) => { + saveVideoProgress(userStorageScope, video.videoId, { + ...createEmptyVideoProgressState(), + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 65 }], + }); + + const loaded = loadVideoProgress(userStorageScope, video.videoId); + expect(loaded?.totalVideoDurationInSeconds).toBe(100); + expect(getUniqueWatchedSeconds(loaded!.segments)).toBe(65); + expect(getWatchPercent(65, 100)).toBeGreaterThanOrEqual(0.6); + }); + + it("stores independent progress for the staging YouTube video", () => { + saveVideoProgress(userStorageScope, STAGING_YOUTUBE_VIDEO.videoId, { + ...createEmptyVideoProgressState(), + totalVideoDurationInSeconds: 200, + segments: [{ watchedSegmentStart: 10, watchedSegmentEnd: 130 }], + }); + + const loaded = loadVideoProgress(userStorageScope, STAGING_YOUTUBE_VIDEO.videoId); + expect(loaded?.totalVideoDurationInSeconds).toBe(200); + expect(getUniqueWatchedSeconds(loaded!.segments)).toBe(120); + expect(getWatchPercent(120, 200)).toBeGreaterThanOrEqual(0.6); + }); + + it("does not mix progress between staging videos on the same page", () => { + saveVideoProgress(userStorageScope, STAGING_WISTIA_VIDEOS[0].videoId, { + ...createEmptyVideoProgressState(), + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 50 }], + thresholdLogged: true, + }); + saveVideoProgress(userStorageScope, STAGING_WISTIA_VIDEOS[1].videoId, { + ...createEmptyVideoProgressState(), + totalVideoDurationInSeconds: 80, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 20 }], + thresholdLogged: false, + }); + saveVideoProgress(userStorageScope, STAGING_YOUTUBE_VIDEO.videoId, { + ...createEmptyVideoProgressState(), + totalVideoDurationInSeconds: 120, + segments: [{ watchedSegmentStart: 5, watchedSegmentEnd: 80 }], + thresholdLogged: false, + }); + + expect(loadVideoProgress(userStorageScope, STAGING_WISTIA_VIDEOS[0].videoId)?.thresholdLogged).toBe(true); + expect(loadVideoProgress(userStorageScope, STAGING_WISTIA_VIDEOS[1].videoId)?.thresholdLogged).toBe(false); + expect(getUniqueWatchedSeconds(loadVideoProgress(userStorageScope, STAGING_YOUTUBE_VIDEO.videoId)!.segments)).toBe( + 75, + ); + expect(loadVideoProgress(userStorageScope, STAGING_WISTIA_VIDEOS[2].videoId)).toBeNull(); + }); + + it.each([...STAGING_WISTIA_VIDEOS, STAGING_YOUTUBE_VIDEO])( + "updates initial state per video from localStorage (%s)", + (video) => { + const storageKey = getVideoProgressStorageKey(userStorageScope, video.videoId); + localStorage.setItem( + storageKey, + JSON.stringify({ + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 60 }], + thresholdLogged: true, + }), + ); + + expect(createInitialVideoProgressState(userStorageScope, video.videoId)).toEqual({ + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 60 }], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: true, + }); + }, + ); +}); + +type StoredVideoProgress = { + totalVideoDurationInSeconds: number; + segments: { watchedSegmentStart: number; watchedSegmentEnd: number }[]; +}; + +const readStoredProgress = (userStorageScope: string, videoId: string): StoredVideoProgress | null => { + const raw = localStorage.getItem(getVideoProgressStorageKey(userStorageScope, videoId)); + return raw ? (JSON.parse(raw) as StoredVideoProgress) : null; +}; + +const wistiaMockContentWindows = new WeakMap(); + +const attachIframeContentWindow = (iframe: HTMLIFrameElement): Window => { + let mockWindow = wistiaMockContentWindows.get(iframe); + if (!mockWindow) { + mockWindow = {} as Window; + wistiaMockContentWindows.set(iframe, mockWindow); + } + Object.defineProperty(iframe, "contentWindow", { + value: mockWindow, + configurable: true, + }); + return mockWindow; +}; + +const dispatchWistiaTrigger = ( + iframe: HTMLIFrameElement, + eventName: string, + eventData: Record = {}, +) => { + const mockWindow = attachIframeContentWindow(iframe); + act(() => { + globalThis.dispatchEvent( + new MessageEvent("message", { + origin: "https://fast.wistia.net", + source: mockWindow, + data: JSON.stringify({ method: "_trigger", args: [eventName, eventData] }), + }), + ); + }); +}; + +const getWistiaIframeForVideo = (videoId: string) => { + const iframe = screen.getByTitle(`Embedded video: ${videoId}.`) as HTMLIFrameElement; + return iframe; +}; + +const StagingMultiWistiaHarness = () => ( +
+ {STAGING_WISTIA_VIDEOS.slice(0, 2).map((video) => ( + + ))} +
+); + +const StagingSwitchingWistiaHarness = ({ remountOnSwitch }: { remountOnSwitch: boolean }) => { + const [activeIndex, setActiveIndex] = useState(0); + const video = STAGING_WISTIA_VIDEOS[activeIndex]; + return ( + <> + + + + ); +}; + +const stagingWistiaThenYoutube = [ + { src: STAGING_WISTIA_VIDEOS[0].src, videoId: STAGING_WISTIA_VIDEOS[0].videoId }, + { src: STAGING_YOUTUBE_VIDEO.src, videoId: STAGING_YOUTUBE_VIDEO.videoId }, +] as const; + +const StagingWistiaAndYoutubeHarness = () => ( +
+ + +
+); + +const StagingSwitchingWistiaToYoutubeHarness = ({ remountOnSwitch }: { remountOnSwitch: boolean }) => { + const [activeIndex, setActiveIndex] = useState(0); + const video = stagingWistiaThenYoutube[activeIndex]; + return ( + <> + + + + ); +}; + +describe("IsaacVideo localStorage when moving between videos on the same page", () => { + const userStorageScope = String(mockUser.id); + const firstVideo = STAGING_WISTIA_VIDEOS[0]; + const secondVideo = STAGING_WISTIA_VIDEOS[1]; + const youtubeVideo = STAGING_YOUTUBE_VIDEO; + const wistiaDuration = { duration: 100 }; + const youtubeDuration = 120; + + const originalYT = globalThis.YT; + let capturedPlayerConfig: { + events?: { + onReady?: (event: { target: typeof youtubeMockPlayer; data: number }) => void; + onStateChange?: (event: { target: typeof youtubeMockPlayer; data: number }) => void; + }; + } | null = null; + let youtubeCurrentTime = 0; + + const youtubeMockPlayer = { + getVideoUrl: () => youtubeVideo.src, + getCurrentTime: () => youtubeCurrentTime, + getDuration: () => youtubeDuration, + }; + + function MockYTPlayer(_node: HTMLElement, config: NonNullable) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: youtubeMockPlayer, data: 0 }); + } + + const setupYoutubeApiMock = () => { + capturedPlayerConfig = null; + youtubeCurrentTime = 0; + globalThis.YT = { + Player: MockYTPlayer as unknown as NonNullable["Player"], + ready: (callback: () => void) => callback(), + PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 }, + }; + }; + + const waitForYoutubePlayer = async () => { + await waitFor(() => { + expect(capturedPlayerConfig?.events?.onStateChange).toBeDefined(); + }); + }; + + const triggerYoutubePlay = (time: number) => { + youtubeCurrentTime = time; + act(() => { + capturedPlayerConfig?.events?.onStateChange?.({ target: youtubeMockPlayer, data: 1 }); + }); + }; + + const triggerYoutubePause = (time: number) => { + youtubeCurrentTime = time; + act(() => { + capturedPlayerConfig?.events?.onStateChange?.({ target: youtubeMockPlayer, data: 2 }); + }); + }; + + const renderLoggedInStagingHarness = async (PageComponent: React.FC, waitForTitle?: string) => { + renderTestEnvironment({ + role: "STUDENT", + PageComponent, + initialRouteEntries: [`/pages/${STAGING_VIDEO_TEST_PAGE_ID}`], + }); + await act(async () => { + await store.dispatch(requestCurrentUser() as any); + }); + store.dispatch({ + type: ACTION_TYPE.DOCUMENT_RESPONSE_SUCCESS, + doc: stagingVideoTestPageDoc, + }); + await waitFor(() => { + expect(screen.getByTitle(`Embedded video: ${waitForTitle ?? firstVideo.videoId}.`)).toBeInTheDocument(); + }); + }; + + const seedProgress = (videoId: string, watchedSegmentEnd: number, totalVideoDurationInSeconds = 100) => { + localStorage.setItem( + getVideoProgressStorageKey(userStorageScope, videoId), + JSON.stringify({ + totalVideoDurationInSeconds, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: watchedSegmentEnd }], + thresholdLogged: false, + }), + ); + }; + + beforeEach(() => { + localStorage.clear(); + mockApiLoggerLog(); + setupYoutubeApiMock(); + }); + + afterEach(() => { + globalThis.YT = originalYT; + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it("keeps separate localStorage progress when two Wistia players are on the page at once", async () => { + seedProgress(firstVideo.videoId, 10); + seedProgress(secondVideo.videoId, 5); + + await renderLoggedInStagingHarness(StagingMultiWistiaHarness); + + const firstIframe = getWistiaIframeForVideo(firstVideo.videoId); + dispatchWistiaTrigger(firstIframe, "play", { seconds: 10, ...wistiaDuration }); + dispatchWistiaTrigger(firstIframe, "pause", { seconds: 25, ...wistiaDuration }); + + const firstVideoAfterPause = readStoredProgress(userStorageScope, firstVideo.videoId); + expect(firstVideoAfterPause?.segments.at(-1)?.watchedSegmentEnd).toBe(25); + + const secondIframe = getWistiaIframeForVideo(secondVideo.videoId); + dispatchWistiaTrigger(secondIframe, "play", { seconds: 5, ...wistiaDuration }); + dispatchWistiaTrigger(secondIframe, "pause", { seconds: 12, ...wistiaDuration }); + + const secondVideoStored = readStoredProgress(userStorageScope, secondVideo.videoId); + const firstVideoStoredAgain = readStoredProgress(userStorageScope, firstVideo.videoId); + + expect(secondVideoStored?.segments.at(-1)?.watchedSegmentEnd).toBe(12); + expect(firstVideoStoredAgain).toEqual(firstVideoAfterPause); + expect(getUniqueWatchedSeconds(firstVideoStoredAgain!.segments)).toBeGreaterThan( + getUniqueWatchedSeconds(secondVideoStored!.segments), + ); + }); + + it("reloads the second video's localStorage after switching away from the first (remount)", async () => { + seedProgress(firstVideo.videoId, 30); + seedProgress(secondVideo.videoId, 8); + + await renderLoggedInStagingHarness(() => ); + + const firstIframe = getWistiaIframeForVideo(firstVideo.videoId); + dispatchWistiaTrigger(firstIframe, "play", { seconds: 30, ...wistiaDuration }); + dispatchWistiaTrigger(firstIframe, "pause", { seconds: 45, ...wistiaDuration }); + + const firstVideoSnapshot = readStoredProgress(userStorageScope, firstVideo.videoId); + expect(firstVideoSnapshot?.segments.at(-1)?.watchedSegmentEnd).toBe(45); + + fireEvent.click(screen.getByRole("button", { name: "Show second video" })); + await waitFor(() => { + expect(screen.getByTitle(`Embedded video: ${secondVideo.videoId}.`)).toBeInTheDocument(); + }); + + const secondIframe = getWistiaIframeForVideo(secondVideo.videoId); + dispatchWistiaTrigger(secondIframe, "play", { seconds: 8, ...wistiaDuration }); + dispatchWistiaTrigger(secondIframe, "pause", { seconds: 15, ...wistiaDuration }); + + const secondVideoStored = readStoredProgress(userStorageScope, secondVideo.videoId); + expect(secondVideoStored?.segments.at(-1)?.watchedSegmentEnd).toBe(15); + expect(getUniqueWatchedSeconds(secondVideoStored!.segments)).toBeLessThan(20); + expect(readStoredProgress(userStorageScope, firstVideo.videoId)).toEqual(firstVideoSnapshot); + }); + + it("reloads the second video's localStorage when the same player receives a new src", async () => { + seedProgress(firstVideo.videoId, 30); + seedProgress(secondVideo.videoId, 8); + + await renderLoggedInStagingHarness(() => ); + + const firstIframe = getWistiaIframeForVideo(firstVideo.videoId); + dispatchWistiaTrigger(firstIframe, "play", { seconds: 30, ...wistiaDuration }); + dispatchWistiaTrigger(firstIframe, "pause", { seconds: 40, ...wistiaDuration }); + + const firstVideoSnapshot = readStoredProgress(userStorageScope, firstVideo.videoId); + + fireEvent.click(screen.getByRole("button", { name: "Show second video" })); + await waitFor(() => { + expect(screen.getByTitle(`Embedded video: ${secondVideo.videoId}.`)).toBeInTheDocument(); + }); + + const secondIframe = getWistiaIframeForVideo(secondVideo.videoId); + dispatchWistiaTrigger(secondIframe, "play", { seconds: 8, ...wistiaDuration }); + dispatchWistiaTrigger(secondIframe, "pause", { seconds: 14, ...wistiaDuration }); + + const secondVideoStored = readStoredProgress(userStorageScope, secondVideo.videoId); + expect(secondVideoStored?.segments.at(-1)?.watchedSegmentEnd).toBe(14); + expect(getUniqueWatchedSeconds(secondVideoStored!.segments)).toBeLessThan(18); + expect(readStoredProgress(userStorageScope, firstVideo.videoId)).toEqual(firstVideoSnapshot); + }); + + it("keeps separate localStorage progress when Wistia and YouTube players are on the page at once", async () => { + seedProgress(firstVideo.videoId, 10); + seedProgress(youtubeVideo.videoId, 6, youtubeDuration); + + await renderLoggedInStagingHarness(StagingWistiaAndYoutubeHarness); + await waitForYoutubePlayer(); + + const wistiaIframe = getWistiaIframeForVideo(firstVideo.videoId); + dispatchWistiaTrigger(wistiaIframe, "play", { seconds: 10, ...wistiaDuration }); + dispatchWistiaTrigger(wistiaIframe, "pause", { seconds: 25, ...wistiaDuration }); + + const wistiaAfterPause = readStoredProgress(userStorageScope, firstVideo.videoId); + expect(wistiaAfterPause?.segments.at(-1)?.watchedSegmentEnd).toBe(25); + + triggerYoutubePlay(6); + triggerYoutubePause(18); + + const youtubeStored = readStoredProgress(userStorageScope, youtubeVideo.videoId); + const wistiaStoredAgain = readStoredProgress(userStorageScope, firstVideo.videoId); + + expect(youtubeStored?.segments.at(-1)?.watchedSegmentEnd).toBe(18); + expect(wistiaStoredAgain).toEqual(wistiaAfterPause); + expect(getUniqueWatchedSeconds(wistiaStoredAgain!.segments)).toBeGreaterThan( + getUniqueWatchedSeconds(youtubeStored!.segments), + ); + }); + + it("reloads YouTube localStorage after switching away from Wistia (remount)", async () => { + seedProgress(firstVideo.videoId, 30); + seedProgress(youtubeVideo.videoId, 8, youtubeDuration); + + await renderLoggedInStagingHarness(() => ); + + const wistiaIframe = getWistiaIframeForVideo(firstVideo.videoId); + dispatchWistiaTrigger(wistiaIframe, "play", { seconds: 30, ...wistiaDuration }); + dispatchWistiaTrigger(wistiaIframe, "pause", { seconds: 45, ...wistiaDuration }); + + const wistiaSnapshot = readStoredProgress(userStorageScope, firstVideo.videoId); + expect(wistiaSnapshot?.segments.at(-1)?.watchedSegmentEnd).toBe(45); + + fireEvent.click(screen.getByRole("button", { name: "Show YouTube video" })); + await waitFor(() => { + expect(screen.getByTitle(`Embedded video: ${youtubeVideo.videoId}.`)).toBeInTheDocument(); + }); + await waitForYoutubePlayer(); + + triggerYoutubePlay(8); + triggerYoutubePause(22); + + const youtubeStored = readStoredProgress(userStorageScope, youtubeVideo.videoId); + expect(youtubeStored?.segments.at(-1)?.watchedSegmentEnd).toBe(22); + expect(getUniqueWatchedSeconds(youtubeStored!.segments)).toBeLessThan(25); + expect(readStoredProgress(userStorageScope, firstVideo.videoId)).toEqual(wistiaSnapshot); + }); + + it("reloads YouTube localStorage when the same player switches from Wistia src to YouTube src", async () => { + seedProgress(firstVideo.videoId, 30); + seedProgress(youtubeVideo.videoId, 8, youtubeDuration); + + await renderLoggedInStagingHarness(() => ); + + const wistiaIframe = getWistiaIframeForVideo(firstVideo.videoId); + dispatchWistiaTrigger(wistiaIframe, "play", { seconds: 30, ...wistiaDuration }); + dispatchWistiaTrigger(wistiaIframe, "pause", { seconds: 40, ...wistiaDuration }); + + const wistiaSnapshot = readStoredProgress(userStorageScope, firstVideo.videoId); + + fireEvent.click(screen.getByRole("button", { name: "Show YouTube video" })); + await waitFor(() => { + expect(screen.getByTitle(`Embedded video: ${youtubeVideo.videoId}.`)).toBeInTheDocument(); + }); + await waitForYoutubePlayer(); + + triggerYoutubePlay(8); + triggerYoutubePause(16); + + const youtubeStored = readStoredProgress(userStorageScope, youtubeVideo.videoId); + expect(youtubeStored?.segments.at(-1)?.watchedSegmentEnd).toBe(16); + expect(getUniqueWatchedSeconds(youtubeStored!.segments)).toBeLessThan(20); + expect(readStoredProgress(userStorageScope, firstVideo.videoId)).toEqual(wistiaSnapshot); + }); +}); + +describe("saveVideoProgress", () => { + const userStorageScope = "user-3"; + const videoId = "vid-3"; + const storageKey = getVideoProgressStorageKey(userStorageScope, videoId); + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("persists progress to localStorage for a scoped user", () => { + saveVideoProgress(userStorageScope, videoId, { + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 5, watchedSegmentEnd: 20 }], + currentSegmentStart: 30, + lastKnownTime: 30, + isPlaying: true, + thresholdLogged: false, + }); + + expect(JSON.parse(localStorage.getItem(storageKey)!)).toEqual({ + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 5, watchedSegmentEnd: 20 }], + thresholdLogged: false, + }); + }); + + it("does not write when user storage scope is missing", () => { + saveVideoProgress(null, videoId, createEmptyVideoProgressState()); + expect(localStorage.getItem(storageKey)).toBeNull(); + }); +}); + +describe("pauseAllVideos", () => { + it("sends pause commands to all iframe content windows", () => { + const postMessage = jest.fn(); + const iframe = document.createElement("iframe"); + Object.defineProperty(iframe, "contentWindow", { + value: { postMessage }, + configurable: true, + }); + document.body.appendChild(iframe); + + pauseAllVideos(); + + expect(postMessage).toHaveBeenCalledWith(JSON.stringify({ event: "command", func: "pauseVideo" }), "*"); + + iframe.remove(); + }); +}); diff --git a/src/test/pages/Video60PercentTestPage.test.tsx b/src/test/pages/Video60PercentTestPage.test.tsx new file mode 100644 index 0000000000..021e9ffcc0 --- /dev/null +++ b/src/test/pages/Video60PercentTestPage.test.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { rest } from "msw"; +import { Generic } from "../../app/components/pages/Generic"; +import { API_PATH } from "../../app/services"; +import { renderTestEnvironment } from "../utils"; +import { + STAGING_VIDEO_TEST_PAGE_ID, + STAGING_WISTIA_VIDEOS, + STAGING_YOUTUBE_VIDEO, + stagingVideoTestPageDoc, +} from "../testPages/stagingVideoTestPage"; + +interface StagingVideoTestPageHarnessProps { + pageIdOverride?: string; + match: { params: { pageId: string } }; +} + +const StagingVideoTestPageHarness: React.FC = (props) => ; + +function MockYTPlayer() { + // Avoid loading real YouTube API in tests +} + +describe("staging video test page", () => { + const renderStagingPage = () => + renderTestEnvironment({ + role: "STUDENT", + PageComponent: StagingVideoTestPageHarness, + componentProps: { + pageIdOverride: STAGING_VIDEO_TEST_PAGE_ID, + match: { params: { pageId: STAGING_VIDEO_TEST_PAGE_ID } }, + }, + initialRouteEntries: [`/pages/${STAGING_VIDEO_TEST_PAGE_ID}`], + extraEndpoints: [ + rest.get(API_PATH + `/pages/${STAGING_VIDEO_TEST_PAGE_ID}`, (_req, res, ctx) => + res(ctx.json(stagingVideoTestPageDoc)), + ), + ], + }); + + it("renders all staging Wistia iframes from the page API payload", async () => { + renderStagingPage(); + + await waitFor(() => { + expect(screen.getAllByTitle(/Embedded video/i).length).toBe(STAGING_WISTIA_VIDEOS.length + 1); + }); + + const iframeSrcs = Array.from(document.querySelectorAll("iframe")).map((iframe) => iframe.getAttribute("src")); + STAGING_WISTIA_VIDEOS.forEach((video) => { + expect(iframeSrcs.some((src) => src?.includes(video.videoId))).toBe(true); + }); + }); + + it("renders the staging YouTube player mount from the page API payload", async () => { + const originalYT = globalThis.YT; + + globalThis.YT = { + Player: MockYTPlayer as unknown as NonNullable["Player"], + ready: (callback: () => void) => callback(), + PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 }, + }; + + try { + renderStagingPage(); + + await waitFor(() => { + expect(screen.getByTitle(/Embedded video:.*youtube/i)).toBeInTheDocument(); + }); + + const youtubeTitle = screen.getByTitle(/Embedded video:.*youtube/i); + expect(youtubeTitle.closest(".content-video-container")).toBeInTheDocument(); + expect(STAGING_YOUTUBE_VIDEO.src).toContain("youtube.com"); + } finally { + globalThis.YT = originalYT; + } + }); +}); diff --git a/src/test/testPages/stagingVideoTestPage.ts b/src/test/testPages/stagingVideoTestPage.ts new file mode 100644 index 0000000000..ef4d6a945b --- /dev/null +++ b/src/test/testPages/stagingVideoTestPage.ts @@ -0,0 +1,64 @@ +// Copy of staging API payload for the video progress test page. +import { ContentDTO, VideoDTO } from "../../IsaacApiTypes"; + +/** https://www.staging.development.isaaccomputerscience.org/pages/79c4810c-b08b-416e-87ca-aac03a27a4bf */ +export const STAGING_VIDEO_TEST_PAGE_ID = "79c4810c-b08b-416e-87ca-aac03a27a4bf"; + +export const STAGING_WISTIA_VIDEOS = [ + { + src: "https://fast.wistia.net/embed/iframe/ivyatyg59i", + videoId: "ivyatyg59i", + }, + { + src: "https://fast.wistia.net/embed/iframe/2scetc0g1q", + videoId: "2scetc0g1q", + }, + { + src: "https://fast.wistia.net/embed/iframe/2z8sft5l2m", + videoId: "2z8sft5l2m", + }, +] as const; + +/** First Wistia video on the page (alias for older tests). */ +export const STAGING_WISTIA_VIDEO = STAGING_WISTIA_VIDEOS[0]; + +/** + * Valid YouTube watch URL for tests. Staging API has returned an invalid src + * (`watch?v=<2z8sft5l2m>`); update this when the CMS entry is corrected. + */ +export const STAGING_YOUTUBE_VIDEO = { + src: "https://www.youtube.com/watch?v=rA67eCfsg4g", + videoId: "rA67eCfsg4g", +} as const; + +export const STAGING_PAGE_VIDEO_IDS = [ + ...STAGING_WISTIA_VIDEOS.map((v) => v.videoId), + STAGING_YOUTUBE_VIDEO.videoId, +] as const; + +const stagingVideoBlock = (src: string, title?: string): VideoDTO => ({ + id: STAGING_VIDEO_TEST_PAGE_ID, + type: "video", + tags: [], + title, + encoding: "markdown", + children: [], + published: true, + src, +}); + +export const stagingVideoTestPageDoc: ContentDTO = { + id: STAGING_VIDEO_TEST_PAGE_ID, + title: "Test video - 60% progress tracking test", + type: "page", + encoding: "markdown", + canonicalSourceFile: "content/_demo_pages/video60percent_test.json", + published: true, + tags: [], + children: [ + stagingVideoBlock(STAGING_WISTIA_VIDEOS[0].src, "video60percent_test.json"), + stagingVideoBlock(STAGING_WISTIA_VIDEOS[1].src), + stagingVideoBlock(STAGING_WISTIA_VIDEOS[2].src), + stagingVideoBlock(STAGING_YOUTUBE_VIDEO.src), + ], +};