From ec1320c23e2696ab6609d0a68d32058b5204620f Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 27 May 2026 13:54:45 +0100 Subject: [PATCH 01/22] Export the main video functions for testing --- src/app/components/content/IsaacVideo.tsx | 40 +++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 94ec0fc526..60bb955474 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -301,7 +301,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 +321,7 @@ async function logVideoEvent( /** * Create video event details object */ -function createEventDetails( +export function createEventDetails( type: VideoEventDetails["type"], videoUrl: string, videoId: string, @@ -342,6 +342,42 @@ function createEventDetails( return details; } +export function onPlayerStateChange( + event: YouTubeEvent, + pageId?: string, + dispatch?: ReturnType, +): void { + const YT = globalThis.YT; + if (!YT) return; + + const videoUrl = event.target.getVideoUrl(); + const videoPosition = event.target.getCurrentTime(); + let eventType: VideoEventDetails["type"] | null = null; + + switch (event.data) { + case YT.PlayerState.PLAYING: + eventType = "VIDEO_PLAY"; + break; + case YT.PlayerState.PAUSED: + eventType = "VIDEO_PAUSE"; + break; + case YT.PlayerState.ENDED: + eventType = "VIDEO_ENDED"; + break; + default: + return; + } + + const eventDetails = createEventDetails( + eventType, + videoUrl, + pageId, + eventType === "VIDEO_ENDED" ? undefined : videoPosition, + ); + + logVideoEvent(eventDetails, dispatch); +} + export function pauseAllVideos(): void { const iframes = document.querySelectorAll("iframe"); iframes.forEach((iframe) => { From e30f3d6b7653214c498bd2f304dfb5340729ba8e Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 27 May 2026 13:55:50 +0100 Subject: [PATCH 02/22] Add tests for log video events --- .../elements/content/IsaacVideo.test.tsx | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index f7413f9ecf..15a02fbbf9 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,4 +1,6 @@ -import { rewrite } from "../../../../app/components/content/IsaacVideo"; +import { jest } from "@jest/globals"; +import { logVideoEvent, rewrite } from "../../../../app/components/content/IsaacVideo"; +import { ACTION_TYPE, api } from "../../../../app/services"; describe("rewrite", () => { it("parses youtube url to iframe src", () => { @@ -15,3 +17,44 @@ describe("rewrite", () => { ); }); }); + +type VideoEventDispatch = NonNullable[1]>; + +describe("logVideoEvent", () => { + const eventDetails = { + type: "VIDEO_PLAY" as const, + 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 dispatch = jest.fn() as VideoEventDispatch; + const logSpy = jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + + await logVideoEvent(eventDetails, dispatch); + + expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); + expect(logSpy).toHaveBeenCalledWith(eventDetails); + }); + + it("calls only the logger API when dispatch is omitted", async () => { + const dispatch = jest.fn() as VideoEventDispatch; + const logSpy = jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + + await logVideoEvent(eventDetails); + + expect(dispatch).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(); + }); +}); From f5054182d9f7184efc2cafae50f8450ea879176e Mon Sep 17 00:00:00 2001 From: Madhura Date: Thu, 28 May 2026 10:59:06 +0100 Subject: [PATCH 03/22] Add the tests for playerStateChange --- .../elements/content/IsaacVideo.test.tsx | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 15a02fbbf9..2fb62b2284 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,5 +1,5 @@ import { jest } from "@jest/globals"; -import { logVideoEvent, rewrite } from "../../../../app/components/content/IsaacVideo"; +import { logVideoEvent, rewrite, onPlayerStateChange } from "../../../../app/components/content/IsaacVideo"; import { ACTION_TYPE, api } from "../../../../app/services"; describe("rewrite", () => { @@ -42,6 +42,7 @@ describe("logVideoEvent", () => { expect(logSpy).toHaveBeenCalledWith(eventDetails); }); + //Testing that logger API is always called irrespective of whether dispatch is provided or not. it("calls only the logger API when dispatch is omitted", async () => { const dispatch = jest.fn() as VideoEventDispatch; const logSpy = jest.spyOn(api.logger, "log").mockResolvedValue({} as never); @@ -58,3 +59,69 @@ describe("logVideoEvent", () => { await expect(logVideoEvent(eventDetails)).resolves.toBeUndefined(); }); }); + +describe("onPlayerStateChange", () => { + const originalYT = globalThis.YT; + + const mockDispatchFn = jest.fn(); + const mockDispatch = mockDispatchFn as VideoEventDispatch; + const mockPlayer = { + getVideoUrl: () => "https://www.youtube.com/watch?v=test123ABCde", + getCurrentTime: () => 30, + }; + + beforeEach(() => { + mockDispatchFn.mockClear(); + globalThis.YT = { + Player: jest.fn() as never, + ready: jest.fn(), + PlayerState: { + PLAYING: 1, + PAUSED: 2, + ENDED: 0, + }, + }; + jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + }); + + afterEach(() => { + globalThis.YT = originalYT; + jest.restoreAllMocks(); + }); + + const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it.each([ + [1, "VIDEO_PLAY"], + [2, "VIDEO_PAUSE"], + [0, "VIDEO_ENDED"], + ])("maps YouTube player state %i to %s and logs via dispatch", async (playerState, expectedEventType) => { + onPlayerStateChange({ target: mockPlayer, data: playerState }, "page-1", mockDispatch); + await flushPromises(); + + const expectedEventDetails: Record = { + type: expectedEventType, + videoUrl: "https://www.youtube.com/watch?v=test123ABCde", + pageId: "page-1", + }; + if (expectedEventType !== "VIDEO_ENDED") { + expectedEventDetails.videoPosition = 30; + } + + expect(mockDispatchFn).toHaveBeenCalledWith({ + type: ACTION_TYPE.LOG_EVENT, + eventDetails: expectedEventDetails, + }); + }); + + it("does not log for unhandled player states or when the YouTube API is unavailable", async () => { + onPlayerStateChange({ target: mockPlayer, data: 99 }, "page-1", mockDispatch); + await flushPromises(); + expect(mockDispatchFn).not.toHaveBeenCalled(); + + globalThis.YT = undefined; + onPlayerStateChange({ target: mockPlayer, data: 1 }, "page-1", mockDispatch); + await flushPromises(); + expect(mockDispatchFn).not.toHaveBeenCalled(); + }); +}); From 0623b7c89a32349034cd14d10bd0a4a98a5be7ff Mon Sep 17 00:00:00 2001 From: Madhura Date: Thu, 28 May 2026 11:19:20 +0100 Subject: [PATCH 04/22] Add video pause tests to ensure video behaviour --- .../elements/content/IsaacVideo.test.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 2fb62b2284..03564bd23a 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,5 +1,10 @@ import { jest } from "@jest/globals"; -import { logVideoEvent, rewrite, onPlayerStateChange } from "../../../../app/components/content/IsaacVideo"; +import { + logVideoEvent, + rewrite, + onPlayerStateChange, + pauseAllVideos, +} from "../../../../app/components/content/IsaacVideo"; import { ACTION_TYPE, api } from "../../../../app/services"; describe("rewrite", () => { @@ -125,3 +130,22 @@ describe("onPlayerStateChange", () => { expect(mockDispatchFn).not.toHaveBeenCalled(); }); }); + +// video pause tests will ensure thatwhen user switches tabs/closes accordion, playing videos are paused instead of continuing in the background. +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" }), "*"); + + document.body.removeChild(iframe); + }); +}); From dd3d04f11e7028c04607a5de438ff5dba8ea5eb0 Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 29 May 2026 13:07:28 +0100 Subject: [PATCH 05/22] Add changes to video file --- src/app/components/content/IsaacVideo.tsx | 199 +++++++++++++----- .../elements/content/IsaacVideo.test.tsx | 89 +++++++- 2 files changed, 229 insertions(+), 59 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 60bb955474..d43be777b9 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -37,6 +37,28 @@ interface WistiaEventData { [key: string]: unknown; } +interface WistiaMessageProcessingContext { + lastKnownTime: number; + embedSrc: string; + pageId?: string; +} + +interface WistiaMessageProcessingResult { + lastKnownTime: number; + eventDetails?: VideoEventDetails; +} + +interface WistiaMessageProcessingContext { + lastKnownTime: number; + embedSrc: string; + pageId?: string; +} + +interface WistiaMessageProcessingResult { + lastKnownTime: number; + eventDetails?: VideoEventDetails; +} + interface YouTubePlayer { getVideoUrl: () => string; getCurrentTime: () => number; @@ -48,6 +70,33 @@ interface YouTubeEvent { data: number; } +const VIDEO_WATCH_THRESHOLD = 0.6; +const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; +const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; + +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[]; + currentSegmentStart: number | null; + lastKnownTime: number | null; + isPlaying: boolean; + thresholdLogged: boolean; +} + declare global { interface Window { YT?: { @@ -92,24 +141,6 @@ const VIDEO_PLATFORMS = { }, } as const; -const VIDEO_WATCH_THRESHOLD = 0.6; -const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; -const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; - -interface WatchedSegment { - watchedSegmentStart: number; - watchedSegmentEnd: number; -} - -interface VideoProgressState { - totalVideoDurationInSeconds: number | null; - segments: WatchedSegment[]; - currentSegmentStart: number | null; - lastKnownTime: number | null; - isPlaying: boolean; - thresholdLogged: boolean; -} - /** * Check if a URL hostname matches allowed hosts for a platform */ @@ -342,6 +373,78 @@ export 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"; +} + +export function processWistiaMessage( + origin: string, + rawData: unknown, + context: WistiaMessageProcessingContext, +): WistiaMessageProcessingResult { + if (!isValidWistiaOrigin(origin)) { + return { lastKnownTime: context.lastKnownTime }; + } + + 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 { lastKnownTime: context.lastKnownTime }; + } + + const eventName = String(data.args[0]).toLowerCase(); + if (isWistiaTimeChangeEvent(eventName)) { + return { + lastKnownTime: updateWistiaTimeFromArgs(context.lastKnownTime, data.args), + }; + } + + const nextKnownTime = updateWistiaTimeFromEventData(context.lastKnownTime, (data.args[1] || {}) as WistiaEventData); + const eventType = WISTIA_EVENT_TYPE_MAP[eventName]; + if (!eventType) { + return { lastKnownTime: nextKnownTime }; + } + + return { + lastKnownTime: nextKnownTime, + eventDetails: createEventDetails( + eventType, + context.embedSrc, + context.pageId, + 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 onPlayerStateChange( event: YouTubeEvent, pageId?: string, @@ -545,6 +648,7 @@ export function IsaacVideo(props: IsaacVideoProps) { if (!isWistia || !wistiaVideoId || !wistiaIframeRef.current) return; const iframe = wistiaIframeRef.current; + let lastKnownTime = 0; // Event type mapping for video events const eventTypeMap: Record = { @@ -562,47 +666,36 @@ export function IsaacVideo(props: IsaacVideoProps) { ); }; - 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 updateTimeFromEventData = (eventData: WistiaEventData): void => { + if (typeof eventData.seconds === "number") { + lastKnownTime = eventData.seconds; + } else if (typeof eventData.secondsWatched === "number") { + lastKnownTime = eventData.secondsWatched; + } }; - const updateTimeFromArgs = (args: Array>): number | null => { + const updateTimeFromArgs = (args: Array>): void => { if (typeof args[1] === "number") { - return args[1]; + lastKnownTime = args[1]; } else if (typeof (args[1] as WistiaEventData)?.seconds === "number") { - return (args[1] as WistiaEventData).seconds as number; + lastKnownTime = (args[1] as WistiaEventData).seconds as number; } - return null; - }; - - const getTotalDurationInSecondsForWistiaVideoFromEventData = (eventData: WistiaEventData): number | null => { - const videoDuration = eventData["duration"]; - return isValidNumber(videoDuration) ? videoDuration : null; }; const handleVideoEvent = (eventName: string, eventData: WistiaEventData): void => { - const videoUrl = embedSrc || ""; - const eventTime = updateTimeFromEventData(eventData) ?? progressReference.current.lastKnownTime ?? 0; - const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); - if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { - setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); - } + updateTimeFromEventData(eventData); const eventType = eventTypeMap[eventName.toLowerCase()]; if (!eventType) return; - if (eventType === "VIDEO_PLAY") { - progressReference.current.isPlaying = true; - startCurrentSegment(eventTime); - } else { - progressReference.current.isPlaying = false; - closeCurrentSegment(eventTime, videoUrl, wistiaVideoId); - progressReference.current.lastKnownTime = eventTime; - } + const eventDetails = createEventDetails( + eventType, + embedSrc || "", + pageId, + eventType === "VIDEO_ENDED" ? undefined : lastKnownTime, + ); - logPlayerEvent(eventType, videoUrl, wistiaVideoId, eventType === "VIDEO_ENDED" ? undefined : eventTime); + logVideoEvent(eventDetails, dispatch); }; const isTimeChangeEvent = (eventName: string): boolean => { @@ -612,9 +705,6 @@ export function IsaacVideo(props: IsaacVideoProps) { 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; - try { const data: WistiaPostMessageData = typeof event.data === "string" ? JSON.parse(event.data) : event.data; @@ -626,14 +716,7 @@ export function IsaacVideo(props: IsaacVideoProps) { 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); - } + updateTimeFromArgs(data.args); } else { handleVideoEvent(eventName, eventData); } @@ -653,7 +736,7 @@ 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"]; + const eventsToTrack = ["play", "pause", "end", "timechange", "secondchange"]; eventsToTrack.forEach((eventName) => { iframe.contentWindow?.postMessage( JSON.stringify({ diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 03564bd23a..b3779ae1dd 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,9 +1,14 @@ import { jest } from "@jest/globals"; import { + isValidWistiaOrigin, + isWistiaTimeChangeEvent, logVideoEvent, - rewrite, onPlayerStateChange, pauseAllVideos, + processWistiaMessage, + rewrite, + updateWistiaTimeFromArgs, + updateWistiaTimeFromEventData, } from "../../../../app/components/content/IsaacVideo"; import { ACTION_TYPE, api } from "../../../../app/services"; @@ -131,6 +136,88 @@ describe("onPlayerStateChange", () => { }); }); +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", 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", pageId: "page-1" }, + ); + expect(playResult).toEqual({ + lastKnownTime: 21, + eventDetails: { + type: "VIDEO_PLAY", + 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", pageId: "page-1" }, + ); + expect(endedResult).toEqual({ + lastKnownTime: 60, + eventDetails: { + type: "VIDEO_ENDED", + 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", 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", pageId: "page-1" }, + ); + expect(methodRejected).toEqual({ lastKnownTime: 7 }); + }); +}); + // video pause tests will ensure thatwhen user switches tabs/closes accordion, playing videos are paused instead of continuing in the background. describe("pauseAllVideos", () => { it("sends pause commands to all iframe content windows", () => { From b643461bbfe8edc7a6e360da594de146cc1cf969 Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 29 May 2026 14:49:30 +0100 Subject: [PATCH 06/22] Add missing changes --- src/app/components/content/IsaacVideo.tsx | 118 ++++++++---------- .../elements/content/IsaacVideo.test.tsx | 45 +++++-- 2 files changed, 89 insertions(+), 74 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index d43be777b9..1a4d5e54a4 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -40,17 +40,7 @@ interface WistiaEventData { interface WistiaMessageProcessingContext { lastKnownTime: number; embedSrc: string; - pageId?: string; -} - -interface WistiaMessageProcessingResult { - lastKnownTime: number; - eventDetails?: VideoEventDetails; -} - -interface WistiaMessageProcessingContext { - lastKnownTime: number; - embedSrc: string; + videoId: string; pageId?: string; } @@ -430,12 +420,10 @@ export function processWistiaMessage( return { lastKnownTime: nextKnownTime, - eventDetails: createEventDetails( - eventType, - context.embedSrc, - context.pageId, - eventType === "VIDEO_ENDED" ? undefined : nextKnownTime, - ), + eventDetails: createEventDetails(eventType, context.embedSrc, context.videoId, { + pageId: context.pageId, + videoPosition: eventType === "VIDEO_ENDED" ? undefined : nextKnownTime, + }), }; } @@ -447,6 +435,7 @@ export function isValidWistiaOrigin(origin: string): boolean { export function onPlayerStateChange( event: YouTubeEvent, + videoId: string, pageId?: string, dispatch?: ReturnType, ): void { @@ -471,12 +460,10 @@ export function onPlayerStateChange( return; } - const eventDetails = createEventDetails( - eventType, - videoUrl, + const eventDetails = createEventDetails(eventType, videoUrl, videoId, { pageId, - eventType === "VIDEO_ENDED" ? undefined : videoPosition, - ); + videoPosition: eventType === "VIDEO_ENDED" ? undefined : videoPosition, + }); logVideoEvent(eventDetails, dispatch); } @@ -648,63 +635,57 @@ export function IsaacVideo(props: IsaacVideoProps) { if (!isWistia || !wistiaVideoId || !wistiaIframeRef.current) return; const iframe = wistiaIframeRef.current; - let lastKnownTime = 0; - - // 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 updateTimeFromEventData = (eventData: WistiaEventData): void => { - if (typeof eventData.seconds === "number") { - lastKnownTime = eventData.seconds; - } else if (typeof eventData.secondsWatched === "number") { - lastKnownTime = eventData.secondsWatched; + const updateTimeFromArgs = (args: Array>): number | null => { + 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 null; }; - const updateTimeFromArgs = (args: Array>): void => { - if (typeof args[1] === "number") { - lastKnownTime = args[1]; - } else if (typeof (args[1] as WistiaEventData)?.seconds === "number") { - lastKnownTime = (args[1] as WistiaEventData).seconds as number; - } + const getTotalDurationInSecondsForWistiaVideoFromEventData = (eventData: WistiaEventData): number | null => { + const videoDuration = eventData["duration"]; + return isValidNumber(videoDuration) ? videoDuration : null; }; const handleVideoEvent = (eventName: string, eventData: WistiaEventData): void => { - updateTimeFromEventData(eventData); + const videoUrl = embedSrc || ""; + const eventTime = updateTimeFromEventData(eventData) ?? progressReference.current.lastKnownTime ?? 0; + const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); + if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { + setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); + } - const eventType = eventTypeMap[eventName.toLowerCase()]; + const eventType = WISTIA_EVENT_TYPE_MAP[eventName.toLowerCase()]; if (!eventType) return; - const eventDetails = createEventDetails( - eventType, - embedSrc || "", - pageId, - eventType === "VIDEO_ENDED" ? undefined : lastKnownTime, - ); - - logVideoEvent(eventDetails, dispatch); - }; + if (eventType === "VIDEO_PLAY") { + progressReference.current.isPlaying = true; + startCurrentSegment(eventTime); + } else { + progressReference.current.isPlaying = false; + closeCurrentSegment(eventTime, videoUrl, wistiaVideoId); + progressReference.current.lastKnownTime = eventTime; + } - const isTimeChangeEvent = (eventName: string): boolean => { - return eventName === "timechange" || eventName === "secondchange"; + logPlayerEvent(eventType, videoUrl, wistiaVideoId, eventType === "VIDEO_ENDED" ? undefined : eventTime); }; const handleWistiaMessage = (event: MessageEvent): void => { if (!isValidWistiaOrigin(event.origin)) return; + // Only accept messages from this iframe's content window (avoids cross-video / XSS issues). + if (event.source !== iframe.contentWindow) return; + try { const data: WistiaPostMessageData = typeof event.data === "string" ? JSON.parse(event.data) : event.data; @@ -715,8 +696,15 @@ export function IsaacVideo(props: IsaacVideoProps) { const eventName = data.args[0] as string; const eventData = (data.args[1] || {}) as WistiaEventData; - if (isTimeChangeEvent(eventName)) { - updateTimeFromArgs(data.args); + if (isWistiaTimeChangeEvent(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); } @@ -735,8 +723,7 @@ 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"]; + const eventsToTrack = ["play", "pause", "end", "timechange", "secondchange", "durationchange"]; eventsToTrack.forEach((eventName) => { iframe.contentWindow?.postMessage( JSON.stringify({ @@ -749,7 +736,6 @@ export function IsaacVideo(props: IsaacVideoProps) { } }; - // Give iframe time to load const timer = setTimeout(setupWistiaBindings, 1000); return () => { diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index b3779ae1dd..2252cc8013 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -33,6 +33,7 @@ type VideoEventDispatch = NonNullable[1]>; describe("logVideoEvent", () => { const eventDetails = { type: "VIDEO_PLAY" as const, + videoId: "test123ABCde", videoUrl: "https://www.youtube.com/watch?v=test123ABCde", pageId: "page-1", videoPosition: 10, @@ -78,6 +79,7 @@ describe("onPlayerStateChange", () => { const mockPlayer = { getVideoUrl: () => "https://www.youtube.com/watch?v=test123ABCde", getCurrentTime: () => 30, + getDuration: () => 120, }; beforeEach(() => { @@ -106,7 +108,7 @@ describe("onPlayerStateChange", () => { [2, "VIDEO_PAUSE"], [0, "VIDEO_ENDED"], ])("maps YouTube player state %i to %s and logs via dispatch", async (playerState, expectedEventType) => { - onPlayerStateChange({ target: mockPlayer, data: playerState }, "page-1", mockDispatch); + onPlayerStateChange({ target: mockPlayer, data: playerState }, "test123ABCde", "page-1", mockDispatch); await flushPromises(); const expectedEventDetails: Record = { @@ -125,12 +127,12 @@ describe("onPlayerStateChange", () => { }); it("does not log for unhandled player states or when the YouTube API is unavailable", async () => { - onPlayerStateChange({ target: mockPlayer, data: 99 }, "page-1", mockDispatch); + onPlayerStateChange({ target: mockPlayer, data: 99 }, "test123ABCde", "page-1", mockDispatch); await flushPromises(); expect(mockDispatchFn).not.toHaveBeenCalled(); globalThis.YT = undefined; - onPlayerStateChange({ target: mockPlayer, data: 1 }, "page-1", mockDispatch); + onPlayerStateChange({ target: mockPlayer, data: 1 }, "test123ABCde", "page-1", mockDispatch); await flushPromises(); expect(mockDispatchFn).not.toHaveBeenCalled(); }); @@ -164,7 +166,12 @@ describe("Wistia helpers", () => { const result = processWistiaMessage( "https://fast.wistia.net", JSON.stringify({ method: "_trigger", args: ["timechange", 17] }), - { lastKnownTime: 3, embedSrc: "https://fast.wistia.net/embed/iframe/abc123", pageId: "page-1" }, + { + lastKnownTime: 3, + embedSrc: "https://fast.wistia.net/embed/iframe/abc123", + videoId: "abc123", + pageId: "page-1", + }, ); expect(result).toEqual({ lastKnownTime: 17 }); @@ -174,12 +181,18 @@ describe("Wistia helpers", () => { 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", pageId: "page-1" }, + { + 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, @@ -189,12 +202,18 @@ describe("Wistia helpers", () => { 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", pageId: "page-1" }, + { + 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", }, @@ -205,14 +224,24 @@ describe("Wistia helpers", () => { const originRejected = processWistiaMessage( "https://youtube.com", JSON.stringify({ method: "_trigger", args: ["play", { seconds: 12 }] }), - { lastKnownTime: 7, embedSrc: "https://fast.wistia.net/embed/iframe/abc123", pageId: "page-1" }, + { + 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", pageId: "page-1" }, + { + lastKnownTime: 7, + embedSrc: "https://fast.wistia.net/embed/iframe/abc123", + videoId: "abc123", + pageId: "page-1", + }, ); expect(methodRejected).toEqual({ lastKnownTime: 7 }); }); From 4343091890f2b0101434953d980acd17f83918fe Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 29 May 2026 16:16:43 +0100 Subject: [PATCH 07/22] Add the deleted changes from merge --- src/app/components/content/IsaacVideo.tsx | 93 ++++--------- .../elements/content/IsaacVideo.test.tsx | 124 ++++++++++++++---- 2 files changed, 125 insertions(+), 92 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 1a4d5e54a4..631199e9d2 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -60,33 +60,6 @@ interface YouTubeEvent { data: number; } -const VIDEO_WATCH_THRESHOLD = 0.6; -const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; -const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; - -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[]; - currentSegmentStart: number | null; - lastKnownTime: number | null; - isPlaying: boolean; - thresholdLogged: boolean; -} - declare global { interface Window { YT?: { @@ -131,6 +104,33 @@ const VIDEO_PLATFORMS = { }, } as const; +const VIDEO_WATCH_THRESHOLD = 0.6; +const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; +const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; + +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[]; + currentSegmentStart: number | null; + lastKnownTime: number | null; + isPlaying: boolean; + thresholdLogged: boolean; +} + /** * Check if a URL hostname matches allowed hosts for a platform */ @@ -433,41 +433,6 @@ export function isValidWistiaOrigin(origin: string): boolean { ); } -export function onPlayerStateChange( - event: YouTubeEvent, - videoId: string, - pageId?: string, - dispatch?: ReturnType, -): void { - const YT = globalThis.YT; - if (!YT) return; - - const videoUrl = event.target.getVideoUrl(); - const videoPosition = event.target.getCurrentTime(); - let eventType: VideoEventDetails["type"] | null = null; - - switch (event.data) { - case YT.PlayerState.PLAYING: - eventType = "VIDEO_PLAY"; - break; - case YT.PlayerState.PAUSED: - eventType = "VIDEO_PAUSE"; - break; - case YT.PlayerState.ENDED: - eventType = "VIDEO_ENDED"; - break; - default: - return; - } - - const eventDetails = createEventDetails(eventType, videoUrl, videoId, { - pageId, - videoPosition: eventType === "VIDEO_ENDED" ? undefined : videoPosition, - }); - - logVideoEvent(eventDetails, dispatch); -} - export function pauseAllVideos(): void { const iframes = document.querySelectorAll("iframe"); iframes.forEach((iframe) => { @@ -645,8 +610,7 @@ export function IsaacVideo(props: IsaacVideoProps) { const updateTimeFromArgs = (args: Array>): number | null => { if (typeof args[1] === "number") { return args[1]; - } - if (typeof (args[1] as WistiaEventData)?.seconds === "number") { + } else if (typeof (args[1] as WistiaEventData)?.seconds === "number") { return (args[1] as WistiaEventData).seconds as number; } return null; @@ -736,6 +700,7 @@ export function IsaacVideo(props: IsaacVideoProps) { } }; + // Give iframe time to load const timer = setTimeout(setupWistiaBindings, 1000); return () => { diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 2252cc8013..3537bd022c 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,9 +1,11 @@ +import React from "react"; +import { act } from "@testing-library/react"; import { jest } from "@jest/globals"; import { + IsaacVideo, isValidWistiaOrigin, isWistiaTimeChangeEvent, logVideoEvent, - onPlayerStateChange, pauseAllVideos, processWistiaMessage, rewrite, @@ -11,6 +13,8 @@ import { updateWistiaTimeFromEventData, } from "../../../../app/components/content/IsaacVideo"; import { ACTION_TYPE, api } from "../../../../app/services"; +import { renderTestEnvironment } from "../../../utils"; +import { store } from "../../../../app/state"; describe("rewrite", () => { it("parses youtube url to iframe src", () => { @@ -53,7 +57,6 @@ describe("logVideoEvent", () => { expect(logSpy).toHaveBeenCalledWith(eventDetails); }); - //Testing that logger API is always called irrespective of whether dispatch is provided or not. it("calls only the logger API when dispatch is omitted", async () => { const dispatch = jest.fn() as VideoEventDispatch; const logSpy = jest.spyOn(api.logger, "log").mockResolvedValue({} as never); @@ -71,29 +74,51 @@ describe("logVideoEvent", () => { }); }); -describe("onPlayerStateChange", () => { +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 mockDispatchFn = jest.fn(); - const mockDispatch = mockDispatchFn as VideoEventDispatch; const mockPlayer = { - getVideoUrl: () => "https://www.youtube.com/watch?v=test123ABCde", + getVideoUrl: () => youtubeSrc, getCurrentTime: () => 30, getDuration: () => 120, }; + class MockYTPlayer { + constructor(_node: HTMLElement, config: CapturedYouTubePlayerConfig) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: mockPlayer, data: 0 }); + } + } + + const IsaacVideoHarness = () => ; + + const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + beforeEach(() => { - mockDispatchFn.mockClear(); + capturedPlayerConfig = null; + jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + jest.spyOn(store, "dispatch"); + globalThis.YT = { - Player: jest.fn() as never, - ready: jest.fn(), + Player: MockYTPlayer as never, + ready: (callback: () => void) => callback(), PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0, }, }; - jest.spyOn(api.logger, "log").mockResolvedValue({} as never); }); afterEach(() => { @@ -101,40 +126,84 @@ describe("onPlayerStateChange", () => { jest.restoreAllMocks(); }); - const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + 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"], - [2, "VIDEO_PAUSE"], - [0, "VIDEO_ENDED"], - ])("maps YouTube player state %i to %s and logs via dispatch", async (playerState, expectedEventType) => { - onPlayerStateChange({ target: mockPlayer, data: playerState }, "test123ABCde", "page-1", mockDispatch); + [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, - videoUrl: "https://www.youtube.com/watch?v=test123ABCde", - pageId: "page-1", + videoId: youtubeVideoId, + videoUrl: youtubeSrc, + videoDurationSeconds: 120, }; - if (expectedEventType !== "VIDEO_ENDED") { - expectedEventDetails.videoPosition = 30; + if (videoPosition !== undefined) { + expectedEventDetails.videoPosition = videoPosition; } - expect(mockDispatchFn).toHaveBeenCalledWith({ + expect(store.dispatch).toHaveBeenCalledWith({ type: ACTION_TYPE.LOG_EVENT, eventDetails: expectedEventDetails, }); }); - it("does not log for unhandled player states or when the YouTube API is unavailable", async () => { - onPlayerStateChange({ target: mockPlayer, data: 99 }, "test123ABCde", "page-1", mockDispatch); + it("onStateChange ignores unhandled player states", async () => { + renderYouTubeVideo(); await flushPromises(); - expect(mockDispatchFn).not.toHaveBeenCalled(); - globalThis.YT = undefined; - onPlayerStateChange({ target: mockPlayer, data: 1 }, "test123ABCde", "page-1", mockDispatch); + const dispatchMock = store.dispatch as jest.Mock; + dispatchMock.mockClear(); + + await act(async () => { + capturedPlayerConfig?.events?.onStateChange?.({ target: mockPlayer, data: 99 }); + }); await flushPromises(); - expect(mockDispatchFn).not.toHaveBeenCalled(); + + expect(dispatchMock).not.toHaveBeenCalled(); }); }); @@ -247,7 +316,6 @@ describe("Wistia helpers", () => { }); }); -// video pause tests will ensure thatwhen user switches tabs/closes accordion, playing videos are paused instead of continuing in the background. describe("pauseAllVideos", () => { it("sends pause commands to all iframe content windows", () => { const postMessage = jest.fn(); From c16d47796c54034c6e579a130b53bf004b684f9e Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 1 Jun 2026 12:40:43 +0100 Subject: [PATCH 08/22] Export and test main functions for segments --- src/app/components/content/IsaacVideo.tsx | 27 +- .../elements/content/IsaacVideo.test.tsx | 323 ++++++++++++++++++ 2 files changed, 338 insertions(+), 12 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 631199e9d2..c4dc99244b 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -108,7 +108,7 @@ 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; } @@ -196,26 +196,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); @@ -239,19 +239,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), @@ -278,7 +278,7 @@ function loadVideoProgress(userStorageScope: string, videoId: string): VideoProg } } -function createEmptyVideoProgressState(): VideoProgressState { +export function createEmptyVideoProgressState(): VideoProgressState { return { totalVideoDurationInSeconds: null, segments: [], @@ -289,7 +289,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(); } @@ -305,7 +308,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 = { diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 3537bd022c..85d9087c53 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -2,13 +2,24 @@ import React from "react"; import { act } from "@testing-library/react"; import { jest } from "@jest/globals"; 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"; @@ -316,6 +327,318 @@ describe("Wistia helpers", () => { }); }); +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(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, 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("hydrates 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(); + }); +}); + +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(); From 7ff761ccd80b8c0ec8cf681b2f18719cbaaf9381 Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 1 Jun 2026 17:14:23 +0100 Subject: [PATCH 09/22] Update test comment --- src/test/components/elements/content/IsaacVideo.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 85d9087c53..11365c09f8 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -494,7 +494,7 @@ describe("createInitialVideoProgressState", () => { expect(createInitialVideoProgressState(userStorageScope, null)).toEqual(createEmptyVideoProgressState()); }); - it("hydrates state from localStorage when progress exists", () => { + it("populates state from localStorage when progress exists", () => { localStorage.setItem( storageKey, JSON.stringify({ From 54f0c95750b2630b55f8620474affd04e27a630e Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 3 Jun 2026 11:57:24 +0100 Subject: [PATCH 10/22] Add tests for wistia video operations and tracking on test page --- .../elements/content/IsaacVideo.test.tsx | 126 ++++++++++++++++++ .../pages/Video60PercentTestPage.test.tsx | 36 +++++ src/test/testPages/stagingVideoTestPage.ts | 32 +++++ 3 files changed, 194 insertions(+) create mode 100644 src/test/pages/Video60PercentTestPage.test.tsx create mode 100644 src/test/testPages/stagingVideoTestPage.ts diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 11365c09f8..a6d1012e86 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -24,6 +24,7 @@ import { updateWistiaTimeFromEventData, } from "../../../../app/components/content/IsaacVideo"; import { ACTION_TYPE, api } from "../../../../app/services"; +import { STAGING_VIDEO_TEST_PAGE_ID, STAGING_WISTIA_VIDEO } from "../../../testPages/stagingVideoTestPage"; import { renderTestEnvironment } from "../../../utils"; import { store } from "../../../../app/state"; @@ -603,6 +604,131 @@ describe("loadVideoProgress", () => { }); }); +describe("staging video test page", () => { + const stagingWistiaEmbedSrc = rewrite(STAGING_WISTIA_VIDEO.src)!; + const stagingWistiaMessageContext = { + lastKnownTime: 0, + embedSrc: stagingWistiaEmbedSrc, + videoId: STAGING_WISTIA_VIDEO.videoId, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + }; + + it("rewrites the staging Wistia embed src", () => { + expect(stagingWistiaEmbedSrc).toContain(`embed/iframe/${STAGING_WISTIA_VIDEO.videoId}`); + expect(stagingWistiaEmbedSrc).toContain("videoFoam=true"); + }); + + it("extracts the staging Wistia video id from the embed URL", () => { + expect(extractVideoId(stagingWistiaEmbedSrc, /embed\/iframe\/([a-zA-Z0-9]+)/)).toBe(STAGING_WISTIA_VIDEO.videoId); + }); + + it("maps staging Wistia play to VIDEO_PLAY with pageId", () => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["play", { seconds: 10, duration: 100 }] }), + stagingWistiaMessageContext, + ); + + expect(result).toEqual({ + lastKnownTime: 10, + eventDetails: { + type: "VIDEO_PLAY", + videoId: STAGING_WISTIA_VIDEO.videoId, + videoUrl: stagingWistiaEmbedSrc, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + videoPosition: 10, + }, + }); + }); + + it("maps staging Wistia pause to VIDEO_PAUSE with pageId", () => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["pause", { seconds: 25 }] }), + { ...stagingWistiaMessageContext, lastKnownTime: 25 }, + ); + + expect(result.eventDetails).toMatchObject({ + type: "VIDEO_PAUSE", + videoId: STAGING_WISTIA_VIDEO.videoId, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + videoPosition: 25, + }); + }); + + it("updates time on staging timechange without emitting a video event", () => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["timechange", 30] }), + { ...stagingWistiaMessageContext, lastKnownTime: 20 }, + ); + + expect(result).toEqual({ lastKnownTime: 30 }); + expect(result.eventDetails).toBeUndefined(); + }); + + it("maps staging Wistia ended to VIDEO_ENDED with pageId", () => { + const result = processWistiaMessage( + "https://fast.wistia.net", + JSON.stringify({ method: "_trigger", args: ["ended", { seconds: 100 }] }), + { ...stagingWistiaMessageContext, lastKnownTime: 100 }, + ); + + expect(result.eventDetails).toMatchObject({ + type: "VIDEO_ENDED", + videoId: STAGING_WISTIA_VIDEO.videoId, + pageId: STAGING_VIDEO_TEST_PAGE_ID, + }); + expect(result.eventDetails?.videoPosition).toBeUndefined(); + }); +}); + +describe("staging page progress persistence", () => { + const userStorageScope = "42"; + const storageKey = getVideoProgressStorageKey(userStorageScope, STAGING_WISTIA_VIDEO.videoId); + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("stores merged segments for the staging Wistia video", () => { + saveVideoProgress(userStorageScope, STAGING_WISTIA_VIDEO.videoId, { + ...createEmptyVideoProgressState(), + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 65 }], + }); + + const loaded = loadVideoProgress(userStorageScope, STAGING_WISTIA_VIDEO.videoId); + expect(loaded?.totalVideoDurationInSeconds).toBe(100); + expect(getUniqueWatchedSeconds(loaded!.segments)).toBe(65); + expect(getWatchPercent(65, 100)).toBeGreaterThanOrEqual(0.6); + }); + + it("updates initial state for the staging Wistia video", () => { + localStorage.setItem( + storageKey, + JSON.stringify({ + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 60 }], + thresholdLogged: true, + }), + ); + + expect(createInitialVideoProgressState(userStorageScope, STAGING_WISTIA_VIDEO.videoId)).toEqual({ + totalVideoDurationInSeconds: 100, + segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 60 }], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: true, + }); + }); +}); + describe("saveVideoProgress", () => { const userStorageScope = "user-3"; const videoId = "vid-3"; diff --git a/src/test/pages/Video60PercentTestPage.test.tsx b/src/test/pages/Video60PercentTestPage.test.tsx new file mode 100644 index 0000000000..945c83dda9 --- /dev/null +++ b/src/test/pages/Video60PercentTestPage.test.tsx @@ -0,0 +1,36 @@ +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_VIDEO, + stagingVideoTestPageDoc, +} from "../testPages/stagingVideoTestPage"; + +describe("staging video test page", () => { + it("renders the staging Wistia video from the page API payload", async () => { + renderTestEnvironment({ + role: "STUDENT", + PageComponent: Generic, + 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)), + ), + ], + }); + + await waitFor(() => { + expect(screen.getByTitle(/Embedded video/i)).toBeInTheDocument(); + }); + + const iframe = document.querySelector("iframe"); + expect(iframe?.getAttribute("src")).toContain(STAGING_WISTIA_VIDEO.videoId); + }); +}); diff --git a/src/test/testPages/stagingVideoTestPage.ts b/src/test/testPages/stagingVideoTestPage.ts new file mode 100644 index 0000000000..ec5af2ec76 --- /dev/null +++ b/src/test/testPages/stagingVideoTestPage.ts @@ -0,0 +1,32 @@ +// This page holds a copy of what staging's API returns when test page is loaded +import { ContentDTO } from "../../IsaacApiTypes"; + +/** Staging: 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_VIDEO = { + src: "https://fast.wistia.net/embed/iframe/ivyatyg59i", + videoId: "ivyatyg59i", +} as const; + +export const stagingWistiaVideoBlock: ContentDTO = { + id: STAGING_VIDEO_TEST_PAGE_ID, + type: "video", + tags: [], + title: "video60percent_test.json", + encoding: "markdown", + children: [], + published: true, + src: STAGING_WISTIA_VIDEO.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: [stagingWistiaVideoBlock], +}; From 25bbd006de7ace50026109836011952a5ece63ad Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 3 Jun 2026 13:07:19 +0100 Subject: [PATCH 11/22] Copy API response for test video page with multiple videos on it --- src/test/testPages/stagingVideoTestPage.ts | 54 +++++++++++++++++----- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/test/testPages/stagingVideoTestPage.ts b/src/test/testPages/stagingVideoTestPage.ts index ec5af2ec76..ef4d6a945b 100644 --- a/src/test/testPages/stagingVideoTestPage.ts +++ b/src/test/testPages/stagingVideoTestPage.ts @@ -1,24 +1,51 @@ -// This page holds a copy of what staging's API returns when test page is loaded -import { ContentDTO } from "../../IsaacApiTypes"; +// Copy of staging API payload for the video progress test page. +import { ContentDTO, VideoDTO } from "../../IsaacApiTypes"; -/** Staging: https://www.staging.development.isaaccomputerscience.org/pages/79c4810c-b08b-416e-87ca-aac03a27a4bf */ +/** 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_VIDEO = { - src: "https://fast.wistia.net/embed/iframe/ivyatyg59i", - videoId: "ivyatyg59i", +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 stagingWistiaVideoBlock: ContentDTO = { +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: "video60percent_test.json", + title, encoding: "markdown", children: [], published: true, - src: STAGING_WISTIA_VIDEO.src, -}; + src, +}); export const stagingVideoTestPageDoc: ContentDTO = { id: STAGING_VIDEO_TEST_PAGE_ID, @@ -28,5 +55,10 @@ export const stagingVideoTestPageDoc: ContentDTO = { canonicalSourceFile: "content/_demo_pages/video60percent_test.json", published: true, tags: [], - children: [stagingWistiaVideoBlock], + 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), + ], }; From 2e44ce4d23556fc0e7e242b7e740776a0b1ddde0 Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 3 Jun 2026 14:32:00 +0100 Subject: [PATCH 12/22] Add tests with multiple videos on the page --- .../elements/content/IsaacVideo.test.tsx | 299 +++++++++++++++--- .../pages/Video60PercentTestPage.test.tsx | 43 ++- 2 files changed, 287 insertions(+), 55 deletions(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index a6d1012e86..c9840b0514 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -24,7 +24,13 @@ import { updateWistiaTimeFromEventData, } from "../../../../app/components/content/IsaacVideo"; import { ACTION_TYPE, api } from "../../../../app/services"; -import { STAGING_VIDEO_TEST_PAGE_ID, STAGING_WISTIA_VIDEO } from "../../../testPages/stagingVideoTestPage"; +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 { store } from "../../../../app/state"; @@ -604,88 +610,230 @@ describe("loadVideoProgress", () => { }); }); -describe("staging video test page", () => { - const stagingWistiaEmbedSrc = rewrite(STAGING_WISTIA_VIDEO.src)!; - const stagingWistiaMessageContext = { - lastKnownTime: 0, - embedSrc: stagingWistiaEmbedSrc, - videoId: STAGING_WISTIA_VIDEO.videoId, - pageId: STAGING_VIDEO_TEST_PAGE_ID, - }; - - it("rewrites the staging Wistia embed src", () => { - expect(stagingWistiaEmbedSrc).toContain(`embed/iframe/${STAGING_WISTIA_VIDEO.videoId}`); - expect(stagingWistiaEmbedSrc).toContain("videoFoam=true"); - }); - - it("extracts the staging Wistia video id from the embed URL", () => { - expect(extractVideoId(stagingWistiaEmbedSrc, /embed\/iframe\/([a-zA-Z0-9]+)/)).toBe(STAGING_WISTIA_VIDEO.videoId); - }); +const stagingWistiaMessageContext = (video: (typeof STAGING_WISTIA_VIDEOS)[number], lastKnownTime = 0) => ({ + lastKnownTime, + embedSrc: rewrite(video.src)!, + videoId: video.videoId, + pageId: STAGING_VIDEO_TEST_PAGE_ID, +}); - it("maps staging Wistia play to VIDEO_PLAY with pageId", () => { +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 }] }), - stagingWistiaMessageContext, + context, ); expect(result).toEqual({ lastKnownTime: 10, eventDetails: { type: "VIDEO_PLAY", - videoId: STAGING_WISTIA_VIDEO.videoId, - videoUrl: stagingWistiaEmbedSrc, + videoId: video.videoId, + videoUrl: context.embedSrc, pageId: STAGING_VIDEO_TEST_PAGE_ID, videoPosition: 10, }, }); }); - it("maps staging Wistia pause to VIDEO_PAUSE with pageId", () => { + 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, lastKnownTime: 25 }, + stagingWistiaMessageContext(video, 25), ); expect(result.eventDetails).toMatchObject({ type: "VIDEO_PAUSE", - videoId: STAGING_WISTIA_VIDEO.videoId, + videoId: video.videoId, pageId: STAGING_VIDEO_TEST_PAGE_ID, videoPosition: 25, }); }); - it("updates time on staging timechange without emitting a video event", () => { + 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, lastKnownTime: 20 }, + stagingWistiaMessageContext(video, 20), ); expect(result).toEqual({ lastKnownTime: 30 }); expect(result.eventDetails).toBeUndefined(); }); - it("maps staging Wistia ended to VIDEO_ENDED with pageId", () => { + 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, lastKnownTime: 100 }, + stagingWistiaMessageContext(video, 100), ); expect(result.eventDetails).toMatchObject({ type: "VIDEO_ENDED", - videoId: STAGING_WISTIA_VIDEO.videoId, + videoId: video.videoId, pageId: STAGING_VIDEO_TEST_PAGE_ID, }); expect(result.eventDetails?.videoPosition).toBeUndefined(); }); }); -describe("staging page progress persistence", () => { +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, + }; + + class MockYTPlayer { + constructor(_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; + jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + jest.spyOn(store, "dispatch"); + globalThis.YT = { + Player: MockYTPlayer as never, + 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"; - const storageKey = getVideoProgressStorageKey(userStorageScope, STAGING_WISTIA_VIDEO.videoId); beforeEach(() => { localStorage.clear(); @@ -695,38 +843,89 @@ describe("staging page progress persistence", () => { localStorage.clear(); }); - it("stores merged segments for the staging Wistia video", () => { - saveVideoProgress(userStorageScope, STAGING_WISTIA_VIDEO.videoId, { + 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, STAGING_WISTIA_VIDEO.videoId); + 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("updates initial state for the staging Wistia video", () => { - localStorage.setItem( - storageKey, - JSON.stringify({ - totalVideoDurationInSeconds: 100, - segments: [{ watchedSegmentStart: 0, watchedSegmentEnd: 60 }], - thresholdLogged: true, - }), - ); + 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); + }); - expect(createInitialVideoProgressState(userStorageScope, STAGING_WISTIA_VIDEO.videoId)).toEqual({ + 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: 60 }], - currentSegmentStart: null, - lastKnownTime: null, - isPlaying: false, + 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, + }); + }, + ); }); describe("saveVideoProgress", () => { diff --git a/src/test/pages/Video60PercentTestPage.test.tsx b/src/test/pages/Video60PercentTestPage.test.tsx index 945c83dda9..a8c7c976a5 100644 --- a/src/test/pages/Video60PercentTestPage.test.tsx +++ b/src/test/pages/Video60PercentTestPage.test.tsx @@ -5,12 +5,13 @@ import { API_PATH } from "../../app/services"; import { renderTestEnvironment } from "../utils"; import { STAGING_VIDEO_TEST_PAGE_ID, - STAGING_WISTIA_VIDEO, + STAGING_WISTIA_VIDEOS, + STAGING_YOUTUBE_VIDEO, stagingVideoTestPageDoc, } from "../testPages/stagingVideoTestPage"; describe("staging video test page", () => { - it("renders the staging Wistia video from the page API payload", async () => { + const renderStagingPage = () => renderTestEnvironment({ role: "STUDENT", PageComponent: Generic, @@ -26,11 +27,43 @@ describe("staging video test page", () => { ], }); + it("renders all staging Wistia iframes from the page API payload", async () => { + renderStagingPage(); + await waitFor(() => { - expect(screen.getByTitle(/Embedded video/i)).toBeInTheDocument(); + 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: class { + constructor() { + /* noop — avoid loading real YouTube API in tests */ + } + } as never, + ready: (callback: () => void) => callback(), + PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 }, + }; + + try { + renderStagingPage(); + + await waitFor(() => { + expect(screen.getByTitle(/Embedded video:.*youtube/i)).toBeInTheDocument(); + }); - const iframe = document.querySelector("iframe"); - expect(iframe?.getAttribute("src")).toContain(STAGING_WISTIA_VIDEO.videoId); + 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; + } }); }); From 21cf618ac90384a6157e958fca191a45c898c1b5 Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 5 Jun 2026 15:08:53 +0100 Subject: [PATCH 13/22] Add tests for multiple wistia/youtube videos on one page --- src/app/components/content/IsaacVideo.tsx | 16 +- .../elements/content/IsaacVideo.test.tsx | 378 +++++++++++++++++- 2 files changed, 384 insertions(+), 10 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index c4dc99244b..8020c217ee 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -479,10 +479,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) => { @@ -532,12 +536,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) => { diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index c9840b0514..004dfcad96 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,6 +1,7 @@ -import React from "react"; -import { act } from "@testing-library/react"; +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, @@ -32,7 +33,7 @@ import { stagingVideoTestPageDoc, } from "../../../testPages/stagingVideoTestPage"; import { renderTestEnvironment } from "../../../utils"; -import { store } from "../../../../app/state"; +import { requestCurrentUser, store } from "../../../../app/state"; describe("rewrite", () => { it("parses youtube url to iframe src", () => { @@ -928,6 +929,377 @@ describe("staging page progress persistence — multiple videos", () => { ); }); +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, + }; + + class MockYTPlayer { + constructor(_node: HTMLElement, config: NonNullable) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: youtubeMockPlayer, data: 0 }); + } + } + + const setupYoutubeApiMock = () => { + capturedPlayerConfig = null; + youtubeCurrentTime = 0; + globalThis.YT = { + Player: MockYTPlayer as never, + 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 never) as Promise); + }); + 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(); + jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + 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"; From 28656f4c2e1bbfa8baff2b0ef46a2490f77ff0b3 Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 5 Jun 2026 16:02:20 +0100 Subject: [PATCH 14/22] Fix the test --- src/test/pages/Video60PercentTestPage.test.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/test/pages/Video60PercentTestPage.test.tsx b/src/test/pages/Video60PercentTestPage.test.tsx index a8c7c976a5..286156eaa5 100644 --- a/src/test/pages/Video60PercentTestPage.test.tsx +++ b/src/test/pages/Video60PercentTestPage.test.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { screen, waitFor } from "@testing-library/react"; import { rest } from "msw"; import { Generic } from "../../app/components/pages/Generic"; @@ -10,11 +11,18 @@ import { stagingVideoTestPageDoc, } from "../testPages/stagingVideoTestPage"; +interface StagingVideoTestPageHarnessProps { + pageIdOverride?: string; + match: { params: { pageId: string } }; +} + +const StagingVideoTestPageHarness: React.FC = (props) => ; + describe("staging video test page", () => { const renderStagingPage = () => renderTestEnvironment({ role: "STUDENT", - PageComponent: Generic, + PageComponent: StagingVideoTestPageHarness, componentProps: { pageIdOverride: STAGING_VIDEO_TEST_PAGE_ID, match: { params: { pageId: STAGING_VIDEO_TEST_PAGE_ID } }, From 4494371ec2ea30f8441c1a96804efd576c30c4ae Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 14:36:28 +0100 Subject: [PATCH 15/22] Refactor the union type --- src/app/components/content/IsaacVideo.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 8020c217ee..83a638c10f 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 { @@ -376,10 +378,7 @@ export function updateWistiaTimeFromEventData(lastKnownTime: number, eventData: return lastKnownTime; } -export function updateWistiaTimeFromArgs( - lastKnownTime: number, - args: Array>, -): number { +export function updateWistiaTimeFromArgs(lastKnownTime: number, args: Array): number { if (typeof args[1] === "number") { return args[1]; } @@ -612,7 +611,7 @@ export function IsaacVideo(props: IsaacVideoProps) { 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") { From 50160bf6616e34a0a51e1f09740f1560aaec7a13 Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 14:59:25 +0100 Subject: [PATCH 16/22] Fix the casting suggesstion --- src/app/components/content/IsaacVideo.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 83a638c10f..e5a6868d44 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -407,7 +407,12 @@ export function processWistiaMessage( return { lastKnownTime: context.lastKnownTime }; } - const eventName = String(data.args[0]).toLowerCase(); + const rawEventName = data.args[0]; + if (typeof rawEventName !== "string") { + return { lastKnownTime: context.lastKnownTime }; + } + + const eventName = rawEventName.toLowerCase(); if (isWistiaTimeChangeEvent(eventName)) { return { lastKnownTime: updateWistiaTimeFromArgs(context.lastKnownTime, data.args), @@ -661,7 +666,12 @@ export function IsaacVideo(props: IsaacVideoProps) { return; } - const eventName = data.args[0] as string; + const rawEventName = data.args[0]; + if (typeof rawEventName !== "string") { + return; + } + + const eventName = rawEventName; const eventData = (data.args[1] || {}) as WistiaEventData; if (isWistiaTimeChangeEvent(eventName)) { From 11707ffa395ca17ead833d5b422b7eedf5b99f6c Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 15:08:35 +0100 Subject: [PATCH 17/22] Reduce complexitey for handleWistiaMessage function --- src/app/components/content/IsaacVideo.tsx | 102 +++++++++++++--------- 1 file changed, 60 insertions(+), 42 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index e5a6868d44..e4cef68096 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -392,6 +392,41 @@ 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, @@ -401,25 +436,19 @@ export function processWistiaMessage( return { lastKnownTime: context.lastKnownTime }; } - const data: WistiaPostMessageData = - typeof rawData === "string" ? JSON.parse(rawData) : (rawData as WistiaPostMessageData); - if (data.method !== "_trigger" || !Array.isArray(data.args) || data.args.length === 0) { + const message = parseWistiaTriggerMessage(rawData); + if (!message) { return { lastKnownTime: context.lastKnownTime }; } - const rawEventName = data.args[0]; - if (typeof rawEventName !== "string") { - return { lastKnownTime: context.lastKnownTime }; - } - - const eventName = rawEventName.toLowerCase(); + const eventName = message.eventName.toLowerCase(); if (isWistiaTimeChangeEvent(eventName)) { return { - lastKnownTime: updateWistiaTimeFromArgs(context.lastKnownTime, data.args), + lastKnownTime: updateWistiaTimeFromArgs(context.lastKnownTime, message.args), }; } - const nextKnownTime = updateWistiaTimeFromEventData(context.lastKnownTime, (data.args[1] || {}) as WistiaEventData); + const nextKnownTime = updateWistiaTimeFromEventData(context.lastKnownTime, message.eventData); const eventType = WISTIA_EVENT_TYPE_MAP[eventName]; if (!eventType) { return { lastKnownTime: nextKnownTime }; @@ -653,47 +682,36 @@ export function IsaacVideo(props: IsaacVideoProps) { logPlayerEvent(eventType, videoUrl, wistiaVideoId, eventType === "VIDEO_ENDED" ? undefined : eventTime); }; - const handleWistiaMessage = (event: MessageEvent): void => { - if (!isValidWistiaOrigin(event.origin)) return; + 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); + } + }; - // Only accept messages from this iframe's content window (avoids cross-video / XSS issues). - if (event.source !== iframe.contentWindow) return; + const handleWistiaMessage = (event: MessageEvent): void => { + 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 rawEventName = data.args[0]; - if (typeof rawEventName !== "string") { + if (isWistiaTimeChangeEvent(message.eventName)) { + applyWistiaTimeChange(message.args, message.eventData); return; } - const eventName = rawEventName; - const eventData = (data.args[1] || {}) as WistiaEventData; - - if (isWistiaTimeChangeEvent(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); - } + 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); } }; From 48bd00f361c41f366ff4e99e021d1520f63d0894 Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 15:19:37 +0100 Subject: [PATCH 18/22] Fix the unnecessary type asserting issue --- .../elements/content/IsaacVideo.test.tsx | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 004dfcad96..2c87c58493 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1,3 +1,4 @@ +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"; @@ -53,6 +54,21 @@ 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, @@ -67,22 +83,22 @@ describe("logVideoEvent", () => { }); it("dispatches LOG_EVENT and calls the logger API when dispatch is provided", async () => { - const dispatch = jest.fn() as VideoEventDispatch; - const logSpy = jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + const { dispatchMock, dispatch } = createVideoEventDispatchMock(); + const logSpy = mockApiLoggerLog(); await logVideoEvent(eventDetails, dispatch); - expect(dispatch).toHaveBeenCalledWith({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); + 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 dispatch = jest.fn() as VideoEventDispatch; - const logSpy = jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + const { dispatchMock } = createVideoEventDispatchMock(); + const logSpy = mockApiLoggerLog(); await logVideoEvent(eventDetails); - expect(dispatch).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalled(); expect(logSpy).toHaveBeenCalledWith(eventDetails); }); @@ -126,11 +142,11 @@ describe("YouTube player handlers", () => { beforeEach(() => { capturedPlayerConfig = null; - jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + mockApiLoggerLog(); jest.spyOn(store, "dispatch"); globalThis.YT = { - Player: MockYTPlayer as never, + Player: MockYTPlayer as unknown as NonNullable["Player"], ready: (callback: () => void) => callback(), PlayerState: { PLAYING: 1, @@ -741,10 +757,10 @@ describe("staging video test page — YouTube", () => { beforeEach(() => { capturedPlayerConfig = null; - jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + mockApiLoggerLog(); jest.spyOn(store, "dispatch"); globalThis.YT = { - Player: MockYTPlayer as never, + Player: MockYTPlayer as unknown as NonNullable["Player"], ready: (callback: () => void) => callback(), PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 }, }; @@ -1068,7 +1084,7 @@ describe("IsaacVideo localStorage when moving between videos on the same page", capturedPlayerConfig = null; youtubeCurrentTime = 0; globalThis.YT = { - Player: MockYTPlayer as never, + Player: MockYTPlayer as unknown as NonNullable["Player"], ready: (callback: () => void) => callback(), PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 }, }; @@ -1101,7 +1117,7 @@ describe("IsaacVideo localStorage when moving between videos on the same page", initialRouteEntries: [`/pages/${STAGING_VIDEO_TEST_PAGE_ID}`], }); await act(async () => { - await (store.dispatch(requestCurrentUser() as never) as Promise); + await store.dispatch(requestCurrentUser() as any); }); store.dispatch({ type: ACTION_TYPE.DOCUMENT_RESPONSE_SUCCESS, @@ -1125,7 +1141,7 @@ describe("IsaacVideo localStorage when moving between videos on the same page", beforeEach(() => { localStorage.clear(); - jest.spyOn(api.logger, "log").mockResolvedValue({} as never); + mockApiLoggerLog(); setupYoutubeApiMock(); }); From ab7d532fb4a9fc390b45832c91181fe7b03839b9 Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 15:25:18 +0100 Subject: [PATCH 19/22] Fix the single constructor issue --- .../elements/content/IsaacVideo.test.tsx | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 2c87c58493..dba306c1d6 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -129,11 +129,9 @@ describe("YouTube player handlers", () => { getDuration: () => 120, }; - class MockYTPlayer { - constructor(_node: HTMLElement, config: CapturedYouTubePlayerConfig) { - capturedPlayerConfig = config; - config.events?.onReady?.({ target: mockPlayer, data: 0 }); - } + function MockYTPlayer(_node: HTMLElement, config: CapturedYouTubePlayerConfig) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: mockPlayer, data: 0 }); } const IsaacVideoHarness = () => ; @@ -742,11 +740,9 @@ describe("staging video test page — YouTube", () => { getDuration: () => 120, }; - class MockYTPlayer { - constructor(_node: HTMLElement, config: NonNullable) { - capturedPlayerConfig = config; - config.events?.onReady?.({ target: mockPlayer, data: 0 }); - } + function MockYTPlayer(_node: HTMLElement, config: NonNullable) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: mockPlayer, data: 0 }); } const StagingYoutubeHarness = () => ( @@ -1073,11 +1069,9 @@ describe("IsaacVideo localStorage when moving between videos on the same page", getDuration: () => youtubeDuration, }; - class MockYTPlayer { - constructor(_node: HTMLElement, config: NonNullable) { - capturedPlayerConfig = config; - config.events?.onReady?.({ target: youtubeMockPlayer, data: 0 }); - } + function MockYTPlayer(_node: HTMLElement, config: NonNullable) { + capturedPlayerConfig = config; + config.events?.onReady?.({ target: youtubeMockPlayer, data: 0 }); } const setupYoutubeApiMock = () => { From c2702b954b041330be9d7f7e7c155042b8c4b5c2 Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 15:29:02 +0100 Subject: [PATCH 20/22] Fix the NaN issue --- src/test/components/elements/content/IsaacVideo.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index dba306c1d6..4e467e2624 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -357,7 +357,7 @@ describe("isValidNumber", () => { }); it("returns false for non-finite or non-number values", () => { - expect(isValidNumber(NaN)).toBe(false); + expect(isValidNumber(Number.NaN)).toBe(false); expect(isValidNumber(Infinity)).toBe(false); expect(isValidNumber("10")).toBe(false); expect(isValidNumber(null)).toBe(false); @@ -461,7 +461,7 @@ describe("getWatchPercent", () => { 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, NaN)).toBe(0); + expect(getWatchPercent(60, Number.NaN)).toBe(0); expect(getWatchPercent(60, Infinity)).toBe(0); }); }); From 7d6f21e4b59ea2008400cfe7bada5d914a72a430 Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 15:32:37 +0100 Subject: [PATCH 21/22] Implement sonarqube suggestions --- .../components/elements/content/IsaacVideo.test.tsx | 2 +- src/test/pages/Video60PercentTestPage.test.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/components/elements/content/IsaacVideo.test.tsx b/src/test/components/elements/content/IsaacVideo.test.tsx index 4e467e2624..2d8d3762c6 100644 --- a/src/test/components/elements/content/IsaacVideo.test.tsx +++ b/src/test/components/elements/content/IsaacVideo.test.tsx @@ -1360,6 +1360,6 @@ describe("pauseAllVideos", () => { expect(postMessage).toHaveBeenCalledWith(JSON.stringify({ event: "command", func: "pauseVideo" }), "*"); - document.body.removeChild(iframe); + iframe.remove(); }); }); diff --git a/src/test/pages/Video60PercentTestPage.test.tsx b/src/test/pages/Video60PercentTestPage.test.tsx index 286156eaa5..a8e5732f30 100644 --- a/src/test/pages/Video60PercentTestPage.test.tsx +++ b/src/test/pages/Video60PercentTestPage.test.tsx @@ -50,12 +50,12 @@ describe("staging video test page", () => { it("renders the staging YouTube player mount from the page API payload", async () => { const originalYT = globalThis.YT; + function MockYTPlayer() { + // Avoid loading real YouTube API in tests + } + globalThis.YT = { - Player: class { - constructor() { - /* noop — avoid loading real YouTube API in tests */ - } - } as never, + Player: MockYTPlayer as unknown as NonNullable["Player"], ready: (callback: () => void) => callback(), PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 }, }; From 03ac2309a2a3b72e92da355250e861bb13c0f5ac Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 8 Jun 2026 15:37:08 +0100 Subject: [PATCH 22/22] Fix the function accessibility issue --- src/test/pages/Video60PercentTestPage.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/pages/Video60PercentTestPage.test.tsx b/src/test/pages/Video60PercentTestPage.test.tsx index a8e5732f30..021e9ffcc0 100644 --- a/src/test/pages/Video60PercentTestPage.test.tsx +++ b/src/test/pages/Video60PercentTestPage.test.tsx @@ -18,6 +18,10 @@ interface StagingVideoTestPageHarnessProps { const StagingVideoTestPageHarness: React.FC = (props) => ; +function MockYTPlayer() { + // Avoid loading real YouTube API in tests +} + describe("staging video test page", () => { const renderStagingPage = () => renderTestEnvironment({ @@ -50,9 +54,6 @@ describe("staging video test page", () => { it("renders the staging YouTube player mount from the page API payload", async () => { const originalYT = globalThis.YT; - function MockYTPlayer() { - // Avoid loading real YouTube API in tests - } globalThis.YT = { Player: MockYTPlayer as unknown as NonNullable["Player"],