Live video on the AT Protocol

desktop: add server restart test for playback recovery

+118 -3
+6
js/components/src/components/mobile-player/player.tsx
··· 25 25 26 26 const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout); 27 27 28 + const setReportingURL = usePlayerStore((x) => x.setReportingURL); 29 + 30 + useEffect(() => { 31 + setReportingURL(props.reportingURL ?? null); 32 + }, [props.reportingURL]); 33 + 28 34 // Will call back every few seconds to send health updates 29 35 usePlayerStatus(); 30 36
+1
js/components/src/components/mobile-player/props.tsx
··· 8 8 setFullscreen: (isFullscreen: boolean) => void; 9 9 ingest?: boolean; 10 10 embedded?: boolean; 11 + reportingURL?: string; 11 12 };
+6
js/components/src/player-store/player-state.tsx
··· 180 180 181 181 /** Function to set the mod message */ 182 182 setModMessage: (message: ChatMessageViewHydrated | null) => void; 183 + 184 + /** URL to send player events to (if not default) */ 185 + reportingURL: string | null; 186 + 187 + /** Function to set the reporting URL */ 188 + setReportingURL: (reportingURL: string | null) => void; 183 189 } 184 190 185 191 export type PlayerEvent = {
+6 -1
js/components/src/player-store/player-store.tsx
··· 114 114 ingestLive: false, 115 115 setIngestLive: (ingestLive: boolean) => set(() => ({ ingestLive })), 116 116 117 + reportingURL: null, 118 + setReportingURL: (reportingURL: string | null) => 119 + set(() => ({ reportingURL })), 120 + 117 121 playerEvent: async ( 118 122 url: string, 119 123 time: string, ··· 131 135 }; 132 136 try { 133 137 // fetch url from sp provider 134 - fetch(`${url}/api/player-event`, { 138 + const reportingURL = x.reportingURL ?? `${url}/api/player-event`; 139 + fetch(reportingURL, { 135 140 method: "POST", 136 141 body: JSON.stringify(data), 137 142 });
+94
js/desktop/src/tests/server-restart-test.ts
··· 1 + import fs from "fs/promises"; 2 + import os from "os"; 3 + import path from "path"; 4 + import { v7 as uuidv7 } from "uuid"; 5 + import makeNode from "../node"; 6 + import { makeWindow } from "../window"; 7 + import { E2ETest, TestEnv } from "./test-env"; 8 + import { delay, PlayerReport, randomPort } from "./util"; 9 + 10 + /** 11 + * This test: 12 + * - Plays a stream that goes up/down every 15 seconds 13 + * - Observes the player for several cycles 14 + * - Checks that the player spends a reasonable amount of time in 'playing' state after each recovery 15 + */ 16 + 17 + const PLAYING_THRESHOLD = 0.7; 18 + 19 + export const serverRestartTest: E2ETest = { 20 + test: async (testEnv: TestEnv): Promise<string | null> => { 21 + const mainWindow = await makeWindow(); 22 + 23 + const tmpDir = await fs.mkdtemp( 24 + path.join(os.tmpdir(), "streamplace-test-"), 25 + ); 26 + 27 + // this test runs another node so we can still use node 1 for reporting! 28 + const env = { 29 + SP_HTTP_ADDR: `127.0.0.1:${randomPort()}`, 30 + SP_HTTP_INTERNAL_ADDR: `127.0.0.1:${randomPort()}`, 31 + SP_DATA_DIR: tmpDir, 32 + SP_TEST_STREAM: "true", 33 + }; 34 + let { proc } = await makeNode({ 35 + env: env, 36 + autoQuit: false, 37 + }); 38 + 39 + const testId = uuidv7(); 40 + const playerId = `${testId}-server-restart`; 41 + const playerConfig = { 42 + name: "server-restart-stream", 43 + playerId, 44 + src: "self-test", // <-- Make sure this matches your Go alias! 45 + showControls: true, 46 + telemetry: true, 47 + forceProtocol: "webrtc", 48 + reportingURL: `${testEnv.addr}/api/player-event`, 49 + }; 50 + const enc = encodeURIComponent(JSON.stringify([playerConfig])); 51 + const load = `http://${env.SP_HTTP_ADDR}/multi/${enc}`; 52 + 53 + console.log(`Opening player at ${load}`); 54 + await mainWindow.loadURL(load); 55 + 56 + await delay(20000); 57 + proc.kill("SIGKILL"); 58 + await delay(500); 59 + let { proc: proc2 } = await makeNode({ 60 + env: env, 61 + autoQuit: false, 62 + }); 63 + 64 + await delay(40000); 65 + proc2.kill("SIGTERM"); 66 + 67 + const res = await fetch( 68 + `${testEnv.internalAddr}/player-report/${playerId}`, 69 + ); 70 + const data = (await res.json()) as PlayerReport; 71 + 72 + const stateTimes = data.whatHappened || {}; 73 + const total = Object.values(stateTimes).reduce((a, b) => a + b, 0); 74 + const playing = stateTimes["playing"] || 0; 75 + 76 + const playingPct = total > 0 ? playing / total : 0; 77 + 78 + console.log( 79 + `Overall playing percentage: ${(playingPct * 100).toFixed(1)}%`, 80 + ); 81 + console.log("Full state times:", JSON.stringify(stateTimes, null, 2)); 82 + 83 + mainWindow.close(); 84 + 85 + if (playingPct < PLAYING_THRESHOLD) { 86 + return `Player spent too little time playing during server-restart stream (${( 87 + playingPct * 100 88 + ).toFixed(1)}%). Possible stall or failure to recover.`; 89 + } 90 + proc2.kill("SIGTERM"); 91 + 92 + return null; 93 + }, 94 + };
+3 -2
js/desktop/src/tests/test-runner.ts
··· 8 8 import makeNode from "../node"; 9 9 import { playbackTest } from "./playback-test"; 10 10 import { resumeLoopTest } from "./resume-loop-test"; 11 + import { serverRestartTest } from "./server-restart-test"; 11 12 import { syncTest } from "./sync-test"; 12 13 import { E2ETest, TestEnv } from "./test-env"; 14 + import { randomPort } from "./util"; 13 15 14 16 const allTests: Record<string, E2ETest> = { 15 17 playback: playbackTest, 16 18 sync: syncTest, 17 19 resume: resumeLoopTest, 20 + serverRestart: serverRestartTest, 18 21 }; 19 22 20 23 export const allTestNames = Object.keys(allTests); 21 - 22 - const randomPort = () => Math.floor(Math.random() * 20000) + 20000; 23 24 24 25 export default async function runTests( 25 26 tests: string[],
+2
js/desktop/src/tests/util.ts
··· 1 1 export const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); 2 2 3 + export const randomPort = () => Math.floor(Math.random() * 20000) + 20000; 4 + 3 5 export type PlayerReport = { 4 6 whatHappened: { [k: string]: number }; 5 7 avSync?: {