Improve local browser streaming devex (#6026)

This commit is contained in:
Suchintan 2026-05-19 21:26:48 -04:00 committed by GitHub
parent 47892c98e2
commit 4988f038b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 983 additions and 71 deletions

View file

@ -1,7 +1,7 @@
# Environment that the agent will run in.
ENV=local
# Browser streaming mode: "cdp" for local CDP screencast, "vnc" for VNC streaming.
# Browser streaming mode: "cdp" for local browser streaming, "vnc" for VNC streaming.
# Quickstart writes "cdp" for new local installs; app code falls back to "vnc" if unset.
BROWSER_STREAMING_MODE=cdp

View file

@ -1,5 +1,5 @@
# Browser streaming mode:
# - cdp: use CDP screencast for local livestreaming
# - cdp: use local browser streaming through the backend
# - vnc: use VNC streaming
# Quickstart writes cdp for new local installs; app code falls back to vnc if unset.
VITE_BROWSER_STREAMING_MODE=cdp

View file

@ -40,6 +40,10 @@ import { wssBaseUrl, newWssBaseUrl, getRuntimeApiKey } from "@/util/env";
import { copyText } from "@/util/copyText";
import { cn } from "@/util/utils";
import { captureRecordBrowser } from "@/util/recordBrowserTelemetry";
import {
StreamStatusPanel,
type StreamDiagnostic,
} from "@/routes/streaming/StreamDiagnostics";
import { RotateThrough } from "./RotateThrough";
import "./browser-stream.css";
@ -779,6 +783,34 @@ function BrowserStream({
const theUserIsControlling =
userIsControlling || (interactive && !showControlButtons);
const streamDiagnostic: StreamDiagnostic =
entity === "browserSession" && browserSessionId && !hasBrowserSession
? {
title: "Browser session is no longer live",
detail: "This live browser session is no longer streaming.",
hint: "Refresh the page or create a new browser session.",
}
: !isBrowserSessionBackendReady
? {
title: "Waiting for browser session",
detail:
"The session exists, but the backend has not marked the browser as ready yet.",
}
: !isVncConnected
? {
title: "Connecting to VNC stream",
detail: "Opening the browser stream and message WebSockets.",
hint: "If this stays here, check VNC support for the session or use local browser streaming.",
}
: !isCanvasReady
? {
title: "Preparing browser display",
detail:
"The VNC connection is open and the UI is waiting for the browser canvas.",
}
: {
title: "Connecting to browser stream",
};
return (
<>
@ -914,25 +946,18 @@ function BrowserStream({
)}
{!isReady && (
<div className="absolute left-0 top-1/2 flex aspect-video max-h-full w-full -translate-y-1/2 flex-col items-center justify-center gap-2 rounded-md border border-slate-800 text-sm text-slate-400">
{entity === "browserSession" &&
browserSessionId &&
!hasBrowserSession ? (
<div>This live browser session is no longer streaming.</div>
) : (
<>
<RotateThrough interval={7 * 1000}>
<span>Hm, working on the connection...</span>
<span>Hang tight, we're almost there...</span>
<span>Just a moment...</span>
<span>Backpropagating...</span>
<span>Attention is all I need...</span>
<span>Consulting the manual...</span>
<span>Looking for the bat phone...</span>
<span>Where's Shu?...</span>
</RotateThrough>
<AnimatedWave text=".‧₊˚ ⋅ ? ✨ ?★ ‧₊˚ ⋅" />
</>
)}
<StreamStatusPanel diagnostic={streamDiagnostic}>
{isBrowserSessionBackendReady && (
<>
<RotateThrough interval={7 * 1000}>
<span>Checking browser stream readiness...</span>
<span>Waiting for the browser display...</span>
<span>Verifying the stream connection...</span>
</RotateThrough>
<AnimatedWave text=". . ." />
</>
)}
</StreamStatusPanel>
</div>
)}
</div>

View file

@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest";
async function loadRuntimeConfigHelpers() {
vi.resetModules();
vi.stubEnv("VITE_API_BASE_URL", "http://localhost:8000/api/v1");
vi.stubEnv("VITE_ARTIFACT_API_BASE_URL", "http://localhost:9090");
vi.stubEnv("VITE_ENVIRONMENT", "test");
vi.stubEnv("VITE_WSS_BASE_URL", "ws://localhost:8000/api/v1");
vi.stubEnv("VITE_BROWSER_STREAMING_MODE", "cdp");
return import("./useRuntimeConfig");
}
describe("runtime config helpers", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it("normalizes supported browser streaming modes", async () => {
const { normalizeBrowserStreamingMode } = await loadRuntimeConfigHelpers();
expect(normalizeBrowserStreamingMode("CDP")).toBe("cdp");
expect(normalizeBrowserStreamingMode("vnc")).toBe("vnc");
});
it("falls back to vnc for invalid browser streaming modes", async () => {
const { normalizeBrowserStreamingMode } = await loadRuntimeConfigHelpers();
expect(normalizeBrowserStreamingMode("unexpected")).toBe("vnc");
expect(normalizeBrowserStreamingMode(undefined)).toBe("vnc");
});
});

View file

@ -0,0 +1,64 @@
import { getClient } from "@/api/AxiosClient";
import { browserStreamingMode as buildTimeBrowserStreamingMode } from "@/util/env";
import { useQuery } from "@tanstack/react-query";
export type BrowserStreamingMode = "cdp" | "vnc";
type RuntimeConfigResponse = {
browser_streaming_mode?: string;
browser_streaming_label?: string;
environment?: string;
warnings?: string[];
};
const STREAMING_MODES = new Set(["cdp", "vnc"]);
function normalizeBrowserStreamingMode(
value: string | null | undefined,
): BrowserStreamingMode {
const normalized = (value ?? "").trim().toLowerCase();
return STREAMING_MODES.has(normalized)
? (normalized as BrowserStreamingMode)
: "vnc";
}
function browserStreamingLabel(mode: BrowserStreamingMode) {
return mode === "cdp" ? "Local browser streaming" : "VNC streaming";
}
function useRuntimeConfig() {
return useQuery<RuntimeConfigResponse>({
queryKey: ["runtimeConfig"],
queryFn: async () => {
const client = await getClient(null, "sans-api-v1");
return client.get("/config/runtime").then((response) => response.data);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
});
}
function useBrowserStreamingMode() {
const query = useRuntimeConfig();
const mode = normalizeBrowserStreamingMode(
query.data?.browser_streaming_mode ?? buildTimeBrowserStreamingMode,
);
return {
browserStreamingMode: mode,
browserStreamingLabel:
query.data?.browser_streaming_label ?? browserStreamingLabel(mode),
runtimeConfigSource: query.data ? "backend" : "build-time-fallback",
runtimeConfigWarnings: query.data?.warnings ?? [],
runtimeConfigQuery: query,
};
}
export {
browserStreamingLabel,
normalizeBrowserStreamingMode,
useBrowserStreamingMode,
useRuntimeConfig,
};

View file

@ -25,15 +25,17 @@ import { SaveSessionAsBrowserProfileDialog } from "@/routes/browserProfiles/Save
import { useBackgroundBrowserProfileCreate } from "@/routes/browserProfiles/hooks/useBackgroundBrowserProfileCreate";
import { CopyText } from "@/routes/workflows/editor/Workspace";
import { type BrowserSession as BrowserSessionType } from "@/routes/workflows/types/browserSessionTypes";
import { browserStreamingMode } from "@/util/env";
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
import {
StreamModeBadge,
type StreamMode,
} from "@/routes/streaming/StreamDiagnostics";
import { BrowserSessionDownloads } from "./BrowserSessionDownloads";
import { BrowserSessionVideo } from "./BrowserSessionVideo";
import { BrowserSessionStream } from "./BrowserSessionStream";
import { BrowserSessionWorkflowRuns } from "./BrowserSessionWorkflowRuns";
const isCdpMode = browserStreamingMode === "cdp";
type TabName = "stream" | "recordings" | "downloads" | "runs";
function BrowserSession() {
@ -49,6 +51,8 @@ function BrowserSession() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isSaveProfileDialogOpen, setIsSaveProfileDialogOpen] = useState(false);
const [vncFailed, setVncFailed] = useState(false);
const { browserStreamingMode } = useBrowserStreamingMode();
const isCdpMode = browserStreamingMode === "cdp";
useEffect(() => {
setVncFailed(false);
@ -71,6 +75,13 @@ function BrowserSession() {
});
const browserSession = query.data;
const streamMode: StreamMode = browserSession?.vnc_streaming_supported
? vncFailed
? "fallback"
: "vnc"
: isCdpMode
? "cdp"
: "unavailable";
const closeBrowserSessionMutation = useCloseBrowserSessionMutation({
browserSessionId,
@ -108,6 +119,7 @@ function BrowserSession() {
<div className="flex w-full flex-row items-center justify-start gap-2">
<LogoMinimized />
<div className="text-xl">Browser Session</div>
{activeTab === "stream" && <StreamModeBadge mode={streamMode} />}
{browserSession && (
<div className="ml-auto flex flex-col items-end justify-end overflow-hidden">
<div className="flex items-center justify-end gap-2">

View file

@ -3,6 +3,10 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { newWssBaseUrl, getCredentialParam } from "@/util/env";
import { useCdpInput } from "@/routes/streaming/useCdpInput";
import { InteractiveStreamView } from "@/routes/streaming/InteractiveStreamView";
import {
StreamStatusPanel,
type StreamDiagnostic,
} from "@/routes/streaming/StreamDiagnostics";
type StreamMessage = {
browser_session_id?: string;
@ -14,6 +18,64 @@ type StreamMessage = {
url?: string;
};
const STARTING_DIAGNOSTIC: StreamDiagnostic = {
title: "Starting local browser stream",
detail:
"Opening the stream WebSocket and waiting for the first browser frame.",
};
function diagnosticForStatus(status: string): StreamDiagnostic {
switch (status) {
case "not_found":
return {
title: "Browser session not found",
detail:
"The backend could not find this browser session for the current organization.",
hint: "Refresh the page or create a new browser session.",
};
case "timeout":
return {
title: "Timed out waiting for browser state",
detail:
"The stream connected, but the backend did not find an active page to screencast.",
hint: "Check backend logs for browser launch errors and verify BROWSER_STREAMING_MODE=cdp.",
};
case "completed":
case "failed":
return {
title: "Browser session is no longer live",
detail: `The browser session status is ${status}.`,
};
default:
return {
title: "Waiting for browser frames",
detail: `The stream is connected and the session status is ${status}.`,
};
}
}
function diagnosticForClose(event: CloseEvent): StreamDiagnostic {
if (event.code === 4001 || event.reason === "use-vnc-streaming") {
return {
title: "Backend is using VNC streaming",
detail:
"The UI tried local browser streaming, but the backend closed the stream with use-vnc-streaming.",
hint: "Check BROWSER_STREAMING_MODE on the backend and the runtime config response.",
};
}
if (event.code === 1006) {
return {
title: "Stream connection dropped",
detail: "The browser stream WebSocket closed before sending a frame.",
hint: "Check that the API server is running and reachable from the UI.",
};
}
return {
title: "Stream connection closed",
detail: `WebSocket closed with code ${event.code}${event.reason ? ` (${event.reason})` : ""}.`,
};
}
interface Props {
browserSessionId: string;
interactive?: boolean;
@ -32,9 +94,12 @@ function BrowserSessionStream({
const [viewportWidth, setViewportWidth] = useState(1280);
const [viewportHeight, setViewportHeight] = useState(720);
const [currentUrl, setCurrentUrl] = useState("");
const [diagnostic, setDiagnostic] =
useState<StreamDiagnostic>(STARTING_DIAGNOSTIC);
const credentialGetter = useCredentialGetter();
const socketRef = useRef<WebSocket | null>(null);
const hasFrameRef = useRef(false);
const inputWsUrl = interactive
? `${newWssBaseUrl}/stream/cdp_input/browser_session/${browserSessionId}`
@ -60,6 +125,8 @@ function BrowserSessionStream({
setViewportWidth(1280);
setViewportHeight(720);
setCurrentUrl("");
setDiagnostic(STARTING_DIAGNOSTIC);
hasFrameRef.current = false;
async function run() {
const credentialParam = await getCredentialParam(credentialGetter);
@ -74,10 +141,18 @@ function BrowserSessionStream({
`${newWssBaseUrl}/stream/browser_sessions/${browserSessionId}?${credentialParam}`,
);
socketRef.current.addEventListener("open", () => {
setDiagnostic({
title: "Connected to stream",
detail: "Waiting for the backend to attach to the browser page.",
});
});
socketRef.current.addEventListener("message", (event) => {
try {
const message: StreamMessage = JSON.parse(event.data);
if (message.screenshot) {
hasFrameRef.current = true;
setStreamImgSrc(message.screenshot);
}
if (message.format) {
@ -92,6 +167,9 @@ function BrowserSessionStream({
if (message.url !== undefined) {
setCurrentUrl(message.url);
}
if (!message.screenshot && message.status) {
setDiagnostic(diagnosticForStatus(message.status));
}
if (
message.status === "completed" ||
message.status === "failed" ||
@ -101,10 +179,25 @@ function BrowserSessionStream({
}
} catch (e) {
console.error("Failed to parse message", e);
setDiagnostic({
title: "Unexpected stream message",
detail: "The browser stream sent a message the UI could not parse.",
});
}
});
socketRef.current.addEventListener("close", () => {
socketRef.current.addEventListener("error", () => {
setDiagnostic({
title: "Stream WebSocket error",
detail:
"The browser stream connection hit a network or server error.",
});
});
socketRef.current.addEventListener("close", (event) => {
if (!cancelled && !hasFrameRef.current) {
setDiagnostic(diagnosticForClose(event));
}
socketRef.current = null;
});
}
@ -151,11 +244,7 @@ function BrowserSessionStream({
);
}
return (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-400">
Starting stream...
</div>
);
return <StreamStatusPanel diagnostic={diagnostic} />;
}
export { BrowserSessionStream };

View file

@ -0,0 +1,95 @@
import type { ReactNode } from "react";
import { InfoCircledIcon } from "@radix-ui/react-icons";
import { cn } from "@/util/utils";
type StreamDiagnostic = {
title: string;
detail?: string;
hint?: string;
};
type StreamMode = "cdp" | "vnc" | "fallback" | "unavailable";
const STREAM_MODE_COPY: Record<
StreamMode,
{ label: string; title: string; className: string }
> = {
cdp: {
label: "Local stream",
title: "Local browser streaming through the backend",
className: "border-cyan-500/40 bg-cyan-500/10 text-cyan-200",
},
vnc: {
label: "VNC",
title: "VNC browser streaming",
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-200",
},
fallback: {
label: "VNC -> Local",
title: "VNC disconnected; using local browser streaming fallback",
className: "border-amber-500/40 bg-amber-500/10 text-amber-200",
},
unavailable: {
label: "Unavailable",
title: "Browser streaming is unavailable for this session",
className: "border-slate-500/40 bg-slate-500/10 text-slate-300",
},
};
function StreamModeBadge({
mode,
className,
}: {
mode: StreamMode;
className?: string;
}) {
const copy = STREAM_MODE_COPY[mode];
return (
<span
title={copy.title}
className={cn(
"inline-flex h-5 items-center rounded border px-2 text-[0.68rem] font-medium uppercase leading-none tracking-normal",
copy.className,
className,
)}
>
{copy.label}
</span>
);
}
function StreamStatusPanel({
diagnostic,
children,
className,
}: {
diagnostic: StreamDiagnostic;
children?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex h-full w-full items-center justify-center rounded-md bg-slate-900 p-6 text-slate-300",
className,
)}
>
<div className="flex max-w-md flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-medium text-slate-100">
<InfoCircledIcon className="h-4 w-4 flex-shrink-0 text-slate-400" />
<span>{diagnostic.title}</span>
</div>
{diagnostic.detail && (
<div className="text-slate-400">{diagnostic.detail}</div>
)}
{diagnostic.hint && (
<div className="text-xs text-slate-500">{diagnostic.hint}</div>
)}
{children}
</div>
</div>
);
}
export { StreamModeBadge, StreamStatusPanel };
export type { StreamDiagnostic, StreamMode };

View file

@ -7,7 +7,12 @@ import { toast } from "@/components/ui/use-toast";
import { ZoomableImage } from "@/components/ZoomableImage";
import { useCostCalculator } from "@/hooks/useCostCalculator";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { browserStreamingMode, getCredentialParam } from "@/util/env";
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
import { getCredentialParam } from "@/util/env";
import {
StreamStatusPanel,
type StreamDiagnostic,
} from "@/routes/streaming/StreamDiagnostics";
import {
keepPreviousData,
useQuery,
@ -36,6 +41,56 @@ type StreamMessage = {
format?: string;
};
const STARTING_DIAGNOSTIC: StreamDiagnostic = {
title: "Starting browser stream",
detail:
"Opening the stream WebSocket and waiting for the first browser frame.",
};
function diagnosticForStatus(status: string): StreamDiagnostic {
switch (status) {
case "not_found":
return {
title: "Task not found",
detail:
"The backend could not find this task for the current organization.",
};
case "timeout":
return {
title: "Timed out waiting for browser state",
detail:
"The task started, but the backend did not find an active page to stream.",
hint: "Check backend logs for browser launch errors or a streaming-mode mismatch.",
};
case "completed":
case "failed":
case "terminated":
return {
title: "Task is no longer live",
detail: `The task status is ${status}.`,
};
default:
return {
title: "Waiting for browser frames",
detail: `The stream is connected and the task status is ${status}.`,
};
}
}
function diagnosticForClose(event: CloseEvent): StreamDiagnostic {
if (event.code === 1006) {
return {
title: "Stream connection dropped",
detail: "The browser stream WebSocket closed before sending a frame.",
hint: "Check that the API server is running and reachable from the UI.",
};
}
return {
title: "Stream connection closed",
detail: `WebSocket closed with code ${event.code}${event.reason ? ` (${event.reason})` : ""}.`,
};
}
const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL;
function TaskActions() {
@ -43,7 +98,10 @@ function TaskActions() {
const credentialGetter = useCredentialGetter();
const [streamImgSrc, setStreamImgSrc] = useState<string>("");
const [streamFormat, setStreamFormat] = useState<string>("png");
const [streamDiagnostic, setStreamDiagnostic] =
useState<StreamDiagnostic>(STARTING_DIAGNOSTIC);
const socketRef = useRef<WebSocket | null>(null);
const hasFrameRef = useRef(false);
const [selectedAction, setSelectedAction] = useState<
number | "stream" | null
>(null);
@ -70,6 +128,7 @@ function TaskActions() {
const taskIsNotFinalized = task && statusIsNotFinalized(task);
const taskIsRunningOrQueued = task && statusIsRunningOrQueued(task);
const browserSessionId = task?.browser_session_id;
const { browserStreamingMode } = useBrowserStreamingMode();
const shouldUseCdpStream = browserStreamingMode === "cdp";
useEffect(() => {
@ -82,9 +141,15 @@ function TaskActions() {
if (!taskIsRunningOrQueued) {
return;
}
setStreamDiagnostic(STARTING_DIAGNOSTIC);
hasFrameRef.current = false;
let cancelled = false;
async function run() {
const credentialParam = await getCredentialParam(credentialGetter);
if (cancelled) {
return;
}
if (socketRef.current) {
socketRef.current.close();
@ -93,15 +158,26 @@ function TaskActions() {
`${wssBaseUrl}/stream/tasks/${taskId}?${credentialParam}`,
);
socketRef.current.addEventListener("open", () => {
setStreamDiagnostic({
title: "Connected to stream",
detail: "Waiting for the backend to attach to the browser page.",
});
});
socketRef.current.addEventListener("message", (event) => {
try {
const message: StreamMessage = JSON.parse(event.data);
if (message.screenshot) {
hasFrameRef.current = true;
setStreamImgSrc(message.screenshot);
}
if (message.format) {
setStreamFormat(message.format);
}
if (!message.screenshot && message.status) {
setStreamDiagnostic(diagnosticForStatus(message.status));
}
if (
message.status === "completed" ||
message.status === "failed" ||
@ -130,16 +206,32 @@ function TaskActions() {
}
} catch (e) {
console.error("Failed to parse message", e);
setStreamDiagnostic({
title: "Unexpected stream message",
detail: "The browser stream sent a message the UI could not parse.",
});
}
});
socketRef.current.addEventListener("close", () => {
socketRef.current.addEventListener("error", () => {
setStreamDiagnostic({
title: "Stream WebSocket error",
detail:
"The browser stream connection hit a network or server error.",
});
});
socketRef.current.addEventListener("close", (event) => {
if (!cancelled && !hasFrameRef.current) {
setStreamDiagnostic(diagnosticForClose(event));
}
socketRef.current = null;
});
}
run();
return () => {
cancelled = true;
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
@ -207,7 +299,7 @@ function TaskActions() {
function getStream() {
// Use VNC streaming via BrowserStream when browser_session_id is available
// and CDP local livestreaming is not enabled.
// and local browser streaming is not enabled.
if (browserSessionId && !shouldUseCdpStream) {
return (
<AspectRatio ratio={16 / 9}>
@ -248,11 +340,7 @@ function TaskActions() {
}
if (task?.status === Status.Running && streamImgSrc.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center bg-slate-elevation1 text-lg">
Starting the stream...
</div>
);
return <StreamStatusPanel diagnostic={streamDiagnostic} />;
}
if (task?.status === Status.Running && streamImgSrc.length > 0) {

View file

@ -41,7 +41,8 @@ import { useBrowserSessionRateLimit } from "../hooks/useBrowserSessionRateLimit"
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
import { BrowserSessionStream } from "@/routes/browserSessions/BrowserSessionStream";
import { browserStreamingMode } from "@/util/env";
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
import { StreamModeBadge } from "@/routes/streaming/StreamDiagnostics";
import { useCacheKeyValuesQuery } from "../hooks/useCacheKeyValuesQuery";
import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { useRecordingStore } from "@/store/useRecordingStore";
@ -280,6 +281,7 @@ function Workspace({
const saveWorkflow = useWorkflowSave({ status: "published" });
const { data: workflowRun } = useWorkflowRunQuery();
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : false;
const { browserStreamingMode } = useBrowserStreamingMode();
const [openCycleBrowserDialogue, setOpenCycleBrowserDialogue] =
useState(false);
@ -1868,6 +1870,7 @@ function Workspace({
/>
<div className="flex items-center gap-2">
<GlobeIcon /> Live Browser
<StreamModeBadge mode="vnc" />
</div>
{showBreakoutButton && (
<BreakoutButton onClick={() => breakout()} />
@ -1893,7 +1896,7 @@ function Workspace({
</div>
)}
{/* CDP screencast: only in local mode when VNC is not supported */}
{/* Local browser stream: only in local mode when VNC is not supported */}
{activeDebugSession &&
!activeDebugSession.vnc_streaming_supported &&
browserStreamingMode === "cdp" && (
@ -1919,6 +1922,7 @@ function Workspace({
/>
<div className="flex items-center gap-2">
<GlobeIcon /> Live Browser
<StreamModeBadge mode="cdp" />
</div>
<div
className={cn("ml-auto flex items-center gap-2", {

View file

@ -23,7 +23,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { browserStreamingMode } from "@/util/env";
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
export type ActionItem = {
block: WorkflowRunBlock;
@ -42,6 +42,7 @@ function WorkflowRunOverview() {
const active = searchParams.get("active");
const queryClient = useQueryClient();
const [vncFailed, setVncFailed] = useState(false);
const { browserStreamingMode } = useBrowserStreamingMode();
const { data: workflowRun, isLoading: workflowRunIsLoading } =
useWorkflowRunWithWorkflowQuery();

View file

@ -9,6 +9,10 @@ import { toast } from "@/components/ui/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { useCdpInput } from "@/routes/streaming/useCdpInput";
import { InteractiveStreamView } from "@/routes/streaming/InteractiveStreamView";
import {
StreamStatusPanel,
type StreamDiagnostic,
} from "@/routes/streaming/StreamDiagnostics";
type StreamMessage = {
task_id?: string;
@ -20,6 +24,56 @@ type StreamMessage = {
viewport_height?: number;
};
const STARTING_DIAGNOSTIC: StreamDiagnostic = {
title: "Starting browser stream",
detail:
"Opening the stream WebSocket and waiting for the first browser frame.",
};
function diagnosticForStatus(status: string): StreamDiagnostic {
switch (status) {
case "not_found":
return {
title: "Workflow run not found",
detail:
"The backend could not find this workflow run for the current organization.",
};
case "timeout":
return {
title: "Timed out waiting for browser state",
detail:
"The run started, but the backend did not find an active page to stream.",
hint: "Check backend logs for browser launch errors or a streaming-mode mismatch.",
};
case "completed":
case "failed":
case "terminated":
return {
title: "Workflow run is no longer live",
detail: `The workflow run status is ${status}.`,
};
default:
return {
title: "Waiting for browser frames",
detail: `The stream is connected and the run status is ${status}.`,
};
}
}
function diagnosticForClose(event: CloseEvent): StreamDiagnostic {
if (event.code === 1006) {
return {
title: "Stream connection dropped",
detail: "The browser stream WebSocket closed before sending a frame.",
hint: "Check that the API server is running and reachable from the UI.",
};
}
return {
title: "Stream connection closed",
detail: `WebSocket closed with code ${event.code}${event.reason ? ` (${event.reason})` : ""}.`,
};
}
interface Props {
alwaysShowStream?: boolean;
interactive?: boolean;
@ -39,6 +93,8 @@ function WorkflowRunStream({
const [streamFormat, setStreamFormat] = useState<string>("png");
const [viewportWidth, setViewportWidth] = useState(1280);
const [viewportHeight, setViewportHeight] = useState(720);
const [diagnostic, setDiagnostic] =
useState<StreamDiagnostic>(STARTING_DIAGNOSTIC);
const showStream =
alwaysShowStream || (workflowRun && statusIsNotFinalized(workflowRun));
const credentialGetter = useCredentialGetter();
@ -47,6 +103,7 @@ function WorkflowRunStream({
const queryClient = useQueryClient();
const socketRef = useRef<WebSocket | null>(null);
const hasFrameRef = useRef(false);
const inputWsUrl =
interactive && workflowRunId
@ -70,9 +127,15 @@ function WorkflowRunStream({
if (!showStream) {
return;
}
setDiagnostic(STARTING_DIAGNOSTIC);
hasFrameRef.current = false;
let cancelled = false;
async function run() {
const credentialParam = await getCredentialParam(credentialGetter);
if (cancelled) {
return;
}
if (socketRef.current) {
socketRef.current.close();
@ -81,10 +144,18 @@ function WorkflowRunStream({
`${wssBaseUrl}/stream/workflow_runs/${workflowRunId}?${credentialParam}`,
);
socketRef.current.addEventListener("open", () => {
setDiagnostic({
title: "Connected to stream",
detail: "Waiting for the backend to attach to the browser page.",
});
});
socketRef.current.addEventListener("message", (event) => {
try {
const message: StreamMessage = JSON.parse(event.data);
if (message.screenshot) {
hasFrameRef.current = true;
setStreamImgSrc(message.screenshot);
}
if (message.format) {
@ -96,6 +167,9 @@ function WorkflowRunStream({
if (message.viewport_height) {
setViewportHeight(message.viewport_height);
}
if (!message.screenshot && message.status) {
setDiagnostic(diagnosticForStatus(message.status));
}
if (
message.status === "completed" ||
message.status === "failed" ||
@ -139,16 +213,32 @@ function WorkflowRunStream({
}
} catch (e) {
console.error("Failed to parse message", e);
setDiagnostic({
title: "Unexpected stream message",
detail: "The browser stream sent a message the UI could not parse.",
});
}
});
socketRef.current.addEventListener("close", () => {
socketRef.current.addEventListener("error", () => {
setDiagnostic({
title: "Stream WebSocket error",
detail:
"The browser stream connection hit a network or server error.",
});
});
socketRef.current.addEventListener("close", (event) => {
if (!cancelled && !hasFrameRef.current) {
setDiagnostic(diagnosticForClose(event));
}
socketRef.current = null;
});
}
run();
return () => {
cancelled = true;
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
@ -184,11 +274,7 @@ function WorkflowRunStream({
}
if (isRunningOrPaused && streamImgSrc.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center rounded-md bg-slate-900 py-8 text-lg">
Starting the stream...
</div>
);
return <StreamStatusPanel diagnostic={diagnostic} />;
}
const hasStream =
@ -211,11 +297,7 @@ function WorkflowRunStream({
}
if (alwaysShowStream) {
return (
<div className="flex h-full w-full items-center justify-center">
Waiting for stream...
</div>
);
return <StreamStatusPanel diagnostic={diagnostic} />;
}
return null;

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import importlib
import importlib.util
import json
@ -14,6 +15,7 @@ import socket
import subprocess
import sys
import traceback
import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
@ -711,7 +713,7 @@ def _validate_streaming_mode_value(value: str | None, source: str) -> list[str]:
def _check_local_streaming_mode() -> CheckResult:
"""Check that local self-hosted installs default to CDP livestreaming."""
"""Check that local self-hosted installs default to local browser streaming."""
from dotenv import dotenv_values
from skyvern.utils.env_paths import resolve_backend_env_path
@ -721,19 +723,26 @@ def _check_local_streaming_mode() -> CheckResult:
backend_raw = dotenv_values(backend_env).get(BACKEND_STREAMING_MODE_VAR, "") if backend_env.exists() else ""
frontend_raw = dotenv_values(frontend_env).get(FRONTEND_STREAMING_MODE_VAR, "") if frontend_env.exists() else ""
backend_mode = _normalize_streaming_mode(str(backend_raw or ""))
frontend_mode = _normalize_streaming_mode(str(frontend_raw or ""))
problems: list[str] = []
if backend_env.exists():
problems.extend(_validate_streaming_mode_value(str(backend_raw or ""), "backend .env"))
if _normalize_streaming_mode(str(backend_raw or "")) != LOCAL_STREAMING_MODE:
problems.append(f"backend .env {BACKEND_STREAMING_MODE_VAR} should be {LOCAL_STREAMING_MODE}")
if backend_mode != LOCAL_STREAMING_MODE:
problems.append(
f"backend .env {BACKEND_STREAMING_MODE_VAR}: {backend_mode or 'missing'} -> {LOCAL_STREAMING_MODE}"
)
else:
problems.append("backend .env is missing")
if frontend_env.exists():
problems.extend(_validate_streaming_mode_value(str(frontend_raw or ""), "skyvern-frontend/.env"))
if _normalize_streaming_mode(str(frontend_raw or "")) != LOCAL_STREAMING_MODE:
problems.append(f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR} should be {LOCAL_STREAMING_MODE}")
if frontend_mode != LOCAL_STREAMING_MODE:
problems.append(
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}: "
f"{frontend_mode or 'missing'} -> {LOCAL_STREAMING_MODE}"
)
else:
problems.append("skyvern-frontend/.env is missing")
@ -742,14 +751,17 @@ def _check_local_streaming_mode() -> CheckResult:
name="Local Streaming Mode",
status="warn",
detail="; ".join(problems),
hint="Run `skyvern doctor --fix` to enable CDP livestreaming for backend and UI",
hint="Run `skyvern doctor --fix` to enable local browser streaming for backend and UI",
)
if not _docker_compose_available():
return CheckResult(
name="Local Streaming Mode",
status="ok",
detail="backend and frontend env files enable CDP livestreaming",
detail=(
f"backend .env {BACKEND_STREAMING_MODE_VAR}={backend_mode}; "
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}={frontend_mode}"
),
)
container_problems: list[str] = []
@ -794,13 +806,21 @@ def _check_local_streaming_mode() -> CheckResult:
return CheckResult(
name="Local Streaming Mode",
status="ok",
detail="backend and frontend env files enable CDP livestreaming; running containers not checked",
detail=(
f"backend .env {BACKEND_STREAMING_MODE_VAR}={backend_mode}; "
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}={frontend_mode}; "
"running containers not checked"
),
)
return CheckResult(
name="Local Streaming Mode",
status="ok",
detail="CDP livestreaming is enabled for backend, UI, and running containers",
detail=(
f"backend .env {BACKEND_STREAMING_MODE_VAR}={backend_mode}; "
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}={frontend_mode}; "
"running containers use local browser streaming"
),
)
@ -1445,20 +1465,32 @@ def _ensure_frontend_env_exists() -> Path:
def _fix_local_streaming_mode() -> bool:
"""Enable local CDP livestreaming in backend and frontend env files."""
from dotenv import set_key
"""Enable local browser streaming in backend and frontend env files."""
from dotenv import dotenv_values, set_key
from skyvern.utils.env_paths import resolve_backend_env_path
backend_env = resolve_backend_env_path()
backend_before = ""
if backend_env.exists():
backend_before = str(dotenv_values(backend_env).get(BACKEND_STREAMING_MODE_VAR) or "")
if not backend_env.exists():
backend_env.touch()
set_key(str(backend_env), BACKEND_STREAMING_MODE_VAR, LOCAL_STREAMING_MODE, quote_mode="never")
console.print(f" [cyan]Set {BACKEND_STREAMING_MODE_VAR}={LOCAL_STREAMING_MODE} in backend .env[/cyan]")
console.print(
" [cyan]Set "
f"backend .env {BACKEND_STREAMING_MODE_VAR}: "
f"{_normalize_streaming_mode(backend_before) or 'missing'} -> {LOCAL_STREAMING_MODE}[/cyan]"
)
frontend_env = _ensure_frontend_env_exists()
frontend_before = str(dotenv_values(frontend_env).get(FRONTEND_STREAMING_MODE_VAR) or "")
set_key(str(frontend_env), FRONTEND_STREAMING_MODE_VAR, LOCAL_STREAMING_MODE, quote_mode="never")
console.print(f" [cyan]Set {FRONTEND_STREAMING_MODE_VAR}={LOCAL_STREAMING_MODE} in skyvern-frontend/.env[/cyan]")
console.print(
" [cyan]Set "
f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR}: "
f"{_normalize_streaming_mode(frontend_before) or 'missing'} -> {LOCAL_STREAMING_MODE}[/cyan]"
)
if _docker_compose_available():
return _recreate_docker_services(["skyvern", "skyvern-ui"])
@ -1600,6 +1632,190 @@ def _recreate_docker_services(services: list[str]) -> bool:
return False
def _normalize_api_root(base_url: str | None) -> str:
raw = (base_url or "http://localhost:8000").strip().rstrip("/")
parsed = urllib.parse.urlsplit(raw)
if not parsed.scheme or not parsed.netloc:
raise ValueError(f"Invalid base URL: {raw}")
path = parsed.path.rstrip("/")
for suffix in ("/api/v1", "/v1", "/api/v2", "/v2"):
if path.endswith(suffix):
path = path[: -len(suffix)]
break
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path.rstrip("/"), "", ""))
def _api_v1_url(api_root: str, path: str) -> str:
return f"{api_root}/v1{path}"
def _ws_v1_url(api_root: str, path: str, query: dict[str, str]) -> str:
parsed = urllib.parse.urlsplit(api_root)
scheme = "wss" if parsed.scheme == "https" else "ws"
query_string = urllib.parse.urlencode(query)
return urllib.parse.urlunsplit((scheme, parsed.netloc, f"{parsed.path.rstrip('/')}/v1{path}", query_string, ""))
async def _wait_for_stream_frame(ws_url: str, timeout_seconds: int) -> str:
import websockets
async with websockets.connect(ws_url, open_timeout=min(timeout_seconds, 10)) as websocket:
deadline = asyncio.get_running_loop().time() + timeout_seconds
last_status = "connected"
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise TimeoutError(f"stream connected but no frame arrived; last status={last_status}")
raw = await asyncio.wait_for(websocket.recv(), timeout=remaining)
if not isinstance(raw, str):
continue
try:
payload = json.loads(raw)
except json.JSONDecodeError:
last_status = raw[:120]
continue
if payload.get("screenshot"):
return str(payload.get("format") or "unknown")
last_status = str(payload.get("status") or payload)
if last_status in {"not_found", "timeout", "completed", "failed"}:
raise RuntimeError(f"stream ended before a frame arrived: {last_status}")
async def _wait_for_cdp_input_ready(ws_url: str, timeout_seconds: int) -> None:
import websockets
async with websockets.connect(ws_url, open_timeout=min(timeout_seconds, 10)) as websocket:
raw = await asyncio.wait_for(websocket.recv(), timeout=timeout_seconds)
if not isinstance(raw, str):
raise RuntimeError("CDP input channel returned a non-text message")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(f"CDP input channel returned non-JSON: {raw[:120]}") from exc
if payload.get("kind") != "ready":
raise RuntimeError(f"CDP input channel did not become ready: {payload}")
async def _streaming_smoke_test_async(
*,
base_url: str | None,
api_key: str,
browser_session_id: str | None,
timeout_seconds: int,
) -> CheckResult:
api_root = _normalize_api_root(base_url)
headers = {"X-API-Key": api_key}
created_session = False
session_id = browser_session_id
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
runtime_response = await client.get(_api_v1_url(api_root, "/config/runtime"))
runtime_response.raise_for_status()
runtime_config = runtime_response.json()
streaming_mode = _normalize_streaming_mode(str(runtime_config.get("browser_streaming_mode") or ""))
if streaming_mode != LOCAL_STREAMING_MODE:
return CheckResult(
name="Streaming Smoke Test",
status="warn",
detail=f"backend reports {BACKEND_STREAMING_MODE_VAR}={streaming_mode or 'missing'}",
hint="Set BROWSER_STREAMING_MODE=cdp for local browser streaming smoke tests",
)
if not session_id:
create_response = await client.post(
_api_v1_url(api_root, "/browser_sessions"),
headers=headers,
json={"timeout": 5},
)
create_response.raise_for_status()
session_id = str(create_response.json().get("browser_session_id") or "")
created_session = True
if not session_id:
raise RuntimeError("browser session create response did not include browser_session_id")
try:
deadline = asyncio.get_running_loop().time() + timeout_seconds
last_status = "created"
while True:
session_response = await client.get(
_api_v1_url(api_root, f"/browser_sessions/{session_id}"),
headers=headers,
)
session_response.raise_for_status()
session = session_response.json()
last_status = str(session.get("status") or "unknown")
if last_status == "running" or session.get("started_at") or session.get("browser_address"):
break
if last_status in {"completed", "failed", "terminated"}:
raise RuntimeError(f"browser session finalized before streaming: {last_status}")
if asyncio.get_running_loop().time() >= deadline:
raise TimeoutError(f"browser session did not start; last status={last_status}")
await asyncio.sleep(1)
auth_query = {"apikey": api_key}
frame_format = await _wait_for_stream_frame(
_ws_v1_url(api_root, f"/stream/browser_sessions/{session_id}", auth_query),
timeout_seconds=timeout_seconds,
)
input_query = {
"apikey": api_key,
"client_id": f"doctor-smoke-{secrets.token_hex(4)}",
}
await _wait_for_cdp_input_ready(
_ws_v1_url(api_root, f"/stream/cdp_input/browser_session/{session_id}", input_query),
timeout_seconds=timeout_seconds,
)
return CheckResult(
name="Streaming Smoke Test",
status="ok",
detail=f"received {frame_format} frame and CDP input channel became ready for {session_id}",
)
finally:
if created_session and session_id:
try:
await client.post(
_api_v1_url(api_root, f"/browser_sessions/{session_id}/close"),
headers=headers,
)
except Exception as exc:
console.print(f" [yellow]Could not close smoke-test browser session {session_id}: {exc}[/yellow]")
def _streaming_smoke_test(
*,
base_url: str | None,
api_key: str | None,
browser_session_id: str | None,
timeout_seconds: int,
) -> CheckResult:
key = (api_key or os.environ.get("SKYVERN_API_KEY") or _read_generated_credential()).strip()
if not key:
return CheckResult(
name="Streaming Smoke Test",
status="error",
detail="No API key available",
hint="Pass --api-key or set SKYVERN_API_KEY",
)
try:
return asyncio.run(
_streaming_smoke_test_async(
base_url=base_url,
api_key=key,
browser_session_id=browser_session_id,
timeout_seconds=timeout_seconds,
)
)
except Exception as exc:
return CheckResult(
name="Streaming Smoke Test",
status="error",
detail=str(exc),
hint="Check that the backend is running, local browser streaming is enabled, and browser launch succeeds",
)
def _fix_install_playwright() -> bool:
console.print(" [cyan]Installing Chromium via Playwright...[/cyan]")
result = subprocess.run(["playwright", "install", "chromium"], capture_output=True, text=True)
@ -1610,6 +1826,45 @@ def _fix_install_playwright() -> bool:
return False
@doctor_app.command("streaming")
def streaming(
base_url: str = typer.Option(
"http://localhost:8000",
"--base-url",
help="Skyvern API server root or v1 URL.",
),
api_key: str | None = typer.Option(None, "--api-key", help="Skyvern API key. Defaults to SKYVERN_API_KEY."),
browser_session_id: str | None = typer.Option(
None,
"--browser-session-id",
help="Existing browser session to test. If omitted, doctor creates and closes one.",
),
timeout_seconds: int = typer.Option(45, "--timeout", min=5, max=180, help="Seconds to wait for stream readiness."),
json_output: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
) -> None:
"""Create or reuse a browser session and verify local browser streaming."""
result = _streaming_smoke_test(
base_url=base_url,
api_key=api_key,
browser_session_id=browser_session_id,
timeout_seconds=timeout_seconds,
)
if json_output:
console.print(json.dumps(result.__dict__))
else:
table = Table(show_header=True, header_style="bold", title="Skyvern Streaming Doctor")
table.add_column("Check", style="bold")
table.add_column("Status")
table.add_column("Details")
table.add_column("Fix")
table.add_row(result.name, _STATUS_STYLE.get(result.status, result.status), result.detail, result.hint)
console.print(table)
if result.status == "error":
raise typer.Exit(code=1)
@doctor_app.callback(invoke_without_command=True)
def doctor(
ctx: typer.Context,

View file

@ -300,7 +300,7 @@ def _run_server_quickstart(
)
run_local = bool(init_result)
if run_local:
_configure_cdp_livestreaming_defaults()
_configure_local_browser_streaming_defaults()
# Start services
if run_local:
@ -535,8 +535,8 @@ def _handle_postgres_container_conflict() -> None:
)
def _configure_cdp_livestreaming_defaults() -> None:
"""Enable local CDP livestreaming for self-hosted quickstart paths."""
def _configure_local_browser_streaming_defaults() -> None:
"""Enable local browser streaming for self-hosted quickstart paths."""
from dotenv import set_key
from skyvern.cli.llm_setup import update_or_add_env_var
@ -569,7 +569,7 @@ def run_docker_compose_setup() -> None:
# Configure LLM provider
console.print("\n[bold blue]Step 1: Configure LLM Provider[/bold blue]")
setup_llm_providers()
_configure_cdp_livestreaming_defaults()
_configure_local_browser_streaming_defaults()
# Run docker compose up
console.print("\n[bold blue]Step 2: Starting Docker Compose...[/bold blue]")

View file

@ -6,6 +6,7 @@ from skyvern.forge.sdk.routes import debug_sessions # noqa: F401
from skyvern.forge.sdk.routes import prompts # noqa: F401
from skyvern.forge.sdk.routes import pylon # noqa: F401
from skyvern.forge.sdk.routes import run_blocks # noqa: F401
from skyvern.forge.sdk.routes import runtime_config # noqa: F401
from skyvern.forge.sdk.routes import scripts # noqa: F401
from skyvern.forge.sdk.routes import sdk # noqa: F401
from skyvern.forge.sdk.routes import webhooks # noqa: F401

View file

@ -0,0 +1,41 @@
from typing import Literal, cast
from pydantic import BaseModel
from skyvern.config import settings
from skyvern.forge.sdk.routes.routers import base_router, legacy_base_router
BrowserStreamingMode = Literal["cdp", "vnc"]
_ALLOWED_STREAMING_MODES: set[str] = {"cdp", "vnc"}
class RuntimeConfig(BaseModel):
browser_streaming_mode: BrowserStreamingMode
browser_streaming_label: str
environment: str
warnings: list[str] = []
def _normalize_browser_streaming_mode(value: str | None) -> tuple[BrowserStreamingMode, list[str]]:
mode = (value or "").strip().lower()
if mode in _ALLOWED_STREAMING_MODES:
return cast(BrowserStreamingMode, mode), []
return "vnc", [f"Invalid BROWSER_STREAMING_MODE={value!r}; using vnc fallback"]
def _browser_streaming_label(mode: BrowserStreamingMode) -> str:
if mode == "cdp":
return "Local browser streaming"
return "VNC streaming"
@base_router.get("/config/runtime", include_in_schema=False)
@legacy_base_router.get("/config/runtime", include_in_schema=False)
async def get_runtime_config() -> RuntimeConfig:
mode, warnings = _normalize_browser_streaming_mode(settings.BROWSER_STREAMING_MODE)
return RuntimeConfig(
browser_streaming_mode=mode,
browser_streaming_label=_browser_streaming_label(mode),
environment=settings.ENV,
warnings=warnings,
)

View file

@ -115,7 +115,7 @@ def test_local_streaming_mode_warns_when_frontend_is_vnc(
result = doctor_module._check_local_streaming_mode()
assert result.status == "warn"
assert "VITE_BROWSER_STREAMING_MODE should be cdp" in result.detail
assert "VITE_BROWSER_STREAMING_MODE: vnc -> cdp" in result.detail
def test_fix_local_streaming_mode_writes_backend_and_frontend(
@ -135,6 +135,26 @@ def test_fix_local_streaming_mode_writes_backend_and_frontend(
assert "VITE_BROWSER_STREAMING_MODE=cdp" in (frontend_dir / ".env").read_text()
def test_normalize_api_root_strips_version_prefix() -> None:
assert doctor_module._normalize_api_root("http://localhost:8000/api/v1") == "http://localhost:8000"
assert doctor_module._normalize_api_root("http://localhost:8000/v1") == "http://localhost:8000"
def test_streaming_smoke_test_requires_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SKYVERN_API_KEY", raising=False)
monkeypatch.setattr(doctor_module, "_read_generated_credential", lambda: "")
result = doctor_module._streaming_smoke_test(
base_url="http://localhost:8000",
api_key=None,
browser_session_id=None,
timeout_seconds=5,
)
assert result.status == "error"
assert "No API key" in result.detail
def test_compose_database_connection_can_satisfy_database_check(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,

View file

@ -0,0 +1,103 @@
import sys
from collections.abc import Iterator
from types import ModuleType, SimpleNamespace
import pytest
from tests.unit_tests._stub_streaming import import_with_stubs
class _FakeAPIRouter:
def __init__(self, *, include_in_schema: bool = True, **_: object) -> None:
self.include_in_schema = include_in_schema
self.routes: list[SimpleNamespace] = []
def get(self, path: str, *, include_in_schema: bool = True, **_: object):
def decorator(fn):
self.routes.append(
SimpleNamespace(
path=path,
include_in_schema=include_in_schema and self.include_in_schema,
endpoint=fn,
)
)
return fn
return decorator
@pytest.fixture()
def runtime_config_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[ModuleType]:
module_names = [
"skyvern.forge.sdk.routes.runtime_config",
"skyvern.forge.sdk.routes.routers",
"skyvern.forge.sdk.routes",
]
missing = object()
original_modules = {module_name: sys.modules.get(module_name, missing) for module_name in module_names}
fastapi_stub = ModuleType("fastapi")
fastapi_stub.APIRouter = _FakeAPIRouter
monkeypatch.setitem(sys.modules, "fastapi", fastapi_stub)
for module_name in module_names:
sys.modules.pop(module_name, None)
module = import_with_stubs(
"skyvern.forge.sdk.routes.runtime_config",
extra_stubs=[
"skyvern.forge.sdk.routes.workflow_schedules",
"skyvern.forge.sdk.routes.streaming.screenshot",
],
)
yield module
for module_name in module_names:
sys.modules.pop(module_name, None)
for module_name, original_module in original_modules.items():
if original_module is not missing:
sys.modules[module_name] = original_module
@pytest.mark.asyncio
async def test_runtime_config_returns_normalized_browser_streaming_mode(
runtime_config_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_config = runtime_config_module
monkeypatch.setattr(runtime_config.settings, "BROWSER_STREAMING_MODE", "CDP")
monkeypatch.setattr(runtime_config.settings, "ENV", "local")
result = await runtime_config.get_runtime_config()
assert result.browser_streaming_mode == "cdp"
assert result.browser_streaming_label == "Local browser streaming"
assert result.environment == "local"
assert result.warnings == []
@pytest.mark.asyncio
async def test_runtime_config_falls_back_for_invalid_streaming_mode(
runtime_config_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_config = runtime_config_module
monkeypatch.setattr(runtime_config.settings, "BROWSER_STREAMING_MODE", "unexpected")
result = await runtime_config.get_runtime_config()
assert result.browser_streaming_mode == "vnc"
assert result.warnings
def test_runtime_config_routes_are_hidden_from_openapi_schema(runtime_config_module: ModuleType) -> None:
routers = sys.modules["skyvern.forge.sdk.routes.routers"]
base_router = routers.base_router
legacy_base_router = routers.legacy_base_router
base_routes = [route for route in base_router.routes if getattr(route, "path", None) == "/config/runtime"]
legacy_routes = [route for route in legacy_base_router.routes if getattr(route, "path", None) == "/config/runtime"]
assert base_routes
assert legacy_routes
assert all(getattr(route, "include_in_schema", True) is False for route in base_routes)
assert all(getattr(route, "include_in_schema", True) is False for route in legacy_routes)

View file

@ -81,7 +81,7 @@ def test_quickstart_does_not_start_services_when_required_browser_is_missing(
monkeypatch.setattr(quickstart_module, "check_docker_compose_file", lambda: False)
monkeypatch.setattr(quickstart_module, "_has_server_quickstart_extra", lambda: True)
monkeypatch.setattr(init_command, "init_env", lambda **_kwargs: init_result)
monkeypatch.setattr(quickstart_module, "_configure_cdp_livestreaming_defaults", lambda: None)
monkeypatch.setattr(quickstart_module, "_configure_local_browser_streaming_defaults", lambda: None)
monkeypatch.setattr(
quickstart_module.typer,
"confirm",