diff --git a/js/components/src/components/mobile-player/player.tsx b/js/components/src/components/mobile-player/player.tsx index f64061b8a..0b6011e99 100644 --- a/js/components/src/components/mobile-player/player.tsx +++ b/js/components/src/components/mobile-player/player.tsx @@ -25,6 +25,12 @@ export function Player( const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout); + const setReportingURL = usePlayerStore((x) => x.setReportingURL); + + useEffect(() => { + setReportingURL(props.reportingURL ?? null); + }, [props.reportingURL]); + // Will call back every few seconds to send health updates usePlayerStatus(); diff --git a/js/components/src/components/mobile-player/props.tsx b/js/components/src/components/mobile-player/props.tsx index b76410efb..f5af17088 100644 --- a/js/components/src/components/mobile-player/props.tsx +++ b/js/components/src/components/mobile-player/props.tsx @@ -8,4 +8,5 @@ export type PlayerProps = { setFullscreen: (isFullscreen: boolean) => void; ingest?: boolean; embedded?: boolean; + reportingURL?: string; }; diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx index a53dfd232..9677efdfd 100644 --- a/js/components/src/player-store/player-state.tsx +++ b/js/components/src/player-store/player-state.tsx @@ -180,6 +180,12 @@ export interface PlayerState { /** Function to set the mod message */ setModMessage: (message: ChatMessageViewHydrated | null) => void; + + /** URL to send player events to (if not default) */ + reportingURL: string | null; + + /** Function to set the reporting URL */ + setReportingURL: (reportingURL: string | null) => void; } export type PlayerEvent = { diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx index 20cbfbd75..8c83dd5d0 100644 --- a/js/components/src/player-store/player-store.tsx +++ b/js/components/src/player-store/player-store.tsx @@ -114,6 +114,10 @@ export const makePlayerStore = (id?: string): StoreApi => { ingestLive: false, setIngestLive: (ingestLive: boolean) => set(() => ({ ingestLive })), + reportingURL: null, + setReportingURL: (reportingURL: string | null) => + set(() => ({ reportingURL })), + playerEvent: async ( url: string, time: string, @@ -131,7 +135,8 @@ export const makePlayerStore = (id?: string): StoreApi => { }; try { // fetch url from sp provider - fetch(`${url}/api/player-event`, { + const reportingURL = x.reportingURL ?? `${url}/api/player-event`; + fetch(reportingURL, { method: "POST", body: JSON.stringify(data), }); diff --git a/js/desktop/src/tests/server-restart-test.ts b/js/desktop/src/tests/server-restart-test.ts new file mode 100644 index 000000000..845722026 --- /dev/null +++ b/js/desktop/src/tests/server-restart-test.ts @@ -0,0 +1,94 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { v7 as uuidv7 } from "uuid"; +import makeNode from "../node"; +import { makeWindow } from "../window"; +import { E2ETest, TestEnv } from "./test-env"; +import { delay, PlayerReport, randomPort } from "./util"; + +/** + * This test: + * - Plays a stream that goes up/down every 15 seconds + * - Observes the player for several cycles + * - Checks that the player spends a reasonable amount of time in 'playing' state after each recovery + */ + +const PLAYING_THRESHOLD = 0.7; + +export const serverRestartTest: E2ETest = { + test: async (testEnv: TestEnv): Promise => { + const mainWindow = await makeWindow(); + + const tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "streamplace-test-"), + ); + + // this test runs another node so we can still use node 1 for reporting! + const env = { + SP_HTTP_ADDR: `127.0.0.1:${randomPort()}`, + SP_HTTP_INTERNAL_ADDR: `127.0.0.1:${randomPort()}`, + SP_DATA_DIR: tmpDir, + SP_TEST_STREAM: "true", + }; + let { proc } = await makeNode({ + env: env, + autoQuit: false, + }); + + const testId = uuidv7(); + const playerId = `${testId}-server-restart`; + const playerConfig = { + name: "server-restart-stream", + playerId, + src: "self-test", // <-- Make sure this matches your Go alias! + showControls: true, + telemetry: true, + forceProtocol: "webrtc", + reportingURL: `${testEnv.addr}/api/player-event`, + }; + const enc = encodeURIComponent(JSON.stringify([playerConfig])); + const load = `http://${env.SP_HTTP_ADDR}/multi/${enc}`; + + console.log(`Opening player at ${load}`); + await mainWindow.loadURL(load); + + await delay(20000); + proc.kill("SIGKILL"); + await delay(500); + let { proc: proc2 } = await makeNode({ + env: env, + autoQuit: false, + }); + + await delay(40000); + proc2.kill("SIGTERM"); + + const res = await fetch( + `${testEnv.internalAddr}/player-report/${playerId}`, + ); + const data = (await res.json()) as PlayerReport; + + const stateTimes = data.whatHappened || {}; + const total = Object.values(stateTimes).reduce((a, b) => a + b, 0); + const playing = stateTimes["playing"] || 0; + + const playingPct = total > 0 ? playing / total : 0; + + console.log( + `Overall playing percentage: ${(playingPct * 100).toFixed(1)}%`, + ); + console.log("Full state times:", JSON.stringify(stateTimes, null, 2)); + + mainWindow.close(); + + if (playingPct < PLAYING_THRESHOLD) { + return `Player spent too little time playing during server-restart stream (${( + playingPct * 100 + ).toFixed(1)}%). Possible stall or failure to recover.`; + } + proc2.kill("SIGTERM"); + + return null; + }, +}; diff --git a/js/desktop/src/tests/test-runner.ts b/js/desktop/src/tests/test-runner.ts index 0420eb4c2..80f041b2f 100644 --- a/js/desktop/src/tests/test-runner.ts +++ b/js/desktop/src/tests/test-runner.ts @@ -8,19 +8,20 @@ import getEnv from "../env"; import makeNode from "../node"; import { playbackTest } from "./playback-test"; import { resumeLoopTest } from "./resume-loop-test"; +import { serverRestartTest } from "./server-restart-test"; import { syncTest } from "./sync-test"; import { E2ETest, TestEnv } from "./test-env"; +import { randomPort } from "./util"; const allTests: Record = { playback: playbackTest, sync: syncTest, resume: resumeLoopTest, + serverRestart: serverRestartTest, }; export const allTestNames = Object.keys(allTests); -const randomPort = () => Math.floor(Math.random() * 20000) + 20000; - export default async function runTests( tests: string[], duration: string, diff --git a/js/desktop/src/tests/util.ts b/js/desktop/src/tests/util.ts index b54711333..9c2d6088a 100644 --- a/js/desktop/src/tests/util.ts +++ b/js/desktop/src/tests/util.ts @@ -1,5 +1,7 @@ export const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); +export const randomPort = () => Math.floor(Math.random() * 20000) + 20000; + export type PlayerReport = { whatHappened: { [k: string]: number }; avSync?: {