Skip to content
Open

Bump #500

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
584 changes: 551 additions & 33 deletions src/app/components/content/IsaacVideo.tsx

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions src/app/components/navigation/AuthDebugLogger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { selectors, useAppSelector } from "../../state";
import { debugLog } from "../../services";

/**
* TODO(#855) TEMPORARY diagnostic component.

Check warning on line 7 in src/app/components/navigation/AuthDebugLogger.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=isaaccomputerscience_isaac-react-app&issues=AZ9AOYe3UiWPiaBPD8I6&open=AZ9AOYe3UiWPiaBPD8I6&pullRequest=500
*
* Logs the app's auth/user view on every navigation (and whenever the user state changes) so we can
* see, per page, whether the app considers the user logged in. This is what gates the video KPI:
* `loggedInOrNull === null` ⇒ IsaacVideo `userStorageScope === null` ⇒ 60% tracking disabled.
*
* Renders nothing. Remove before the production release.
*/
export const AuthDebugLogger = () => {
const location = useLocation();
const user = useAppSelector(selectors.user.orNull);
const loggedInUser = useAppSelector(selectors.user.loggedInOrNull);

useEffect(() => {
const userId = loggedInUser ? loggedInUser.id : undefined;
const userStorageScope = userId == null ? null : String(userId);
debugLog("auth", "page visit", {
path: location.pathname,
loggedIn: user?.loggedIn ?? null,
requesting: (user as { requesting?: boolean } | null)?.requesting ?? false,
userId,
role: loggedInUser ? loggedInUser.role : undefined,
loggedInOrNull: loggedInUser ? userId : null,
// exactly what IsaacVideo computes; null here means the 60% KPI is disabled on this page
userStorageScope,
videoKpiEnabled: Boolean(userStorageScope),
});
}, [location.pathname, user, loggedInUser]);

return null;
};
3 changes: 3 additions & 0 deletions src/app/components/navigation/IsaacApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
} from "../../state";
import { Route, Router, Switch } from "react-router-dom";
import ErrorClear from "./ErrorClear";
import { AuthDebugLogger } from "./AuthDebugLogger";
import { Footer } from "./Footer";
import { Question } from "../pages/Question";
import { Concept } from "../pages/Concept";
Expand Down Expand Up @@ -156,6 +157,8 @@
// Render
return (
<Router history={history}>
{/* TODO(#855) TEMPORARY diagnostic: logs auth/user state on every navigation. Remove before prod. */}

Check warning on line 160 in src/app/components/navigation/IsaacApp.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=isaaccomputerscience_isaac-react-app&issues=AZ9AM0TeNCQofI86dgpg&open=AZ9AM0TeNCQofI86dgpg&pullRequest=500
<AuthDebugLogger />
<ErrorClear />
<Header />
<Toasts />
Expand Down
20 changes: 19 additions & 1 deletion src/app/services/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { AxiosPromise } from "axios";
import { API_PATH, EventStageFilter, EventTypeFilter, securePadCredentials, securePadPasswordReset, TAG_ID } from "./";
import { debugLog } from "./debug";
import * as ApiTypes from "../../IsaacApiTypes";
import {
AuthenticationProvider,
Expand Down Expand Up @@ -518,7 +519,24 @@
},
logger: {
log: (eventDetails: object): AxiosPromise<void> => {
return endpoint.post(`/log`, eventDetails);
// TODO(#855) TEMPORARY diagnostic: trace every event POSTed to /log (payload + result).

Check warning on line 522 in src/app/services/api.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=isaaccomputerscience_isaac-react-app&issues=AZ9AM0WkNCQofI86dgpk&open=AZ9AM0WkNCQofI86dgpk&pullRequest=500
// This is the single choke point all client log events pass through (LOG_EVENT redux action
// and IsaacVideo's play/pause/ended/60% logging), so it captures them all.
const eventType = (eventDetails as { type?: string })?.type;
debugLog("backend-log", "POST /log →", eventDetails);
const request = endpoint.post<void>(`/log`, eventDetails);
// Side-branch handlers only — the original `request` promise is returned unchanged so callers
// still receive success/failure exactly as before.
request
.then((response) => debugLog("backend-log", "POST /log OK", { status: response.status, type: eventType }))
.catch((error) =>
debugLog("backend-log", "POST /log FAILED", {
status: error?.response?.status,
type: eventType,
message: error?.message,
}),
);
return request;
},
},
websockets: {
Expand Down
19 changes: 19 additions & 0 deletions src/app/services/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* TODO(#855) TEMPORARY diagnostic logging.

Check warning on line 2 in src/app/services/debug.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=isaaccomputerscience_isaac-react-app&issues=AZ9AOYhnUiWPiaBPD8I7&open=AZ9AOYhnUiWPiaBPD8I7&pullRequest=500
*
* Unconditional console output used to trace, in the browser, exactly what the code sees while we
* debug why VIDEO_60_PERCENT_WATCHED is not reaching the backend: the auth/user state on every
* page, every event POSTed to /log (payload + result), and the video-KPI decision path including
* the conditions that gate whether an event is sent or skipped.
*
* This is intentionally always-on (not behind a flag) and never logs PII beyond user id + role.
* Remove this file and all `debugLog(` call sites before the production release.
*/
export function debugLog(scope: string, message: string, data?: unknown): void {
try {
// eslint-disable-next-line no-console
console.info(`[${scope}] ${message}`, data ?? "");
} catch {
// ignore (e.g. console unavailable)
}
}
1 change: 1 addition & 0 deletions src/app/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// No internal app dependencies
export * from "./debug";
export * from "./localStorage";
export * from "./siteConstants";
export * from "./demoTools";
Expand Down
13 changes: 13 additions & 0 deletions src/app/state/actions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
ACTION_TYPE,
api,
API_REQUEST_FAILURE_MESSAGE,
debugLog,
atLeastOne,
augmentEvent,
DOCUMENT_TYPE,
Expand Down Expand Up @@ -196,13 +197,25 @@

export const requestCurrentUser = () => async (dispatch: Dispatch<Action>) => {
dispatch({ type: ACTION_TYPE.CURRENT_USER_REQUEST });
// TODO(#855) TEMPORARY diagnostic: trace the current_user lifecycle so we can see exactly when

Check warning on line 200 in src/app/state/actions/index.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=isaaccomputerscience_isaac-react-app&issues=AZ9AM0XXNCQofI86dgpl&open=AZ9AM0XXNCQofI86dgpl&pullRequest=500
// and why the app flips to anonymous (which disables the video KPI).
debugLog("auth", "current_user request");
try {
// Request the user
const currentUser = await api.users.getCurrent();
// Now with that information request auth settings and preferences asynchronously
await Promise.all([dispatch(getUserAuthSettings() as any), dispatch(getUserPreferences() as any)]);
debugLog("auth", "current_user OK", {
userId: currentUser.data?.id,
role: currentUser.data?.role,
loggedIn: true,
});
dispatch({ type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS, user: currentUser.data });
} catch (e) {
debugLog("auth", "current_user FAILED (app is now anonymous)", {
status: (e as { response?: { status?: number } })?.response?.status,
message: (e as Error)?.message,
});
dispatch({ type: ACTION_TYPE.CURRENT_USER_RESPONSE_FAILURE });
}
};
Expand Down
91 changes: 91 additions & 0 deletions src/test/components/elements/content/IsaacVideo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
processWistiaMessage,
rewrite,
saveVideoProgress,
sendVideoEngagementBeacon,
updateWistiaTimeFromArgs,
updateWistiaTimeFromEventData,
} from "../../../../app/components/content/IsaacVideo";
Expand Down Expand Up @@ -238,6 +239,52 @@ describe("YouTube player handlers", () => {

expect(dispatchMock).not.toHaveBeenCalled();
});

it("logs VIDEO_60_PERCENT_WATCHED during continuous playback, without a pause/seek/end", async () => {
// Regression: the KPI must fire live from the playback poll while the segment is still open. Previously it
// was only evaluated when a segment closed (pause/seek/end/unmount), so a straight watch-through never logged.
let currentTime = 0;
const advancingPlayer = {
getVideoUrl: () => youtubeSrc,
getCurrentTime: () => currentTime,
getDuration: () => 120,
};

renderYouTubeVideo();
await flushPromises();

const dispatchMock = store.dispatch as jest.Mock;

// Fake timers must be active BEFORE play so the 1s poll interval is registered as a fake timer we can advance.
jest.useFakeTimers();
try {
// Start playback at t=0 (opens a segment and starts the 1s poll timer).
await act(async () => {
capturedPlayerConfig?.events?.onStateChange?.({ target: advancingPlayer, data: 1 });
});
dispatchMock.mockClear();

// Advance the player one second per poll tick (below the seek tolerance) up past 60% of 120s (= 72s).
for (let second = 1; second <= 75; second++) {
currentTime = second;
act(() => {
jest.advanceTimersByTime(1000);
});
}
} finally {
jest.useRealTimers();
}

type LoggedAction = { type?: string; eventDetails?: { type?: string; videoId?: string; watchPercent?: number } };
const thresholdLog = dispatchMock.mock.calls.find(([action]) => {
const a = action as LoggedAction;
return a?.type === ACTION_TYPE.LOG_EVENT && a?.eventDetails?.type === "VIDEO_60_PERCENT_WATCHED";
});
expect(thresholdLog).toBeDefined();
const eventDetails = (thresholdLog?.[0] as LoggedAction).eventDetails;
expect(eventDetails?.videoId).toBe(youtubeVideoId);
expect(eventDetails?.watchPercent).toBeGreaterThanOrEqual(0.6);
});
});

describe("Wistia helpers", () => {
Expand Down Expand Up @@ -1363,3 +1410,47 @@ describe("pauseAllVideos", () => {
iframe.remove();
});
});

describe("sendVideoEngagementBeacon", () => {
const eventDetails = {
type: "VIDEO_60_PERCENT_WATCHED" as const,
videoId: "test123ABCde",
videoUrl: "https://www.youtube.com/watch?v=test123ABCde",
videoDurationSeconds: 120,
watchedSeconds: 80,
watchPercent: 0.66,
};

afterEach(() => {
jest.restoreAllMocks();
});

it("POSTs the event to /log via fetch with keepalive so it survives page unload", () => {
const fetchMock = jest.fn(() => Promise.resolve({ ok: true } as Response));
jest.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);

const dispatched = sendVideoEngagementBeacon(eventDetails);

expect(dispatched).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toMatch(/\/log$/);
expect(init.method).toBe("POST");
expect(init.keepalive).toBe(true);
expect(init.credentials).toBe("include");
expect(init.headers).toMatchObject({ "Content-Type": "application/json" });
expect(JSON.parse(init.body as string)).toMatchObject({
type: "VIDEO_60_PERCENT_WATCHED",
videoId: "test123ABCde",
});
});

it("returns false and does not throw when fetch is unavailable", () => {
jest.spyOn(globalThis, "fetch").mockImplementation(() => {
throw new Error("fetch unavailable");
});

expect(() => sendVideoEngagementBeacon(eventDetails)).not.toThrow();
expect(sendVideoEngagementBeacon(eventDetails)).toBe(false);
});
});
Loading