diff --git a/ui/src/hooks/useMoqYamlSync.test.ts b/ui/src/hooks/useMoqYamlSync.test.ts index 4888182b..67b8c91f 100644 --- a/ui/src/hooks/useMoqYamlSync.test.ts +++ b/ui/src/hooks/useMoqYamlSync.test.ts @@ -38,8 +38,12 @@ const moqYaml = (broadcast: string, gateway = '/moq/test') => const nonMoqYaml = 'nodes:\n colorbars:\n kind: video::colorbars\n'; describe('useMoqYamlSync', () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); it('re-derives MoQ settings on a debounce after a direct YAML edit', () => { const actions = makeActions(); diff --git a/ui/src/views/StreamView.loadSamples.test.ts b/ui/src/views/StreamView.loadSamples.test.ts new file mode 100644 index 00000000..4d2604d7 --- /dev/null +++ b/ui/src/views/StreamView.loadSamples.test.ts @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { useStreamViewState } from '@/hooks/useStreamViewState'; +import { useStreamStore } from '@/stores/streamStore'; +import type { SamplePipeline } from '@/types/generated/api-types'; + +import { loadAndApplySamples } from './streamSamples'; + +// streamSamples imports the stream store, which pulls in the WebTransport-backed +// @moq/* libraries; stub them so the module loads in jsdom. +vi.mock('@moq/hang', () => ({ default: {} })); +vi.mock('@moq/watch', () => ({ default: {}, Broadcast: vi.fn() })); +vi.mock('@moq/publish', () => ({ default: {}, Broadcast: vi.fn() })); +vi.mock('@moq/signals', () => ({ Effect: vi.fn() })); + +const listDynamicSamples = vi.fn(); +vi.mock('@/services/samples', () => ({ + listDynamicSamples: () => listDynamicSamples(), +})); + +const sample = (id: string): SamplePipeline => + ({ id, name: id, yaml: `client:\n gateway_path: /moq/${id}\n` }) as SamplePipeline; + +function makeViewState(): ReturnType { + return { + selectedTemplateId: '', + setSamples: vi.fn(), + setSamplesLoading: vi.fn(), + setSamplesError: vi.fn(), + setSelectedTemplateId: vi.fn(), + setPipelineYaml: vi.fn(), + } as unknown as ReturnType; +} + +describe('loadAndApplySamples (cold-load ordering)', () => { + beforeEach(() => { + listDynamicSamples.mockReset(); + }); + + afterEach(() => { + useStreamStore.setState({ configLoaded: false }); + }); + + it('defers the first derive until config finishes loading', async () => { + listDynamicSamples.mockResolvedValue([sample('a')]); + const viewState = makeViewState(); + const deriveMoqFromYaml = vi.fn(); + + let resolveConfig!: () => void; + const loadConfig = vi.fn( + () => + new Promise((resolve) => { + resolveConfig = () => resolve(); + }) + ); + useStreamStore.setState({ configLoaded: false, loadConfig }); + + const done = loadAndApplySamples(viewState, deriveMoqFromYaml); + + // The sample list resolves first, but nothing is applied until config + // loads, so the derive never resolves against an empty configServerUrl (#604). + await Promise.resolve(); + expect(loadConfig).toHaveBeenCalledTimes(1); + expect(viewState.setPipelineYaml).not.toHaveBeenCalled(); + expect(deriveMoqFromYaml).not.toHaveBeenCalled(); + + resolveConfig(); + await done; + expect(viewState.setPipelineYaml).toHaveBeenCalledWith(sample('a').yaml); + expect(deriveMoqFromYaml).toHaveBeenCalledWith(sample('a').yaml); + }); + + it('does not reload config when it is already loaded (warm load)', async () => { + listDynamicSamples.mockResolvedValue([sample('a')]); + const viewState = makeViewState(); + const deriveMoqFromYaml = vi.fn(); + + const loadConfig = vi.fn(() => Promise.resolve()); + useStreamStore.setState({ configLoaded: true, loadConfig }); + + await loadAndApplySamples(viewState, deriveMoqFromYaml); + + expect(loadConfig).not.toHaveBeenCalled(); + expect(deriveMoqFromYaml).toHaveBeenCalledWith(sample('a').yaml); + }); + + it('reports an error and stops loading when the sample fetch fails', async () => { + listDynamicSamples.mockRejectedValue(new Error('boom')); + const viewState = makeViewState(); + const deriveMoqFromYaml = vi.fn(); + + useStreamStore.setState({ configLoaded: true, loadConfig: vi.fn() }); + + await loadAndApplySamples(viewState, deriveMoqFromYaml); + + expect(viewState.setSamplesError).toHaveBeenCalledWith('boom'); + expect(viewState.setSamplesLoading).toHaveBeenLastCalledWith(false); + expect(deriveMoqFromYaml).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/views/StreamView.tsx b/ui/src/views/StreamView.tsx index e41e53ba..28be7b63 100644 --- a/ui/src/views/StreamView.tsx +++ b/ui/src/views/StreamView.tsx @@ -33,13 +33,12 @@ import { useStreamViewState } from '@/hooks/useStreamViewState'; import { useVideoCanvas } from '@/hooks/useVideoCanvas'; import { useWebSocket } from '@/hooks/useWebSocket'; import { getApiUrl } from '@/services/base'; -import { listDynamicSamples } from '@/services/samples'; import { createSession, listSessions } from '@/services/sessions'; import { useSchemaStore, ensureSchemasLoaded } from '@/stores/schemaStore'; import { useSessionStore } from '@/stores/sessionStore'; import type { Event } from '@/types/types'; import { getLogger } from '@/utils/logger'; -import { orderSamplePipelinesSystemFirst } from '@/utils/samplePipelineOrdering'; +import { loadAndApplySamples } from '@/views/streamSamples'; import type { CameraStatus, @@ -80,34 +79,6 @@ function autoConnectIfMoq( })(); } -/** Load dynamic pipeline samples and auto-select the first one. */ -async function loadAndApplySamples( - viewState: ReturnType, - deriveMoqFromYaml: (yaml: string) => void -): Promise { - try { - viewState.setSamplesLoading(true); - viewState.setSamplesError(null); - const samples = await listDynamicSamples(); - const orderedSamples = orderSamplePipelinesSystemFirst(samples); - viewState.setSamples(orderedSamples); - - if (orderedSamples.length > 0 && !viewState.selectedTemplateId) { - const first = orderedSamples[0]; - viewState.setSelectedTemplateId(first.id); - viewState.setPipelineYaml(first.yaml); - deriveMoqFromYaml(first.yaml); - } - } catch (error) { - logger.error('Failed to load dynamic samples:', error); - viewState.setSamplesError( - error instanceof Error ? error.message : 'Failed to load pipeline templates' - ); - } finally { - viewState.setSamplesLoading(false); - } -} - /** Build the relay connection status label for the streaming status bar. */ function relayStatusLabel(status: ConnectionStatus, connectingStep: string): string { if (status === 'connected') return 'Relay: connected'; @@ -473,7 +444,6 @@ const StreamView: React.FC = () => { setMsePath, setActiveSession, clearActiveSession, - loadConfig, connect, disconnect, toggleMicrophone, @@ -524,7 +494,6 @@ const StreamView: React.FC = () => { setMsePath: s.setMsePath, setActiveSession: s.setActiveSession, clearActiveSession: s.clearActiveSession, - loadConfig: s.loadConfig, connect: s.connect, disconnect: s.disconnect, toggleMicrophone: s.toggleMicrophone, @@ -584,10 +553,6 @@ const StreamView: React.FC = () => { }; }, []); - useEffect(() => { - if (!configLoaded) loadConfig(); - }, [configLoaded, loadConfig]); - const [mseError, setMseError] = useState(null); const mseUrl = React.useMemo(() => { if (!activeSessionId || !msePath) return null; diff --git a/ui/src/views/streamSamples.ts b/ui/src/views/streamSamples.ts new file mode 100644 index 00000000..2ad67893 --- /dev/null +++ b/ui/src/views/streamSamples.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import type { useStreamViewState } from '@/hooks/useStreamViewState'; +import { listDynamicSamples } from '@/services/samples'; +import { useStreamStore } from '@/stores/streamStore'; +import { getLogger } from '@/utils/logger'; +import { orderSamplePipelinesSystemFirst } from '@/utils/samplePipelineOrdering'; + +const logger = getLogger('streamSamples'); + +/** + * Load dynamic pipeline samples and auto-select the first one. + * + * The first derive resolves the MoQ server URL from `configServerUrl`, which + * `loadConfig()` populates asynchronously. Config and samples are fetched in + * parallel (`Promise.all`), so the auto-select applies the template and derives + * its MoQ settings synchronously once config is loaded — the URL is present even + * when the sample list resolves first (otherwise the field stays blank on a cold + * load until the user edits the client section, issue #604). + */ +export async function loadAndApplySamples( + viewState: ReturnType, + deriveMoqFromYaml: (yaml: string) => void +): Promise { + const { configLoaded, loadConfig } = useStreamStore.getState(); + try { + viewState.setSamplesLoading(true); + viewState.setSamplesError(null); + const [, samples] = await Promise.all([ + configLoaded ? Promise.resolve() : loadConfig(), + listDynamicSamples(), + ]); + const orderedSamples = orderSamplePipelinesSystemFirst(samples); + viewState.setSamples(orderedSamples); + + if (orderedSamples.length > 0 && !viewState.selectedTemplateId) { + const first = orderedSamples[0]; + viewState.setSelectedTemplateId(first.id); + viewState.setPipelineYaml(first.yaml); + deriveMoqFromYaml(first.yaml); + } + } catch (error) { + logger.error('Failed to load dynamic samples:', error); + viewState.setSamplesError( + error instanceof Error ? error.message : 'Failed to load pipeline templates' + ); + } finally { + viewState.setSamplesLoading(false); + } +}