From 0530d120d29e6c2e4d72860dd41bde6f04508410 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Mon, 28 Apr 2025 16:39:40 -0400 Subject: [PATCH 01/19] Add a Stop command that does a bit of cleanup in the engineStateMachine --- src/App.tsx | 5 +++-- src/machines/engineStreamMachine.ts | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4e501a20e1..9f01f4c7d0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -135,9 +135,10 @@ export function App() { }, [lastCommandType]) useEffect(() => { - // When leaving the modeling scene, cut the engine stream. return () => { - engineStreamActor.send({ type: EngineStreamTransition.Pause }) + // When leaving the modeling scene, cut the engine stream. + // Stop is more serious than Pause + engineStreamActor.send({ type: EngineStreamTransition.Stop }) } }, []) diff --git a/src/machines/engineStreamMachine.ts b/src/machines/engineStreamMachine.ts index 69ac02e1a9..264cad83f0 100644 --- a/src/machines/engineStreamMachine.ts +++ b/src/machines/engineStreamMachine.ts @@ -270,6 +270,9 @@ export const engineStreamMachine = setup({ [EngineStreamTransition.Pause]: { target: EngineStreamState.Paused, }, + [EngineStreamTransition.Stop]: { + target: EngineStreamState.Stopped, + }, }, }, [EngineStreamState.Reconfiguring]: { From 77847b7e4dfa76e1fbd0a29bc9c537d8ce982956 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Mon, 28 Apr 2025 16:42:50 -0400 Subject: [PATCH 02/19] Major network code startup refactor; backoff reconnect; many more logs for us --- src/components/EngineStream.tsx | 212 ++++++++------ src/components/ModelingMachineProvider.tsx | 3 +- src/lang/std/engineConnection.ts | 315 +++++++++++++-------- src/machines/engineStreamMachine.ts | 274 +++++++++++------- 4 files changed, 510 insertions(+), 294 deletions(-) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 0c8553fbdb..8440036eca 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -5,7 +5,10 @@ import { useModelingContext } from '@src/hooks/useModelingContext' import { useNetworkContext } from '@src/hooks/useNetworkContext' import { NetworkHealthState } from '@src/hooks/useNetworkStatus' import { getArtifactOfTypes } from '@src/lang/std/artifactGraph' -import { EngineCommandManagerEvents } from '@src/lang/std/engineConnection' +import { + EngineCommandManagerEvents, + EngineConnectionStateType, +} from '@src/lang/std/engineConnection' import { btnName } from '@src/lib/cameraControls' import { PATHS } from '@src/lib/paths' import { sendSelectEventToEngine } from '@src/lib/selections' @@ -35,17 +38,28 @@ export const EngineStream = (props: { authToken: string | undefined }) => { const { setAppState } = useAppState() - const [firstPlay, setFirstPlay] = useState(true) - - const { overallState } = useNetworkContext() const settings = useSettings() - - const engineStreamState = useSelector(engineStreamActor, (state) => state) - + const { state: modelingMachineState, send: modelingMachineActorSend } = + useModelingContext() + const commandBarState = useCommandBarState() const { file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const last = useRef(Date.now()) + + const [firstPlay, setFirstPlay] = useState(true) + const [isRestartRequestStarting, setIsRestartRequestStarting] = + useState(false) + const [attemptTimes, setAttemptTimes] = useState<[number, number]>([0, 1000]) + + // These will be passed to the engineStreamActor to handle. + const videoRef = useRef(null) + const canvasRef = useRef(null) + + // For attaching right-click menu events const videoWrapperRef = useRef(null) + const { overallState } = useNetworkContext() + const engineStreamState = useSelector(engineStreamActor, (state) => state) + const settingsEngine = { theme: settings.app.theme.current, enableSSAO: settings.modeling.enableSSAO.current, @@ -54,19 +68,46 @@ export const EngineStream = (props: { cameraProjection: settings.modeling.cameraProjection.current, } - const { state: modelingMachineState, send: modelingMachineActorSend } = - useModelingContext() - const streamIdleMode = settings.app.streamIdleMode.current + useEffect(() => { + engineStreamActor.send({ + type: EngineStreamTransition.SetVideoRef, + videoRef: { current: videoRef.current }, + }) + }, [videoRef.current]) + + useEffect(() => { + engineStreamActor.send({ + type: EngineStreamTransition.SetCanvasRef, + canvasRef: { current: canvasRef.current }, + }) + }, [canvasRef.current]) + + useEffect(() => { + engineStreamActor.send({ + type: EngineStreamTransition.SetPool, + pool: props.pool, + }) + }, [props.pool]) + + useEffect(() => { + engineStreamActor.send({ + type: EngineStreamTransition.SetAuthToken, + authToken: props.authToken, + }) + }, [props.authToken]) + + // We have to call this here because of the dependencies: + // modelingMachineActorSend, setAppState, settingsEngine + // It's possible to pass these in earlier but I (lee) don't want to + // restructure this further at the moment. const startOrReconfigureEngine = () => { engineStreamActor.send({ type: EngineStreamTransition.StartOrReconfigureEngine, modelingMachineActorSend, settings: settingsEngine, setAppState, - - // It's possible a reconnect happens as we drag the window :') onMediaStream(mediaStream: MediaStream) { engineStreamActor.send({ type: EngineStreamTransition.SetMediaStream, @@ -76,18 +117,47 @@ export const EngineStream = (props: { }) } - // When the scene is ready play the stream and execute! + useEffect(() => { + if ( + engineStreamState.value !== EngineStreamState.WaitingForDependencies && + engineStreamState.value !== EngineStreamState.Stopped + ) + return + startOrReconfigureEngine() + }, [engineStreamState, setAppState]) + + // I would inline this but it needs to be a function for removeEventListener. const play = () => { engineStreamActor.send({ type: EngineStreamTransition.Play, }) + } + useEffect(() => { + engineCommandManager.addEventListener( + EngineCommandManagerEvents.SceneReady, + play + ) + return () => { + engineCommandManager.removeEventListener( + EngineCommandManagerEvents.SceneReady, + play + ) + } + }, []) + + // When the scene is ready, execute kcl! + const executeKcl = () => { + console.log('scene is ready, execute kcl') const kmp = kclManager.executeCode().catch(trap) if (!firstPlay) return + setFirstPlay(false) - console.log('scene is ready, fire!') + // Reset the restart timeouts + setAttemptTimes([0, 1000]) + console.log('firstPlay true, zoom to fit') kmp .then(() => // It makes sense to also call zoom to fit here, when a new file is @@ -110,51 +180,59 @@ export const EngineStream = (props: { useEffect(() => { engineCommandManager.addEventListener( EngineCommandManagerEvents.SceneReady, - play + executeKcl ) return () => { engineCommandManager.removeEventListener( EngineCommandManagerEvents.SceneReady, - play + executeKcl ) } }, [firstPlay]) useEffect(() => { - engineCommandManager.addEventListener( - EngineCommandManagerEvents.SceneReady, - play - ) + // We do a back-off restart, using a fibonacci sequence, since it + // has a nice retry time curve (somewhat quick then exponential) + const attemptRestartIfNecessary = () => { + if (isRestartRequestStarting) return + setIsRestartRequestStarting(true) + setTimeout(() => { + engineStreamState.context.videoRef.current?.pause() + engineCommandManager.tearDown() + startOrReconfigureEngine() + setFirstPlay(false) + setIsRestartRequestStarting(false) + }, attemptTimes[0] + attemptTimes[1]) + setAttemptTimes([attemptTimes[1], attemptTimes[0] + attemptTimes[1]]) + } - engineStreamActor.send({ - type: EngineStreamTransition.SetPool, - data: { pool: props.pool }, - }) - engineStreamActor.send({ - type: EngineStreamTransition.SetAuthToken, - data: { authToken: props.authToken }, - }) + // Poll that we're connected. If not, send a reset signal. + // Do not restart if we're in idle mode. + const connectionCheckIntervalId = setInterval(() => { + // Don't try try to restart if we're already connected! + const hasEngineConnectionInst = engineCommandManager.engineConnection + const isDisconnected = + engineCommandManager.engineConnection?.state.type === + EngineConnectionStateType.Disconnected + const inIdleMode = engineStreamState.value === EngineStreamState.Paused + if ((hasEngineConnectionInst && !isDisconnected) || inIdleMode) return - return () => { - engineCommandManager.tearDown() - } - }, []) + attemptRestartIfNecessary() + }, 1000) - // In the past we'd try to play immediately, but the proper thing is to way - // for the 'canplay' event to tell us data is ready. - useEffect(() => { - const videoRef = engineStreamState.context.videoRef.current - if (!videoRef) { - return - } - const play = () => { - videoRef.play().catch(console.error) - } - videoRef.addEventListener('canplay', play) + engineCommandManager.addEventListener( + EngineCommandManagerEvents.EngineRestartRequest, + attemptRestartIfNecessary + ) return () => { - videoRef.removeEventListener('canplay', play) + clearInterval(connectionCheckIntervalId) + + engineCommandManager.removeEventListener( + EngineCommandManagerEvents.EngineRestartRequest, + attemptRestartIfNecessary + ) } - }, [engineStreamState.context.videoRef.current]) + }, [engineStreamState, attemptTimes, isRestartRequestStarting]) useEffect(() => { if (engineStreamState.value === EngineStreamState.Reconfiguring) return @@ -182,25 +260,6 @@ export const EngineStream = (props: { }).observe(document.body) }, [engineStreamState.value]) - // When the video and canvas element references are set, start the engine. - useEffect(() => { - if ( - engineStreamState.context.canvasRef.current && - engineStreamState.context.videoRef.current - ) { - startOrReconfigureEngine() - } - }, [ - engineStreamState.context.canvasRef.current, - engineStreamState.context.videoRef.current, - ]) - - // On settings change, reconfigure the engine. When paused this gets really tricky, - // and also requires onMediaStream to be set! - useEffect(() => { - startOrReconfigureEngine() - }, Object.values(settingsEngine)) - /** * Subscribe to execute code when the file changes * but only if the scene is already ready. @@ -276,18 +335,7 @@ export const EngineStream = (props: { } if (engineStreamState.value === EngineStreamState.Paused) { - engineStreamActor.send({ - type: EngineStreamTransition.StartOrReconfigureEngine, - modelingMachineActorSend, - settings: settingsEngine, - setAppState, - onMediaStream(mediaStream: MediaStream) { - engineStreamActor.send({ - type: EngineStreamTransition.SetMediaStream, - mediaStream, - }) - }, - }) + startOrReconfigureEngine() } timeoutStart.current = Date.now() @@ -390,7 +438,7 @@ export const EngineStream = (props: { autoPlay muted key={engineStreamActor.id + 'video'} - ref={engineStreamState.context.videoRef} + ref={videoRef} controls={false} className="w-full cursor-pointer h-full" disablePictureInPicture @@ -398,7 +446,7 @@ export const EngineStream = (props: { /> @@ -415,9 +463,11 @@ export const EngineStream = (props: { } menuTargetElement={videoWrapperRef} /> - {![EngineStreamState.Playing, EngineStreamState.Paused].some( - (s) => s === engineStreamState.value - ) && ( + {![ + EngineStreamState.Playing, + EngineStreamState.Paused, + EngineStreamState.Resuming, + ].some((s) => s === engineStreamState.value) && ( Connecting to engine diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index cda06532b3..fc38592b59 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -195,12 +195,13 @@ export const ModelingMachineProvider = ({ sceneInfra.camControls.syncDirection = 'engineToClient' + // TODO: Re-evaluate if this pause/play logic is needed. store.videoElement?.pause() return kclManager .executeCode() .then(() => { - if (engineCommandManager.engineConnection?.idleMode) return + if (engineCommandManager.idleMode) return store.videoElement?.play().catch((e) => { console.warn('Video playing was prevented', e) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 2d7d639d1c..fae15e2fa5 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -7,6 +7,7 @@ import type { MachineManager } from '@src/components/MachineManagerProvider' import type { useModelingContext } from '@src/hooks/useModelingContext' import type { KclManager } from '@src/lang/KclSingleton' import type CodeManager from '@src/lang/codeManager' +import type { SceneInfra } from '@src/clientSideScene/sceneInfra' import type { EngineCommand, ResponseMap } from '@src/lang/std/artifactGraph' import type { CommandLog } from '@src/lang/std/commandLog' import { CommandLogType } from '@src/lang/std/commandLog' @@ -238,11 +239,23 @@ class EngineConnection extends EventTarget { pc?: RTCPeerConnection unreliableDataChannel?: RTCDataChannel mediaStream?: MediaStream - idleMode: boolean = false promise?: Promise sdpAnswer?: RTCSessionDescriptionInit triggeredStart = false + onWebSocketOpen = function (event: Event) {} + onWebSocketClose = function (event: Event) {} + onWebSocketError = function (event: Event) {} + onWebSocketMessage = function (event: MessageEvent) {} + onIceGatheringStateChange = function ( + this: RTCPeerConnection, + event: Event + ) {} + onIceConnectionStateChange = function ( + this: RTCPeerConnection, + event: Event + ) {} + onNegotiationNeeded = function (this: RTCPeerConnection, event: Event) {} onIceCandidate = function ( this: RTCPeerConnection, event: RTCPeerConnectionIceEvent @@ -252,6 +265,9 @@ class EngineConnection extends EventTarget { event: RTCPeerConnectionIceErrorEvent ) {} onConnectionStateChange = function (this: RTCPeerConnection, event: Event) {} + onSignalingStateChange = function (this: RTCDataChannel, event: Event) {} + + onTrack = function (this: RTCPeerConnection, event: RTCTrackEvent) {} onDataChannelOpen = function (this: RTCDataChannel, event: Event) {} onDataChannelClose = function (this: RTCDataChannel, event: Event) {} onDataChannelError = function (this: RTCDataChannel, event: Event) {} @@ -260,11 +276,7 @@ class EngineConnection extends EventTarget { this: RTCPeerConnection, event: RTCDataChannelEvent ) {} - onTrack = function (this: RTCPeerConnection, event: RTCTrackEvent) {} - onWebSocketOpen = function (event: Event) {} - onWebSocketClose = function (event: Event) {} - onWebSocketError = function (event: Event) {} - onWebSocketMessage = function (event: MessageEvent) {} + onNetworkStatusReady = () => {} private _state: EngineConnectionState = { @@ -383,7 +395,7 @@ class EngineConnection extends EventTarget { this.tearDown = () => {} this.websocket.addEventListener('open', this.onWebSocketOpen) - this.websocket?.addEventListener('message', ((event: MessageEvent) => { + this.onWebSocketMessage = (event: MessageEvent) => { const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) if (!('resp' in message)) return @@ -416,18 +428,18 @@ class EngineConnection extends EventTarget { return this.state.type === EngineConnectionStateType.ConnectionEstablished } - tearDown(opts?: { idleMode: boolean }) { - this.idleMode = opts?.idleMode ?? false - if (this.pingIntervalId) { - clearInterval(this.pingIntervalId) - } - if (this.timeoutToForceConnectId) { - clearTimeout(this.timeoutToForceConnectId) - } + tearDown() { + clearInterval(this.pingIntervalId) + clearTimeout(this.timeoutToForceConnectId) + + // As each network connection (websocket, webrtc, peer connection) is + // closed, they will handle removing their own event listeners. + // If they didn't then it'd be possible we stop listened to close events + // which is what we want to do in the first place :) this.disconnectAll() - if (this.idleMode) { + if (this.engineCommandManager.idleMode) { this.state = { type: EngineConnectionStateType.Disconnecting, value: { @@ -435,6 +447,7 @@ class EngineConnection extends EventTarget { }, } } + // Pass the state along if (this.state.type === EngineConnectionStateType.Disconnecting) return if (this.state.type === EngineConnectionStateType.Disconnected) return @@ -568,30 +581,41 @@ class EngineConnection extends EventTarget { }, 3000) } this.pc?.addEventListener?.('icecandidate', this.onIceCandidate) + + // Watch out human! The names of the next couple events are really similar! + this.onIceGatheringStateChange = (event) => { + console.log('icegatheringstatechange', event) + + that.initiateConnectionExclusive() + } this.pc?.addEventListener?.( 'icegatheringstatechange', - function (_event) { - console.log('icegatheringstatechange', this.iceGatheringState) - - if (this.iceGatheringState !== 'complete') return - that.initiateConnectionExclusive() - } + this.onIceGatheringStateChange ) + this.onIceConnectionStateChange = (event: Event) => { + console.log('iceconnectionstatechange', event) + } this.pc?.addEventListener?.( 'iceconnectionstatechange', - function (_event) { - console.log('iceconnectionstatechange', this.iceConnectionState) - console.log('iceconnectionstatechange', this.iceGatheringState) - } + this.onIceConnectionStateChange + ) + + this.onNegotiationNeeded = (event: Event) => { + console.log('negotiationneeded', event) + } + this.pc?.addEventListener?.( + 'negotiationneeded', + this.onNegotiationNeeded + ) + + this.onSignalingStateChange = (event) => { + console.log('signalingstatechange', event) + } + this.pc?.addEventListener?.( + 'signalingstatechange', + this.onSignalingStateChange ) - this.pc?.addEventListener?.('negotiationneeded', function (_event) { - console.log('negotiationneeded', this.iceConnectionState) - console.log('negotiationneeded', this.iceGatheringState) - }) - this.pc?.addEventListener?.('signalingstatechange', function (event) { - console.log('signalingstatechange', this.signalingState) - }) this.onIceCandidateError = (_event: Event) => { const event = _event as RTCPeerConnectionIceErrorEvent @@ -618,38 +642,12 @@ class EngineConnection extends EventTarget { detail: { conn: this, mediaStream: this.mediaStream! }, }) ) - - setTimeout(() => { - // Everything is now connected. - this.state = { - type: EngineConnectionStateType.ConnectionEstablished, - } - - this.engineCommandManager.inSequence = 1 - - this.dispatchEvent( - new CustomEvent(EngineConnectionEvents.Opened, { - detail: this, - }) - ) - markOnce('code/endInitialEngineConnect') - }, 2000) break + case 'connecting': break - case 'disconnected': - case 'failed': - this.pc?.removeEventListener('icecandidate', this.onIceCandidate) - this.pc?.removeEventListener( - 'icecandidateerror', - this.onIceCandidateError - ) - this.pc?.removeEventListener( - 'connectionstatechange', - this.onConnectionStateChange - ) - this.pc?.removeEventListener('track', this.onTrack) + case 'failed': this.state = { type: EngineConnectionStateType.Disconnecting, value: { @@ -662,6 +660,43 @@ class EngineConnection extends EventTarget { } this.disconnectAll() break + + // The remote end broke up with us! :( + case 'disconnected': + this.dispatchEvent( + new CustomEvent(EngineConnectionEvents.RestartRequest, {}) + ) + break + case 'closed': + this.pc?.removeEventListener('icecandidate', this.onIceCandidate) + this.pc?.removeEventListener( + 'icegatheringstatechange', + this.onIceGatheringStateChange + ) + this.pc?.removeEventListener( + 'iceconnectionstatechange', + this.onIceConnectionStateChange + ) + this.pc?.removeEventListener( + 'negotiationneeded', + this.onNegotiationNeeded + ) + this.pc?.removeEventListener( + 'signalingstatechange', + this.onSignalingStateChange + ) + this.pc?.removeEventListener( + 'icecandidateerror', + this.onIceCandidateError + ) + this.pc?.removeEventListener( + 'connectionstatechange', + this.onConnectionStateChange + ) + this.pc?.removeEventListener('track', this.onTrack) + this.pc?.removeEventListener('datachannel', this.onDataChannel) + break + default: break } @@ -735,9 +770,7 @@ class EngineConnection extends EventTarget { // The app is eager to use the MediaStream; as soon as onNewTrack is // called, the following sequence happens: // EngineConnection.onNewTrack -> StoreState.setMediaStream -> - // Stream.tsx reacts to mediaStream change, setting a video element. - // We wait until connectionstatechange changes to "connected" - // to pass it to the rest of the application. + // EngineStream.tsx reacts to mediaStream change, setting a video element. this.mediaStream = mediaStream } @@ -761,6 +794,25 @@ class EngineConnection extends EventTarget { type: ConnectingType.DataChannelEstablished, }, } + + // Start firing off engine commands at this point. + // They could be fired at an earlier time, onWebSocketOpen, + // but DataChannel can offer some benefits like speed, + // and it's nice to say everything's connected before interacting + // with the server. + + this.state = { + type: EngineConnectionStateType.ConnectionEstablished, + } + + this.engineCommandManager.inSequence = 1 + + this.dispatchEvent( + new CustomEvent(EngineConnectionEvents.Opened, { + detail: this, + }) + ) + markOnce('code/endInitialEngineConnect') } this.unreliableDataChannel?.addEventListener( 'open', @@ -784,7 +836,6 @@ class EngineConnection extends EventTarget { 'message', this.onDataChannelMessage ) - this.pc?.removeEventListener('datachannel', this.onDataChannel) this.disconnectAll() } @@ -898,16 +949,19 @@ class EngineConnection extends EventTarget { } this.websocket.addEventListener('close', this.onWebSocketClose) - this.onWebSocketError = (event) => { - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Error, + this.onWebSocketError = (event: Event) => { + if (event.target instanceof WebSocket) { + this.state = { + type: EngineConnectionStateType.Disconnecting, value: { - error: ConnectionError.WebSocketError, - context: event, + type: DisconnectingType.Error, + value: { + error: ConnectionError.WebSocketError, + context: + WEBSOCKET_READYSTATE_TEXT[event.target.readyState] ?? event, + }, }, - }, + } } this.disconnectAll() @@ -1180,7 +1234,7 @@ class EngineConnection extends EventTarget { ) } disconnectAll() { - if (this.websocket && this.websocket?.readyState < 3) { + if (this.websocket?.readyState < 3) { this.websocket?.close() } if (this.unreliableDataChannel?.readyState === 'open') { @@ -1208,20 +1262,27 @@ class EngineConnection extends EventTarget { !this.websocket || this.websocket?.readyState === 3 - if (closedPc && closedUDC && closedWS) { - if (!this.idleMode) { - // Do not notify the rest of the program that we have cut off anything. - this.state = { type: EngineConnectionStateType.Disconnected } - } else { - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { - type: DisconnectingType.Pause, - }, - } + if (!(closedPc && closedUDC && closedWS)) { + return + } + + // Clean up all the event listeners. + + if (!this.engineCommandManager.idleMode) { + // Do not notify the rest of the program that we have cut off anything. + this.state = { type: EngineConnectionStateType.Disconnected } + this.dispatchEvent( + new CustomEvent(EngineConnectionEvents.RestartRequest, {}) + ) + } else { + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Pause, + }, } - this.triggeredStart = false } + this.triggeredStart = false } } @@ -1431,29 +1492,51 @@ export class EngineCommandManager extends EventTarget { }) ) + this.engineConnection.addEventListener( + EngineConnectionEvents.RestartRequest, + this.onEngineConnectionRestartRequest as EventListener + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises this.onEngineConnectionOpened = async () => { - await this.rustContext?.clearSceneAndBustCache( - { settings: await jsAppSettings() }, - this.codeManager?.currentFilePath || undefined - ) + console.log('onEngineConnectionOpened') + + try { + console.log('clearing scene and busting cache') + await this.rustContext?.clearSceneAndBustCache( + { settings: await jsAppSettings() }, + this.codeManager?.currentFilePath || undefined + ) + } catch (e) { + // If this happens shit's actually gone south aka the websocket closed. + // Let's restart. + console.warn("shit's gone south") + console.warn(e) + this.engineConnection?.dispatchEvent( + new CustomEvent(EngineConnectionEvents.RestartRequest, {}) + ) + return + } // Set the stream's camera projection type // We don't send a command to the engine if in perspective mode because // for now it's the engine's default. if (settings.cameraProjection === 'orthographic') { - this.sendSceneCommand({ + console.log('Setting camera to orthographic') + await this.sendSceneCommand({ type: 'modeling_cmd_req', cmd_id: uuidv4(), cmd: { type: 'default_camera_set_orthographic', }, - }).catch(reportRejection) + }) } // Set the theme - this.setTheme(this.settings.theme).catch(reportRejection) + console.log('Setting theme', this.settings.theme) + await this.setTheme(this.settings.theme) // Set up a listener for the dark theme media query + console.log('Setup theme media query change') darkModeMatcher?.addEventListener( 'change', this.onDarkThemeMediaQueryChange @@ -1461,7 +1544,8 @@ export class EngineCommandManager extends EventTarget { // Set the edge lines visibility // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendSceneCommand({ + console.log('setting edge_lines_visible') + await this.sendSceneCommand({ type: 'modeling_cmd_req', cmd_id: uuidv4(), cmd: { @@ -1470,21 +1554,30 @@ export class EngineCommandManager extends EventTarget { }, }) + console.log('camControlsCameraChange') this._camControlsCameraChange() - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendSceneCommand({ - // CameraControls subscribes to default_camera_get_settings response events - // firing this at connection ensure the camera's are synced initially - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_get_settings', - }, - }) + // We should eventually only have 1 restoral call. + if (this.idleMode) { + await this.sceneInfra?.camControls.restoreRemoteCameraStateAndTriggerSync() + } else { + // NOTE: This code is old. It uses the old hack to restore camera. + console.log('call default_camera_get_settings') + // eslint-disable-next-line @typescript-eslint/no-floating-promises + await this.sendSceneCommand({ + // CameraControls subscribes to default_camera_get_settings response events + // firing this at connection ensure the camera's are synced initially + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_get_settings', + }, + }) + } setIsStreamReady(true) + console.log('Dispatching SceneReady') // Other parts of the application should use this to react on scene ready. this.dispatchEvent( new CustomEvent(EngineCommandManagerEvents.SceneReady, { @@ -1550,7 +1643,7 @@ export class EngineCommandManager extends EventTarget { }) as EventListener) this.onVideoTrackMute = () => { - console.error('video track mute: check webrtc internals -> inbound rtp') + console.warn('video track mute - potentially lost stream for a moment') } this.onEngineConnectionNewTrack = ({ @@ -1764,14 +1857,14 @@ export class EngineCommandManager extends EventTarget { this.onDarkThemeMediaQueryChange ) - this.engineConnection?.tearDown(opts) + this.engineConnection?.tearDown() // Our window.engineCommandManager.tearDown assignment causes this case to happen which is // only really for tests. // @ts-ignore } else if (this.engineCommandManager?.engineConnection) { // @ts-ignore - this.engineCommandManager?.engineConnection?.tearDown(opts) + this.engineCommandManager?.engineConnection?.tearDown() // @ts-ignore this.engineCommandManager.engineConnection = null } @@ -2082,25 +2175,25 @@ export class EngineCommandManager extends EventTarget { // Set the stream background color // This takes RGBA values from 0-1 // So we convert from the conventional 0-255 found in Figma - this.sendSceneCommand({ + await this.sendSceneCommand({ cmd_id: uuidv4(), type: 'modeling_cmd_req', cmd: { type: 'set_background_color', color: getThemeColorForEngine(theme), }, - }).catch(reportRejection) + }) // Sets the default line colors const opposingTheme = getOppositeTheme(theme) - this.sendSceneCommand({ + await this.sendSceneCommand({ cmd_id: uuidv4(), type: 'modeling_cmd_req', cmd: { type: 'set_default_system_properties', color: getThemeColorForEngine(opposingTheme), }, - }).catch(reportRejection) + }) } } diff --git a/src/machines/engineStreamMachine.ts b/src/machines/engineStreamMachine.ts index 264cad83f0..0056a47cb8 100644 --- a/src/machines/engineStreamMachine.ts +++ b/src/machines/engineStreamMachine.ts @@ -77,76 +77,6 @@ export const engineStreamMachine = setup({ input: {} as EngineStreamContext, }, actors: { - [EngineStreamTransition.Play]: fromPromise( - async ({ - input: { context, params, rootContext }, - }: { - input: { - context: EngineStreamContext - params: { zoomToFit: boolean } - rootContext: AppMachineContext - } - }) => { - const canvas = context.canvasRef.current - if (!canvas) return false - - const video = context.videoRef.current - if (!video) return false - - const mediaStream = context.mediaStream - if (!mediaStream) return false - - // If the video is already playing it means we're doing a reconfigure. - // We don't want to re-run the KCL or touch the video element at all. - if (!video.paused) { - return - } - - await rootContext.sceneInfra.camControls.restoreRemoteCameraStateAndTriggerSync() - - video.style.display = 'block' - canvas.style.display = 'none' - - video.srcObject = mediaStream - } - ), - [EngineStreamTransition.Pause]: fromPromise( - async ({ - input: { context, rootContext }, - }: { - input: { context: EngineStreamContext; rootContext: AppMachineContext } - }) => { - const video = context.videoRef.current - if (!video) return - - await video.pause() - - const canvas = context.canvasRef.current - if (!canvas) return - - await holdOntoVideoFrameInCanvas(video, canvas) - video.style.display = 'none' - - await rootContext.sceneInfra.camControls.saveRemoteCameraState() - - // Make sure we're on the next frame for no flickering between canvas - // and the video elements. - window.requestAnimationFrame( - () => - void (async () => { - // Destroy the media stream. We will re-establish it. We could - // leave everything at pausing, preventing video decoders from running - // but we can do even better by significantly reducing network - // cards also. - context.mediaStream?.getVideoTracks()[0].stop() - context.mediaStream = null - video.srcObject = null - - rootContext.engineCommandManager.tearDown({ idleMode: true }) - })() - ) - } - ), [EngineStreamTransition.StartOrReconfigureEngine]: fromPromise( async ({ input: { context, event, rootContext }, @@ -157,21 +87,17 @@ export const engineStreamMachine = setup({ rootContext: AppMachineContext } }) => { - if (!context.authToken) return - - const video = context.videoRef.current - if (!video) return - - const canvas = context.canvasRef.current - if (!canvas) return + if (!context.authToken) return Promise.reject() + if (!context.videoRef.current) return Promise.reject() + if (!context.canvasRef.current) return Promise.reject() const { width, height } = getDimensions( window.innerWidth, window.innerHeight ) - video.width = width - video.height = height + context.videoRef.current.width = width + context.videoRef.current.height = height const settingsNext = { // override the pool param (?pool=) to request a specific engine instance @@ -206,51 +132,183 @@ export const engineStreamMachine = setup({ }) } ), + [EngineStreamTransition.Play]: fromPromise( + async ({ + input: { context, params }, + }: { + input: { context: EngineStreamContext; params: { zoomToFit: boolean } } + }) => { + if (!context.canvasRef.current) return + if (!context.videoRef.current) return + if (!context.mediaStream) return + + // If the video is already playing it means we're doing a reconfigure. + // We don't want to re-run the KCL or touch the video element at all. + if (!context.videoRef.current.paused) { + return + } + + // In the past we'd try to play immediately, but the proper thing is to way + // for the 'canplay' event to tell us data is ready. + const onCanPlay = () => { + if (!context.videoRef.current) { + return + } + + context.videoRef.current.play().catch(console.error) + + // Yes, event listeners can remove themselves because of the + // lazy nature of interpreted languages :D + context.videoRef.current.removeEventListener('canplay', onCanPlay) + } + + // We're receiving video frames, so show the video now. + const onPlay = () => { + // We have to give engine time to crunch all the scene setup we + // ask it to do. As far as I can tell it doesn't block until + // they are done, so we must wait. + setTimeout(() => { + if (!context.videoRef.current) { + return + } + if (!context.canvasRef.current) { + return + } + + context.videoRef.current.style.display = 'block' + context.canvasRef.current.style.display = 'none' + + context.videoRef.current.removeEventListener('play', onPlay) + // I've tried < 400ms and sometimes it's possible to see a flash + // and the camera snap. + }, 400) + } + + context.videoRef.current.addEventListener('canplay', onCanPlay) + context.videoRef.current.addEventListener('play', onPlay) + + // THIS ASSIGNMENT IS *EXTREMELY* EFFECTFUL! The amount of logic + // this triggers is quite far and wide. It drives the above events. + context.videoRef.current.srcObject = context.mediaStream + } + ), + + // Pause is also called when leaving the modeling scene. It's possible + // then videoRef and canvasRef are now null due to their DOM elements + // being destroyed. + [EngineStreamTransition.Pause]: fromPromise( + async ({ + input: { context, rootContext }, + }: { + input: { + context: EngineStreamContext + rootContext: AppMachineContext + } + }) => { + if (context.videoRef.current && context.canvasRef.current) { + await context.videoRef.current.pause() + + await holdOntoVideoFrameInCanvas( + context.videoRef.current, + context.canvasRef.current + ) + context.videoRef.current.style.display = 'none' + } + + await rootContext.sceneInfra.camControls.saveRemoteCameraState() + + // Make sure we're on the next frame for no flickering between canvas + // and the video elements. + window.requestAnimationFrame( + () => + void (async () => { + // Destroy the media stream. We will re-establish it. We could + // leave everything at pausing, preventing video decoders from running + // but we can do even better by significantly reducing network + // cards also. + context.mediaStream?.getVideoTracks()[0].stop() + context.mediaStream = null + + if (context.videoRef.current) { + context.videoRef.current.srcObject = null + } + + engineCommandManager.tearDown({ idleMode: true }) + })() + ) + } + ), }, }).createMachine({ - initial: EngineStreamState.Off, + initial: EngineStreamState.WaitingForDependencies, context: (initial) => initial.input, states: { - [EngineStreamState.Off]: { - reenter: true, + [EngineStreamState.WaitingForDependencies]: { on: { [EngineStreamTransition.SetPool]: { - target: EngineStreamState.Off, - actions: [assign({ pool: ({ context, event }) => event.data.pool })], + target: EngineStreamState.WaitingForDependencies, + actions: [assign({ pool: ({ context, event }) => event.pool })], }, [EngineStreamTransition.SetAuthToken]: { - target: EngineStreamState.Off, + target: EngineStreamState.WaitingForDependencies, + actions: [ + assign({ authToken: ({ context, event }) => event.authToken }), + ], + }, + [EngineStreamTransition.SetVideoRef]: { + target: EngineStreamState.WaitingForDependencies, + actions: [ + assign({ videoRef: ({ context, event }) => event.videoRef }), + ], + }, + [EngineStreamTransition.SetCanvasRef]: { + target: EngineStreamState.WaitingForDependencies, actions: [ - assign({ authToken: ({ context, event }) => event.data.authToken }), + assign({ canvasRef: ({ context, event }) => event.canvasRef }), ], }, [EngineStreamTransition.StartOrReconfigureEngine]: { - target: EngineStreamState.On, + target: EngineStreamState.WaitingForMediaStream, }, }, }, - [EngineStreamState.On]: { - reenter: true, + [EngineStreamState.WaitingForMediaStream]: { invoke: { src: EngineStreamTransition.StartOrReconfigureEngine, input: (args) => ({ context: args.context, rootContext: args.self.system.get('root').getSnapshot().context, - params: { zoomToFit: args.context.zoomToFit }, event: args.event, }), + onError: [ + { + target: EngineStreamState.WaitingForDependencies, + reenter: true, + }, + ], }, on: { - // Transition requested by engineConnection + [EngineStreamTransition.StartOrReconfigureEngine]: { + target: EngineStreamState.WaitingForMediaStream, + reenter: true, + }, [EngineStreamTransition.SetMediaStream]: { - target: EngineStreamState.On, + target: EngineStreamState.WaitingToPlay, actions: [ assign({ mediaStream: ({ context, event }) => event.mediaStream }), ], }, + }, + }, + [EngineStreamState.WaitingToPlay]: { + on: { [EngineStreamTransition.Play]: { target: EngineStreamState.Playing, - actions: [assign({ zoomToFit: () => true })], + }, + // We actually failed inbetween needing to play and sending commands. + [EngineStreamTransition.StartOrReconfigureEngine]: { + target: EngineStreamState.WaitingForMediaStream, + renter: true, }, }, }, @@ -283,9 +341,7 @@ export const engineStreamMachine = setup({ rootContext: args.self.system.get('root').getSnapshot().context, event: args.event, }), - onDone: { - target: EngineStreamState.Playing, - }, + onDone: [{ target: EngineStreamState.Playing }], }, }, [EngineStreamState.Paused]: { @@ -302,8 +358,27 @@ export const engineStreamMachine = setup({ }, }, }, + [EngineStreamState.Stopped]: { + invoke: { + src: EngineStreamTransition.Pause, + input: (args) => ({ + context: args.context, + rootContext: args.self.system.get('root').getSnapshot().context, + }), + onDone: [ + { + target: EngineStreamState.WaitingForDependencies, + actions: [ + assign({ + videoRef: { current: null }, + canvasRef: { current: null }, + }), + ], + }, + ], + }, + }, [EngineStreamState.Resuming]: { - reenter: true, invoke: { src: EngineStreamTransition.StartOrReconfigureEngine, input: (args) => ({ @@ -318,14 +393,11 @@ export const engineStreamMachine = setup({ target: EngineStreamState.Paused, }, [EngineStreamTransition.SetMediaStream]: { + target: EngineStreamState.Playing, actions: [ assign({ mediaStream: ({ context, event }) => event.mediaStream }), ], }, - [EngineStreamTransition.Play]: { - target: EngineStreamState.Playing, - actions: [assign({ zoomToFit: () => false })], - }, }, }, }, From 3b1ece3d0148dc282d0262ec77526d2564c8289e Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Mon, 28 Apr 2025 16:43:22 -0400 Subject: [PATCH 03/19] Save camera state after every user interaction in case of disconnection and restoral --- src/clientSideScene/CameraControls.ts | 1 - src/components/EngineStream.tsx | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/clientSideScene/CameraControls.ts b/src/clientSideScene/CameraControls.ts index e62cb84a48..e42c09b40a 100644 --- a/src/clientSideScene/CameraControls.ts +++ b/src/clientSideScene/CameraControls.ts @@ -975,7 +975,6 @@ export class CameraControls { }, }) } - await this.engineCommandManager.sendSceneCommand({ type: 'modeling_cmd_req', cmd_id: uuidv4(), diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 8440036eca..ca9e04c026 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -368,6 +368,30 @@ export const EngineStream = (props: { } }, [streamIdleMode, engineStreamState.value]) + // On various inputs save the camera state, in case we get disconnected. + useEffect(() => { + const onInput = () => { + // Save the remote camera state to restore on stream restore. + // Fire-and-forget because we don't know when a camera movement is + // completed on the engine side (there are no responses to data channel + // mouse movements.) + sceneInfra.camControls.saveRemoteCameraState().catch(trap) + } + + // These usually signal a user is done some sort of operation. + window.document.addEventListener('keyup', onInput) + window.document.addEventListener('mouseup', onInput) + window.document.addEventListener('scroll', onInput) + window.document.addEventListener('touchstop', onInput) + + return () => { + window.document.removeEventListener('keyup', onInput) + window.document.removeEventListener('mouseup', onInput) + window.document.removeEventListener('scroll', onInput) + window.document.removeEventListener('touchstop', onInput) + } + }, []) + const isNetworkOkay = overallState === NetworkHealthState.Ok || overallState === NetworkHealthState.Weak From fac080479e03cb86f7f057cee0070d573f459e72 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Mon, 28 Apr 2025 16:43:50 -0400 Subject: [PATCH 04/19] Translate basic WebSocket error numbers into useful text --- src/components/Loading.tsx | 4 +++- src/lang/std/engineConnection.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index 57a1bf19f8..de10e61787 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -138,7 +138,9 @@ const Loading = ({ children, className, dataTestId }: LoadingProps) => { CONNECTION_ERROR_TEXT[error.error] + (error.context ? '\n\nThe error details are: ' + - JSON.stringify(error.context) + (error.context instanceof Object + ? JSON.stringify(error.context) + : error.context) : ''), { renderer: new SafeRenderer(markedOptions), diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index fae15e2fa5..1774080a98 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -110,6 +110,13 @@ export const CONNECTION_ERROR_TEXT: Record = { 'An unexpected error occurred. Please report this to us.', } +export const WEBSOCKET_READYSTATE_TEXT: Record = { + [WebSocket.CONNECTING]: 'WebSocket.CONNECTING', + [WebSocket.OPEN]: 'WebSocket.OPEN', + [WebSocket.CLOSING]: 'WebSocket.CLOSING', + [WebSocket.CLOSED]: 'WebSocket.CLOSED', +} + export interface ErrorType { // The error we've encountered. error: ConnectionError From 6c07ae89b85ec2dc919ee35a9859b1a5beeafecd Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Mon, 28 Apr 2025 16:44:18 -0400 Subject: [PATCH 05/19] Add a RestartRequest event to the engineCommandManager --- src/lang/std/engineConnection.ts | 27 +++++++++++++++++++++++++++ src/lib/singletons.ts | 1 + src/machines/engineStreamMachine.ts | 25 +++++++++++++++++++------ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 1774080a98..4465ecb687 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -216,6 +216,9 @@ export enum EngineConnectionEvents { // We can eventually use it for more, but one step at a time. ConnectionStateChanged = 'connection-state-changed', // (state: EngineConnectionState) => void + // There are various failure scenarios where we want to try a restart. + RestartRequest = 'restart-request', + // These are used for the EngineCommandManager and were created // before onConnectionStateChange existed. ConnectionStarted = 'connection-started', // (engineConnection: EngineConnection) => void @@ -1317,6 +1320,9 @@ export enum EngineCommandManagerEvents { // engineConnection is available but scene setup may not have run EngineAvailable = 'engine-available', + // request a restart of engineConnection + EngineRestartRequest = 'engine-restart-request', + // the whole scene is ready (settings loaded) SceneReady = 'scene-ready', } @@ -1429,10 +1435,25 @@ export class EngineCommandManager extends EventTarget { kclManager: null | KclManager = null codeManager?: CodeManager rustContext?: RustContext + sceneInfra?: SceneInfra // The current "manufacturing machine" aka 3D printer, CNC, etc. public machineManager: MachineManager | null = null + // Dispatch to the application the engine needs a restart. + private onEngineConnectionRestartRequest = () => { + this.dispatchEvent( + new CustomEvent(EngineCommandManagerEvents.EngineRestartRequest, {}) + ) + } + + private onOffline = () => { + console.log('Browser reported network is offline') + this.onEngineConnectionRestartRequest() + } + + idleMode: boolean = false + start({ setMediaStream, setIsStreamReady, @@ -1478,6 +1499,8 @@ export class EngineCommandManager extends EventTarget { return } + window.addEventListener('offline', this.onOffline) + let additionalSettings = this.settings.enableSSAO ? '&post_effect=ssao' : '' additionalSettings += '&show_grid=' + (this.settings.showScaleGrid ? 'true' : 'false') @@ -1827,6 +1850,10 @@ export class EngineCommandManager extends EventTarget { } tearDown(opts?: { idleMode: boolean }) { + this.idleMode = opts?.idleMode ?? false + + window.removeEventListener('offline', this.onOffline) + if (this.engineConnection) { for (const [cmdId, pending] of Object.entries(this.pendingCommands)) { pending.reject([ diff --git a/src/lib/singletons.ts b/src/lib/singletons.ts index c81c331a0f..a926b82c10 100644 --- a/src/lib/singletons.ts +++ b/src/lib/singletons.ts @@ -68,6 +68,7 @@ editorManager.kclManager = kclManager // TODO: proper dependency injection. engineCommandManager.kclManager = kclManager engineCommandManager.codeManager = codeManager +engineCommandManager.sceneInfra = sceneInfra engineCommandManager.rustContext = rustContext kclManager.sceneInfraBaseUnitMultiplierSetter = (unit: BaseUnit) => { diff --git a/src/machines/engineStreamMachine.ts b/src/machines/engineStreamMachine.ts index 0056a47cb8..064fa0d9b4 100644 --- a/src/machines/engineStreamMachine.ts +++ b/src/machines/engineStreamMachine.ts @@ -1,44 +1,57 @@ +import { engineCommandManager } from '@src/lib/singletons' import type { MutableRefObject } from 'react' import type { ActorRefFrom } from 'xstate' import { assign, fromPromise, setup } from 'xstate' import type { AppMachineContext } from '@src/lib/types' export enum EngineStreamState { - Off = 'off', - On = 'on', - WaitForMediaStream = 'wait-for-media-stream', + WaitingForDependencies = 'waiting-for-dependencies', + WaitingForMediaStream = 'waiting-for-media-stream', + WaitingToPlay = 'waiting-to-play', Playing = 'playing', Reconfiguring = 'reconfiguring', Paused = 'paused', + Stopped = 'stopped', // The is the state in-between Paused and Playing *specifically that order*. Resuming = 'resuming', } export enum EngineStreamTransition { - SetMediaStream = 'set-media-stream', + // This brings us back to the configuration loop + WaitForDependencies = 'wait-for-dependencies', + + // Our dependencies to set SetPool = 'set-pool', SetAuthToken = 'set-auth-token', + SetVideoRef = 'set-video-ref', + SetCanvasRef = 'set-canvas-ref', + SetMediaStream = 'set-media-stream', + + // Stream operations Play = 'play', Resume = 'resume', Pause = 'pause', + Stop = 'stop', + + // Used to reconfigure the stream during connection StartOrReconfigureEngine = 'start-or-reconfigure-engine', } export interface EngineStreamContext { pool: string | null authToken: string | undefined - mediaStream: MediaStream | null videoRef: MutableRefObject canvasRef: MutableRefObject + mediaStream: MediaStream | null zoomToFit: boolean } export const engineStreamContextCreate = (): EngineStreamContext => ({ pool: null, authToken: undefined, - mediaStream: null, videoRef: { current: null }, canvasRef: { current: null }, + mediaStream: null, zoomToFit: true, }) From cc0aef6ef4b1d1e3b835161a1293056bbe0cc874 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Mon, 28 Apr 2025 17:40:10 -0400 Subject: [PATCH 06/19] Adjust for a rebase --- src/components/EngineStream.tsx | 1 - src/lang/std/engineConnection.ts | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index ca9e04c026..8e6f7d3af4 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -41,7 +41,6 @@ export const EngineStream = (props: { const settings = useSettings() const { state: modelingMachineState, send: modelingMachineActorSend } = useModelingContext() - const commandBarState = useCommandBarState() const { file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const last = useRef(Date.now()) diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 4465ecb687..30026030b5 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -331,10 +331,10 @@ class EngineConnection extends EventTarget { private engineCommandManager: EngineCommandManager private pingPongSpan: { ping?: number; pong?: number } - private pingIntervalId: ReturnType | null = null + private pingIntervalId: ReturnType | undefined = undefined isUsingConnectionLite: boolean = false - timeoutToForceConnectId: ReturnType | null = null + timeoutToForceConnectId: ReturnType | undefined = undefined constructor({ engineCommandManager, @@ -405,7 +405,7 @@ class EngineConnection extends EventTarget { this.tearDown = () => {} this.websocket.addEventListener('open', this.onWebSocketOpen) - this.onWebSocketMessage = (event: MessageEvent) => { + this.websocket?.addEventListener('message', ((event: MessageEvent) => { const message: Models['WebSocketResponse_type'] = JSON.parse(event.data) if (!('resp' in message)) return @@ -1244,7 +1244,7 @@ class EngineConnection extends EventTarget { ) } disconnectAll() { - if (this.websocket?.readyState < 3) { + if (this.websocket && this.websocket?.readyState < 3) { this.websocket?.close() } if (this.unreliableDataChannel?.readyState === 'open') { From 60f59b107feb738d9ecee69efc4ba5154b6d7156 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Tue, 29 Apr 2025 15:22:25 -0400 Subject: [PATCH 07/19] Add E2E test for stream pause behavior --- ...test-network-and-connection-issues.spec.ts | 106 +++++++++++++++++- e2e/playwright/test-utils.ts | 1 + 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index fab50e8d0a..faa9de10c0 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -1,17 +1,22 @@ import type { EngineCommand } from '@src/lang/std/artifactGraph' import { uuidv4 } from '@src/lib/utils' -import { commonPoints, getUtils } from '@e2e/playwright/test-utils' +import { + commonPoints, + getUtils, + TEST_COLORS, + circleMove, +} from '@e2e/playwright/test-utils' import { expect, test } from '@e2e/playwright/zoo-test' test.describe( - 'Test network and connection issues', + 'Test network related behaviors', { tag: ['@macos', '@windows'], }, () => { test( - 'simulate network down and network little widget', + 'simulate network down and network little w1dget', { tag: '@skipLocalEngine' }, async ({ page, homePage }) => { const u = await getUtils(page) @@ -255,5 +260,100 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) ).not.toBeVisible() } ) + + test( + 'Paused stream freezes view frame, unpause reconnect is seamless to user', + { tag: '@skipLocalEngine' }, + async ({ page, homePage, scene, cmdBar, toolbar }) => { + const u = await getUtils(page) + const networkToggle = page.getByTestId('network-toggle') + const userSettingsTab = page.getByRole('radio', { name: 'User' }) + const appStreamIdleModeSetting = page.getByTestId('app-streamIdleMode') + const settingsCloseButton = page.getByTestId('settings-close-button') + + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `sketch001 = startSketchOn(XY) +profile001 = startProfile(sketch001, at = [44.41, 59.65]) + |> line(end = [205.52, 251.67]) + |> line(end = [184.62, -219.45]) + |> line(endAbsolute = [profileStartX(%), profileStartY(%)]) + |> close()` + ) + }) + + const dim = { width: 1200, height: 800 } + await page.setBodyDimensions(dim) + + await test.step('Go to modeling scene', async () => { + await homePage.goToModelingScene() + await scene.settled(cmdBar) + }) + + // GRAB THE COORDINATES OF A POINT ON A LINE. + // Use the right side of the square (not covered by panes) + // Trigger highlight by hovering over the space + await circleMove(page, dim.width / 2, dim.height / 2, 20, 10) + + // Double click to edit sketch. + await page.mouse.dblclick(dim.width / 2, dim.height / 2, { delay: 100 }) + await toolbar.waitUntilSketchingReady() + + // We need to be in sketch mode to access this data. + const line = await u.getBoundingBox('[data-overlay-index="0"]') + const angle = await u.getAngle('[data-overlay-index="0"]') + const midPoint = { + x: line.x + (Math.sin((angle / 360) * Math.PI * 2) * line.width) / 2, + // Different coordinate space, need to -1 to fix it up + y: + (line.y + + (Math.cos((angle / 360) * Math.PI * 2) * line.height) / 2) * + -1, + } + await page.getByRole('button', { name: 'Exit Sketch' }).click() + + await test.step('Set stream idle pause time to 5s', async () => { + await page.getByRole('link', { name: 'Settings' }).last().click() + await expect( + page.getByRole('heading', { name: 'Settings', exact: true }) + ).toBeVisible() + await userSettingsTab.click() + await appStreamIdleModeSetting.click() + await appStreamIdleModeSetting.selectOption('5000') + await settingsCloseButton.click() + await expect( + page.getByText('Set stream idle mode to "5000" as a user default') + ).toBeVisible() + }) + + await test.step('Verify pausing behavior', async () => { + // Wait 5s + 1s to pause. + await page.waitForTimeout(6000) + + // We should now be paused. To the user, it should appear we're still + // connected. + await expect(networkToggle).toContainText('Connected') + + // ... and the model's still visibly there + console.log(midPoint) + await scene.expectPixelColor(TEST_COLORS.OFFWHITE, midPoint, 15) + + // Now move the mouse around to unpause! + await circleMove(page, dim.width / 2, dim.height / 2, 20, 10) + + // ONCE AGAIN! Check the view area hasn't changed at all. + // Check the pixel a couple times as it reconnects. + // NOTE: Remember, idle behavior is still on at this point - + // if this test takes longer than 5s shit WILL go south! + await scene.expectPixelColor(TEST_COLORS.OFFWHITE, midPoint, 15) + await page.waitForTimeout(1000) + await scene.expectPixelColor(TEST_COLORS.OFFWHITE, midPoint, 15) + + // Ensure we're still connected + await expect(networkToggle).toContainText('Connected') + }) + } + ) } ) diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index 55dcf9eff4..449d56c676 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -42,6 +42,7 @@ export const networkingMasks = (page: Page) => [ export type TestColor = [number, number, number] export const TEST_COLORS: { [key: string]: TestColor } = { WHITE: [249, 249, 249], + OFFWHITE: [237, 237, 237], YELLOW: [255, 255, 0], BLUE: [0, 0, 255], DARK_MODE_BKGD: [27, 27, 27], From d3545a8a0f65e3c5323191c536dfb378f99c38be Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 30 Apr 2025 10:20:00 -0400 Subject: [PATCH 08/19] Fix snapshot test --- e2e/playwright/snapshot-tests.spec.ts | 6 +- .../theme-persists-1.png | Bin 0 -> 47769 bytes ...test-network-and-connection-issues.spec.ts | 61 ++++++++---------- e2e/playwright/test-utils.ts | 1 + src/components/EngineStream.tsx | 5 ++ src/lang/std/engineConnection.ts | 5 ++ 6 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 e2e/playwright/snapshot-tests.spec.ts-snapshots/theme-persists-1.png diff --git a/e2e/playwright/snapshot-tests.spec.ts b/e2e/playwright/snapshot-tests.spec.ts index 38e3bf536e..023668fb9a 100644 --- a/e2e/playwright/snapshot-tests.spec.ts +++ b/e2e/playwright/snapshot-tests.spec.ts @@ -1044,7 +1044,7 @@ test.describe('Grid visibility', { tag: '@snapshot' }, () => { }) }) -test('theme persists', async ({ page, context }) => { +test('theme persists', async ({ page, context, homePage }) => { const u = await getUtils(page) await context.addInitScript(async () => { localStorage.setItem( @@ -1062,7 +1062,7 @@ test('theme persists', async ({ page, context }) => { await page.setViewportSize({ width: 1200, height: 500 }) - await u.waitForAuthSkipAppStart() + await homePage.goToModelingScene() await page.waitForTimeout(500) // await page.getByRole('link', { name: 'Settings Settings (tooltip)' }).click() @@ -1090,7 +1090,7 @@ test('theme persists', async ({ page, context }) => { // Disconnect and reconnect to check the theme persists through a reload // Expect the network to be down - await expect(networkToggle).toContainText('Offline') + await expect(networkToggle).toContainText('Problem') // simulate network up await u.emulateNetworkConditions({ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/theme-persists-1.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/theme-persists-1.png new file mode 100644 index 0000000000000000000000000000000000000000..1907ac24e7c975bcb8a5fb3a142d38edbff4f3d1 GIT binary patch literal 47769 zcmce;RaBO3*9D4-N-Chz4T5xcNq0yotw?txZGe=3(%s!D($d}CQqtWed+~k$f3U~c z2m9#1#`_L=faiYhSTWa}^TJ3Oc?XO0Kb6r)Q_nX`X6!4os-QA zs!s|_#*7!M_36l+xX6`JJ|~xx&XV%D?4Gud{>Y$wFt~CpaJj`cQ+3+Ix6tFU%d;vF zota6M8&c>dQ8(>nU>a3E#fOLxyU-a%{ouiaZKC^dA_INO%F6Kx3DbNi2)r*e-5w&m zd?+4j;N}ZIL%4eJ#~AbD$z5c!+gGq0l)hu!UIna|5AXkfe_Pyo_rJe^fROxtaA=H^ zlaqyog@YCT7J2^4#NS`2VY`hBmb_h5}fh>;cL0~-_L*9ez3RDOd%PZ?$yqSWNEp5 z9~Jx8uV3z!l8ArW%N?hk?umMEJTv6rsL*tlmXh+bDx3Uveu%^Un6P~R)X|a%tLMKb zu=b|jp3@IQMp047Zn@*sT@1mK-O0&Av|0DGqLPJcQ=N+-UtC_kphjcd^Teq!Wnql- zrzJivrFTK=>S11PZft!lZB+Nl{s(#M+Un}+XM=YT>Sd&*O?NT=UH%a-TzTjv?76o$ zb(i0;%en8t^uF+&GvSG;NjC;9iS{1>0W!lO%4%xw{!A1#6q*djV|@Mg6FW#EZFn8` zYlSoE|J)&x;DUl{WhN$-ClvSvPS)`+Nk1GcceaO+V2Zf9x_)_po?G$7+uJ+Gf3Pbe z&uM37pO!zLq3MfvafJeH|91YD0y zc3*lr@KSlW@m{57XZGuNmpjuZOaF>LSgd_)eOJ*-4#~`b>-qBxh@k1|X*wDjkM^~e zRFjKqi(o93w|1;zucM zMj?#Bjvv(cc3|DXOf~Bz>G$v7Ns&G`FEGc*2_`9GAO@J!$Z!s|k9o)3F})&|pzd!kwF|Mr*;XI40i5g`d<;4ta7*t%bx?F$_^ zl6-pkJv`id>DQ-EpWZC?^!C8iN#9@jI z4Tak{HC(7uLry_aP*Bj@+v|3^!@$HeKZkjJw79gCvum%Xr>CMaeDY|OF4Q0OF3!^C z?hrBcPiJ~0vh;Do-`S>oawFXlhTQb7hHk$1V!i2pqjozwqC3xXS5R>j{v(f(k&$`$ zm{?Uw2?Gf+B0^}AkB*Ft#h?Sj+|GQlu^CvX2DsLp6;;pw;+z|A7g#i0bUQ;l^iL?AMQS*vYe~$uCqB?3NM~2 z`;&yUzh}m^;3R0beRZH-NrWBvoQzC3)L_Z}{{6dmlKPEH-oT?EMPiJSO&feej~x+w zaDMgEz4}jN?d@$vAS zUtX%{-CsO7+kda9sQCDCh~ws1L_|bI{*SJ%t_t(<*tj?*-P#X*@jQS3{)M+#uQ26> zgoK2JsaRO#=jClqS6W|RU1cgJ?QCz)#&UWoy9;`qY)z`EsS)uLh#{%HeocUnuaD~l z-@9|~0U9B4k2c+~EvI|(0dblk&UlKKS%@VcKt_3$myuEHWF{zr6oqQUFt-}Pnd z#9J$?op>JmYB(!68I3Zdm6a9$l6`e@9l1_3L6)KXx_jsOS2FQo4k zG^V2R(WyEQzSvx6u1N8qQcTQGPX4`m7^nMnsa-gfGO0f*Dk@3PomHub zi2y>qq@;wAkumk(OKIuOPxl_SwX}>(hkyO*`dVe!^U<}|HJaHM?>#m%TGC^$P)aFY3~uV5ILpY$NMb&x{2xTI6AE;g zh_)+T�-hx}%tOrz*O~Z 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 literal 0 HcmV?d00001 diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index faa9de10c0..bc52ec290d 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -267,6 +267,8 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) async ({ page, homePage, scene, cmdBar, toolbar }) => { const u = await getUtils(page) const networkToggle = page.getByTestId('network-toggle') + const networkToggleConnectedText = page.getByText('Connected') + const networkToggleWeakText = page.getByText('Network health (Weak)') const userSettingsTab = page.getByRole('radio', { name: 'User' }) const appStreamIdleModeSetting = page.getByTestId('app-streamIdleMode') const settingsCloseButton = page.getByTestId('settings-close-button') @@ -275,15 +277,14 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) localStorage.setItem( 'persistCode', `sketch001 = startSketchOn(XY) -profile001 = startProfile(sketch001, at = [44.41, 59.65]) - |> line(end = [205.52, 251.67]) - |> line(end = [184.62, -219.45]) - |> line(endAbsolute = [profileStartX(%), profileStartY(%)]) +profile001 = startProfile(sketch001, at = [0.0, 0.0]) + |> line(end = [10.0, 0]) + |> line(end = [0, 10.0]) |> close()` ) }) - const dim = { width: 1200, height: 800 } + const dim = { width: 1200, height: 500 } await page.setBodyDimensions(dim) await test.step('Go to modeling scene', async () => { @@ -291,28 +292,6 @@ profile001 = startProfile(sketch001, at = [44.41, 59.65]) await scene.settled(cmdBar) }) - // GRAB THE COORDINATES OF A POINT ON A LINE. - // Use the right side of the square (not covered by panes) - // Trigger highlight by hovering over the space - await circleMove(page, dim.width / 2, dim.height / 2, 20, 10) - - // Double click to edit sketch. - await page.mouse.dblclick(dim.width / 2, dim.height / 2, { delay: 100 }) - await toolbar.waitUntilSketchingReady() - - // We need to be in sketch mode to access this data. - const line = await u.getBoundingBox('[data-overlay-index="0"]') - const angle = await u.getAngle('[data-overlay-index="0"]') - const midPoint = { - x: line.x + (Math.sin((angle / 360) * Math.PI * 2) * line.width) / 2, - // Different coordinate space, need to -1 to fix it up - y: - (line.y + - (Math.cos((angle / 360) * Math.PI * 2) * line.height) / 2) * - -1, - } - await page.getByRole('button', { name: 'Exit Sketch' }).click() - await test.step('Set stream idle pause time to 5s', async () => { await page.getByRole('link', { name: 'Settings' }).last().click() await expect( @@ -333,25 +312,39 @@ profile001 = startProfile(sketch001, at = [44.41, 59.65]) // We should now be paused. To the user, it should appear we're still // connected. - await expect(networkToggle).toContainText('Connected') + await networkToggle.hover() + await expect(networkToggleConnectedText.or(networkToggleWeakText)).toBeVisible() + + const center = { + x: dim.width / 2, + y: dim.height / 2, + } + + let probe = { x: 0, y: 0 } // ... and the model's still visibly there - console.log(midPoint) - await scene.expectPixelColor(TEST_COLORS.OFFWHITE, midPoint, 15) + probe.x = center.x + (dim.width / 100) + probe.y = center.y + await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) + probe = { ...center } // Now move the mouse around to unpause! - await circleMove(page, dim.width / 2, dim.height / 2, 20, 10) + await circleMove(page, probe.x, probe.y, 20, 10) // ONCE AGAIN! Check the view area hasn't changed at all. // Check the pixel a couple times as it reconnects. // NOTE: Remember, idle behavior is still on at this point - // if this test takes longer than 5s shit WILL go south! - await scene.expectPixelColor(TEST_COLORS.OFFWHITE, midPoint, 15) + probe.x = center.x + (dim.width / 100) + probe.y = center.y + await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) await page.waitForTimeout(1000) - await scene.expectPixelColor(TEST_COLORS.OFFWHITE, midPoint, 15) + await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) + probe = { ...center } // Ensure we're still connected - await expect(networkToggle).toContainText('Connected') + await networkToggle.hover() + await expect(networkToggleConnectedText.or(networkToggleWeakText)).toBeVisible() }) } ) diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index 449d56c676..1d314f662d 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -43,6 +43,7 @@ export type TestColor = [number, number, number] export const TEST_COLORS: { [key: string]: TestColor } = { WHITE: [249, 249, 249], OFFWHITE: [237, 237, 237], + GREY: [142, 142, 142], YELLOW: [255, 255, 0], BLUE: [0, 0, 255], DARK_MODE_BKGD: [27, 27, 27], diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 8e6f7d3af4..6b5f79fde5 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -1,3 +1,4 @@ +import { TEST } from '@src/env' import { useAppState } from '@src/AppState' import { ClientSideScene } from '@src/clientSideScene/ClientSideSceneComp' import { ViewControlContextMenu } from '@src/components/ViewControlMenu' @@ -208,6 +209,10 @@ export const EngineStream = (props: { // Poll that we're connected. If not, send a reset signal. // Do not restart if we're in idle mode. const connectionCheckIntervalId = setInterval(() => { + // SKIP DURING TESTS BECAUSE IT WILL MESS WITH REUSING THE + // ELECTRON INSTANCE. + if (TEST) { return } + // Don't try try to restart if we're already connected! const hasEngineConnectionInst = engineCommandManager.engineConnection const isDisconnected = diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index 30026030b5..26aef42a1f 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -1,3 +1,4 @@ +import { TEST } from '@src/env' import type { Models } from '@kittycad/lib' import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from '@src/env' import { jsAppSettings } from '@src/lib/settings/settingsUtils' @@ -1449,6 +1450,10 @@ export class EngineCommandManager extends EventTarget { private onOffline = () => { console.log('Browser reported network is offline') + if (TEST) { + console.warn('DURING TESTS ENGINECONNECTION.ONOFFLINE WILL DO NOTHING.') + return + } this.onEngineConnectionRestartRequest() } From f54582f81b497aa93f2fbae35c268a4032c3a861 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 30 Apr 2025 10:21:01 -0400 Subject: [PATCH 09/19] fmt, lint --- .../test-network-and-connection-issues.spec.ts | 13 ++++++++----- src/components/EngineStream.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index bc52ec290d..96775f9f44 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -265,7 +265,6 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) 'Paused stream freezes view frame, unpause reconnect is seamless to user', { tag: '@skipLocalEngine' }, async ({ page, homePage, scene, cmdBar, toolbar }) => { - const u = await getUtils(page) const networkToggle = page.getByTestId('network-toggle') const networkToggleConnectedText = page.getByText('Connected') const networkToggleWeakText = page.getByText('Network health (Weak)') @@ -313,7 +312,9 @@ profile001 = startProfile(sketch001, at = [0.0, 0.0]) // We should now be paused. To the user, it should appear we're still // connected. await networkToggle.hover() - await expect(networkToggleConnectedText.or(networkToggleWeakText)).toBeVisible() + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() const center = { x: dim.width / 2, @@ -323,7 +324,7 @@ profile001 = startProfile(sketch001, at = [0.0, 0.0]) let probe = { x: 0, y: 0 } // ... and the model's still visibly there - probe.x = center.x + (dim.width / 100) + probe.x = center.x + dim.width / 100 probe.y = center.y await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) probe = { ...center } @@ -335,7 +336,7 @@ profile001 = startProfile(sketch001, at = [0.0, 0.0]) // Check the pixel a couple times as it reconnects. // NOTE: Remember, idle behavior is still on at this point - // if this test takes longer than 5s shit WILL go south! - probe.x = center.x + (dim.width / 100) + probe.x = center.x + dim.width / 100 probe.y = center.y await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) await page.waitForTimeout(1000) @@ -344,7 +345,9 @@ profile001 = startProfile(sketch001, at = [0.0, 0.0]) // Ensure we're still connected await networkToggle.hover() - await expect(networkToggleConnectedText.or(networkToggleWeakText)).toBeVisible() + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() }) } ) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 6b5f79fde5..3dbd299ff8 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -211,8 +211,10 @@ export const EngineStream = (props: { const connectionCheckIntervalId = setInterval(() => { // SKIP DURING TESTS BECAUSE IT WILL MESS WITH REUSING THE // ELECTRON INSTANCE. - if (TEST) { return } - + if (TEST) { + return + } + // Don't try try to restart if we're already connected! const hasEngineConnectionInst = engineCommandManager.engineConnection const isDisconnected = From fb5c5cc02bd22915845ef60e38b09e5bf238900b Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 14 May 2025 13:40:10 -0400 Subject: [PATCH 10/19] small issue --- src/components/EngineStream.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 3fd9ffbc13..55b58e115a 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -72,7 +72,6 @@ export const EngineStream = (props: { const { overallState } = useNetworkContext() const engineStreamState = useSelector(engineStreamActor, (state) => state) - const settingsEngine = { /** * We omit `pool` here because `engineStreamMachine` will override it anyway * within the `EngineStreamTransition.StartOrReconfigureEngine` Promise actor. From ffaa05fac1fe30d5ac755586a4ec8a051fb113c9 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 14 May 2025 14:55:59 -0400 Subject: [PATCH 11/19] Fix tests --- ...test-network-and-connection-issues.spec.ts | 45 +++++++++---------- src/components/EngineStream.tsx | 2 - 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index 96775f9f44..493fbfea54 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -16,7 +16,7 @@ test.describe( }, () => { test( - 'simulate network down and network little w1dget', + 'simulate network down and network little widget', { tag: '@skipLocalEngine' }, async ({ page, homePage }) => { const u = await getUtils(page) @@ -114,7 +114,7 @@ test.describe( await page.mouse.click(700, 200) await expect(page.locator('.cm-content')).toHaveText( - `sketch001 = startSketchOn(XZ)` + `@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)` ) await u.closeDebugPanel() @@ -123,7 +123,7 @@ test.describe( const startXPx = 600 await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) await expect(page.locator('.cm-content')).toHaveText( - `sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})` + `@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})` ) await page.waitForTimeout(100) @@ -132,7 +132,7 @@ test.describe( await expect( page.locator('.cm-content') - ).toHaveText(`sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt}) + ).toHaveText(`@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt}) |> xLine(length = ${commonPoints.num1})`) // Expect the network to be up @@ -224,7 +224,10 @@ test.describe( await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) await expect .poll(u.normalisedEditorCode) - .toBe(`sketch001 = startSketchOn(XZ) + .toBe(`@settings(defaultLengthUnit = in) + + +sketch001 = startSketchOn(XZ) profile001 = startProfile(sketch001, at = [12.34, -12.34]) |> xLine(length = 12.34) |> line(end = [-12.34, 12.34]) @@ -235,7 +238,10 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) await expect .poll(u.normalisedEditorCode) - .toBe(`sketch001 = startSketchOn(XZ) + .toBe(`@settings(defaultLengthUnit = in) + + +sketch001 = startSketchOn(XZ) profile001 = startProfile(sketch001, at = [12.34, -12.34]) |> xLine(length = 12.34) |> line(end = [-12.34, 12.34]) @@ -264,13 +270,18 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) test( 'Paused stream freezes view frame, unpause reconnect is seamless to user', { tag: '@skipLocalEngine' }, - async ({ page, homePage, scene, cmdBar, toolbar }) => { + async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => { const networkToggle = page.getByTestId('network-toggle') const networkToggleConnectedText = page.getByText('Connected') const networkToggleWeakText = page.getByText('Network health (Weak)') - const userSettingsTab = page.getByRole('radio', { name: 'User' }) - const appStreamIdleModeSetting = page.getByTestId('app-streamIdleMode') - const settingsCloseButton = page.getByTestId('settings-close-button') + + await tronApp.cleanProjectDir({ + app: { + appearance: { + streamIdleMode: 5000, + }, + }, + }) await page.addInitScript(async () => { localStorage.setItem( @@ -291,20 +302,6 @@ profile001 = startProfile(sketch001, at = [0.0, 0.0]) await scene.settled(cmdBar) }) - await test.step('Set stream idle pause time to 5s', async () => { - await page.getByRole('link', { name: 'Settings' }).last().click() - await expect( - page.getByRole('heading', { name: 'Settings', exact: true }) - ).toBeVisible() - await userSettingsTab.click() - await appStreamIdleModeSetting.click() - await appStreamIdleModeSetting.selectOption('5000') - await settingsCloseButton.click() - await expect( - page.getByText('Set stream idle mode to "5000" as a user default') - ).toBeVisible() - }) - await test.step('Verify pausing behavior', async () => { // Wait 5s + 1s to pause. await page.waitForTimeout(6000) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 55b58e115a..f8bd7f7deb 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -52,8 +52,6 @@ export const EngineStream = (props: { const { state: modelingMachineState, send: modelingMachineActorSend } = useModelingContext() - const engineStreamState = useSelector(engineStreamActor, (state) => state) - const { file, project } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const last = useRef(Date.now()) From eb9d177db4b6a248c7f81440fe5024f23db416ab Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 14 May 2025 15:30:08 -0400 Subject: [PATCH 12/19] Fix up idle test --- .../test-network-and-connection-issues.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index 493fbfea54..93012eab20 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -269,17 +269,19 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) test( 'Paused stream freezes view frame, unpause reconnect is seamless to user', - { tag: '@skipLocalEngine' }, + { tag: ['@electron','@skipLocalEngine'] }, async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => { const networkToggle = page.getByTestId('network-toggle') const networkToggleConnectedText = page.getByText('Connected') const networkToggleWeakText = page.getByText('Network health (Weak)') + if (!tronApp) { + fail() + } + await tronApp.cleanProjectDir({ app: { - appearance: { - streamIdleMode: 5000, - }, + streamIdleMode: 5000, }, }) From 99863f110a2fa90725511cc145238fd653e257b9 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 14 May 2025 15:45:47 -0400 Subject: [PATCH 13/19] One last fix --- e2e/playwright/test-network-and-connection-issues.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index 93012eab20..d555e33840 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -269,7 +269,7 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) test( 'Paused stream freezes view frame, unpause reconnect is seamless to user', - { tag: ['@electron','@skipLocalEngine'] }, + { tag: ['@electron', '@skipLocalEngine'] }, async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => { const networkToggle = page.getByTestId('network-toggle') const networkToggleConnectedText = page.getByText('Connected') @@ -281,7 +281,7 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) await tronApp.cleanProjectDir({ app: { - streamIdleMode: 5000, + stream_idle_mode: 5000, }, }) From 4f64c3278a2667bff011cc5fb2584dc3a48b1866 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 14 May 2025 16:54:58 -0400 Subject: [PATCH 14/19] fixes --- ...test-network-and-connection-issues.spec.ts | 22 +++++++++++++++---- src/components/EngineStream.tsx | 8 +++---- src/machines/engineStreamMachine.ts | 2 +- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index d555e33840..aa661a1d21 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -19,6 +19,9 @@ test.describe( 'simulate network down and network little widget', { tag: '@skipLocalEngine' }, async ({ page, homePage }) => { + const networkToggleConnectedText = page.getByText('Connected') + const networkToggleWeakText = page.getByText('Network health (Weak)') + const u = await getUtils(page) await page.setBodyDimensions({ width: 1200, height: 500 }) @@ -39,7 +42,9 @@ test.describe( await expect(networkPopover).not.toBeVisible() // (First check) Expect the network to be up - await expect(networkToggle).toContainText('Connected') + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() // Click the network widget await networkWidget.click() @@ -87,7 +92,9 @@ test.describe( ).not.toBeDisabled({ timeout: 15000 }) // (Second check) expect the network to be up - await expect(networkToggle).toContainText('Connected') + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() } ) @@ -95,6 +102,9 @@ test.describe( 'Engine disconnect & reconnect in sketch mode', { tag: '@skipLocalEngine' }, async ({ page, homePage, toolbar, scene, cmdBar }) => { + const networkToggleConnectedText = page.getByText('Connected') + const networkToggleWeakText = page.getByText('Network health (Weak)') + const networkToggle = page.getByTestId('network-toggle') const u = await getUtils(page) @@ -136,7 +146,9 @@ test.describe( |> xLine(length = ${commonPoints.num1})`) // Expect the network to be up - await expect(networkToggle).toContainText('Connected') + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() // simulate network down await u.emulateNetworkConditions({ @@ -173,7 +185,9 @@ test.describe( ).not.toBeDisabled({ timeout: 15000 }) // Expect the network to be up - await expect(networkToggle).toContainText('Connected') + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() await scene.settled(cmdBar) // Click off the code pane. diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index f8bd7f7deb..28ad829e60 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -399,7 +399,7 @@ export const EngineStream = (props: { window.document.addEventListener('mouseup', onAnyInput) window.document.addEventListener('scroll', onAnyInput) window.document.addEventListener('touchstart', onAnyInput) - window.document.addEventListener('touchstop', onAnyInput) + window.document.addEventListener('touchend', onAnyInput) return () => { timeoutStart.current = null @@ -410,7 +410,7 @@ export const EngineStream = (props: { window.document.removeEventListener('mouseup', onAnyInput) window.document.removeEventListener('scroll', onAnyInput) window.document.removeEventListener('touchstart', onAnyInput) - window.document.removeEventListener('touchstop', onAnyInput) + window.document.removeEventListener('touchend', onAnyInput) } }, [streamIdleMode, engineStreamState.value]) @@ -428,13 +428,13 @@ export const EngineStream = (props: { window.document.addEventListener('keyup', onInput) window.document.addEventListener('mouseup', onInput) window.document.addEventListener('scroll', onInput) - window.document.addEventListener('touchstop', onInput) + window.document.addEventListener('touchend', onInput) return () => { window.document.removeEventListener('keyup', onInput) window.document.removeEventListener('mouseup', onInput) window.document.removeEventListener('scroll', onInput) - window.document.removeEventListener('touchstop', onInput) + window.document.removeEventListener('touchend', onInput) } }, []) diff --git a/src/machines/engineStreamMachine.ts b/src/machines/engineStreamMachine.ts index bcd8083679..1af48c5092 100644 --- a/src/machines/engineStreamMachine.ts +++ b/src/machines/engineStreamMachine.ts @@ -321,7 +321,7 @@ export const engineStreamMachine = setup({ // We actually failed inbetween needing to play and sending commands. [EngineStreamTransition.StartOrReconfigureEngine]: { target: EngineStreamState.WaitingForMediaStream, - renter: true, + reenter: true, }, }, }, From ee590a0081f1e721449bc44f07d15e3e0a86238c Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Wed, 14 May 2025 17:28:21 -0400 Subject: [PATCH 15/19] Remove circ dep --- src/machines/engineStreamMachine.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/machines/engineStreamMachine.ts b/src/machines/engineStreamMachine.ts index 1af48c5092..d099f6d57c 100644 --- a/src/machines/engineStreamMachine.ts +++ b/src/machines/engineStreamMachine.ts @@ -1,4 +1,3 @@ -import { engineCommandManager } from '@src/lib/singletons' import type { MutableRefObject } from 'react' import type { ActorRefFrom } from 'xstate' import { assign, fromPromise, setup } from 'xstate' @@ -246,7 +245,7 @@ export const engineStreamMachine = setup({ context.videoRef.current.srcObject = null } - engineCommandManager.tearDown({ idleMode: true }) + rootContext.engineCommandManager.tearDown({ idleMode: true }) })() ) } From 1f0c91439062263d05f0e13c98a26929ef845790 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Thu, 15 May 2025 10:12:03 -0400 Subject: [PATCH 16/19] fix test --- ...test-network-and-connection-issues.spec.ts | 653 +++++++++--------- 1 file changed, 324 insertions(+), 329 deletions(-) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index aa661a1d21..663c785b42 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -9,236 +9,232 @@ import { } from '@e2e/playwright/test-utils' import { expect, test } from '@e2e/playwright/zoo-test' -test.describe( - 'Test network related behaviors', - { - tag: ['@macos', '@windows'], - }, - () => { - test( - 'simulate network down and network little widget', - { tag: '@skipLocalEngine' }, - async ({ page, homePage }) => { - const networkToggleConnectedText = page.getByText('Connected') - const networkToggleWeakText = page.getByText('Network health (Weak)') - - const u = await getUtils(page) - await page.setBodyDimensions({ width: 1200, height: 500 }) - - await homePage.goToModelingScene() - - const networkToggle = page.getByTestId('network-toggle') - - // This is how we wait until the stream is online - await expect( - page.getByRole('button', { name: 'Start Sketch' }) - ).not.toBeDisabled({ timeout: 15000 }) - - const networkWidget = page.locator('[data-testid="network-toggle"]') - await expect(networkWidget).toBeVisible() - await networkWidget.hover() - - const networkPopover = page.locator('[data-testid="network-popover"]') - await expect(networkPopover).not.toBeVisible() - - // (First check) Expect the network to be up - await expect( - networkToggleConnectedText.or(networkToggleWeakText) - ).toBeVisible() - - // Click the network widget - await networkWidget.click() - - // Check the modal opened. - await expect(networkPopover).toBeVisible() - - // Click off the modal. - await page.mouse.click(100, 100) - await expect(networkPopover).not.toBeVisible() - - // Turn off the network - await u.emulateNetworkConditions({ - offline: true, - // values of 0 remove any active throttling. crbug.com/456324#c9 - latency: 0, - downloadThroughput: -1, - uploadThroughput: -1, - }) - - // Expect the network to be down - await expect(networkToggle).toContainText('Problem') - - // Click the network widget - await networkWidget.click() - - // Check the modal opened. - await expect(networkPopover).toBeVisible() - - // Click off the modal. - await page.mouse.click(0, 0) - await expect(networkPopover).not.toBeVisible() - - // Turn back on the network - await u.emulateNetworkConditions({ - offline: false, - // values of 0 remove any active throttling. crbug.com/456324#c9 - latency: 0, - downloadThroughput: -1, - uploadThroughput: -1, - }) - - await expect( - page.getByRole('button', { name: 'Start Sketch' }) - ).not.toBeDisabled({ timeout: 15000 }) - - // (Second check) expect the network to be up - await expect( - networkToggleConnectedText.or(networkToggleWeakText) - ).toBeVisible() - } - ) - - test( - 'Engine disconnect & reconnect in sketch mode', - { tag: '@skipLocalEngine' }, - async ({ page, homePage, toolbar, scene, cmdBar }) => { - const networkToggleConnectedText = page.getByText('Connected') - const networkToggleWeakText = page.getByText('Network health (Weak)') - - const networkToggle = page.getByTestId('network-toggle') - - const u = await getUtils(page) - await page.setBodyDimensions({ width: 1200, height: 500 }) - const PUR = 400 / 37.5 //pixeltoUnitRatio - - await homePage.goToModelingScene() - await u.waitForPageLoad() - - await u.openDebugPanel() - // click on "Start Sketch" button - await u.clearCommandLogs() - await page.getByRole('button', { name: 'Start Sketch' }).click() - await page.waitForTimeout(100) - - // select a plane - await page.mouse.click(700, 200) - - await expect(page.locator('.cm-content')).toHaveText( - `@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)` - ) - await u.closeDebugPanel() - - await page.waitForTimeout(500) // TODO detect animation ending, or disable animation - - const startXPx = 600 - await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) - await expect(page.locator('.cm-content')).toHaveText( - `@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})` - ) - await page.waitForTimeout(100) - - await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) - await page.waitForTimeout(100) - - await expect( - page.locator('.cm-content') - ).toHaveText(`@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt}) +test.describe('Test network related behaviors', () => { + test( + 'simulate network down and network little widget', + { tag: '@skipLocalEngine' }, + async ({ page, homePage }) => { + const networkToggleConnectedText = page.getByText('Connected') + const networkToggleWeakText = page.getByText('Network health (Weak)') + + const u = await getUtils(page) + await page.setBodyDimensions({ width: 1200, height: 500 }) + + await homePage.goToModelingScene() + + const networkToggle = page.getByTestId('network-toggle') + + // This is how we wait until the stream is online + await expect( + page.getByRole('button', { name: 'Start Sketch' }) + ).not.toBeDisabled({ timeout: 15000 }) + + const networkWidget = page.locator('[data-testid="network-toggle"]') + await expect(networkWidget).toBeVisible() + await networkWidget.hover() + + const networkPopover = page.locator('[data-testid="network-popover"]') + await expect(networkPopover).not.toBeVisible() + + // (First check) Expect the network to be up + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() + + // Click the network widget + await networkWidget.click() + + // Check the modal opened. + await expect(networkPopover).toBeVisible() + + // Click off the modal. + await page.mouse.click(100, 100) + await expect(networkPopover).not.toBeVisible() + + // Turn off the network + await u.emulateNetworkConditions({ + offline: true, + // values of 0 remove any active throttling. crbug.com/456324#c9 + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + + // Expect the network to be down + await expect(networkToggle).toContainText('Problem') + + // Click the network widget + await networkWidget.click() + + // Check the modal opened. + await expect(networkPopover).toBeVisible() + + // Click off the modal. + await page.mouse.click(0, 0) + await expect(networkPopover).not.toBeVisible() + + // Turn back on the network + await u.emulateNetworkConditions({ + offline: false, + // values of 0 remove any active throttling. crbug.com/456324#c9 + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + + await expect( + page.getByRole('button', { name: 'Start Sketch' }) + ).not.toBeDisabled({ timeout: 15000 }) + + // (Second check) expect the network to be up + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() + } + ) + + test( + 'Engine disconnect & reconnect in sketch mode', + { tag: '@skipLocalEngine' }, + async ({ page, homePage, toolbar, scene, cmdBar }) => { + const networkToggle = page.getByTestId('network-toggle') + const networkToggleConnectedText = page.getByText('Connected') + const networkToggleWeakText = page.getByText('Network health (Weak)') + + const u = await getUtils(page) + await page.setBodyDimensions({ width: 1200, height: 500 }) + const PUR = 400 / 37.5 //pixeltoUnitRatio + + await homePage.goToModelingScene() + await u.waitForPageLoad() + + await u.openDebugPanel() + // click on "Start Sketch" button + await u.clearCommandLogs() + await page.getByRole('button', { name: 'Start Sketch' }).click() + await page.waitForTimeout(100) + + // select a plane + await page.mouse.click(700, 200) + + await expect(page.locator('.cm-content')).toHaveText( + `@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)` + ) + await u.closeDebugPanel() + + await page.waitForTimeout(500) // TODO detect animation ending, or disable animation + + const startXPx = 600 + await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) + await expect(page.locator('.cm-content')).toHaveText( + `@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})` + ) + await page.waitForTimeout(100) + + await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) + await page.waitForTimeout(100) + + await expect( + page.locator('.cm-content') + ).toHaveText(`@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt}) |> xLine(length = ${commonPoints.num1})`) - // Expect the network to be up - await expect( - networkToggleConnectedText.or(networkToggleWeakText) - ).toBeVisible() - - // simulate network down - await u.emulateNetworkConditions({ - offline: true, - // values of 0 remove any active throttling. crbug.com/456324#c9 - latency: 0, - downloadThroughput: -1, - uploadThroughput: -1, - }) - - // Expect the network to be down - await expect(networkToggle).toContainText('Problem') - - // Ensure we are not in sketch mode - await expect( - page.getByRole('button', { name: 'Exit Sketch' }) - ).not.toBeVisible() - await expect( - page.getByRole('button', { name: 'Start Sketch' }) - ).toBeVisible() - - // simulate network up - await u.emulateNetworkConditions({ - offline: false, - // values of 0 remove any active throttling. crbug.com/456324#c9 - latency: 0, - downloadThroughput: -1, - uploadThroughput: -1, - }) - - // Wait for the app to be ready for use - await expect( - page.getByRole('button', { name: 'Start Sketch' }) - ).not.toBeDisabled({ timeout: 15000 }) - - // Expect the network to be up - await expect( - networkToggleConnectedText.or(networkToggleWeakText) - ).toBeVisible() - await scene.settled(cmdBar) - - // Click off the code pane. - await page.mouse.click(100, 100) - - // select a line - await page - .getByText(`startProfile(sketch001, at = ${commonPoints.startAt})`) - .click() - - // enter sketch again - await toolbar.editSketch() - - // Click the line tool - await page - .getByRole('button', { name: 'line Line', exact: true }) - .click() - - await page.waitForTimeout(150) - - const camCommand: EngineCommand = { - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_look_at', - center: { x: 109, y: 0, z: -152 }, - vantage: { x: 115, y: -505, z: -152 }, - up: { x: 0, y: 0, z: 1 }, - }, - } - const updateCamCommand: EngineCommand = { - type: 'modeling_cmd_req', - cmd_id: uuidv4(), - cmd: { - type: 'default_camera_get_settings', - }, - } - await toolbar.openPane('debug') - await u.sendCustomCmd(camCommand) - await page.waitForTimeout(100) - await u.sendCustomCmd(updateCamCommand) - await page.waitForTimeout(100) - - // click to continue profile - await page.mouse.click(1007, 400) - await page.waitForTimeout(100) - // Ensure we can continue sketching - await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) - await expect - .poll(u.normalisedEditorCode) - .toBe(`@settings(defaultLengthUnit = in) + // Expect the network to be up + await networkToggle.hover() + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() + + // simulate network down + await u.emulateNetworkConditions({ + offline: true, + // values of 0 remove any active throttling. crbug.com/456324#c9 + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + + // Expect the network to be down + await networkToggle.hover() + await expect(networkToggle).toContainText('Problem') + + // Ensure we are not in sketch mode + await expect( + page.getByRole('button', { name: 'Exit Sketch' }) + ).not.toBeVisible() + await expect( + page.getByRole('button', { name: 'Start Sketch' }) + ).toBeVisible() + + // simulate network up + await u.emulateNetworkConditions({ + offline: false, + // values of 0 remove any active throttling. crbug.com/456324#c9 + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + + // Wait for the app to be ready for use + await expect( + page.getByRole('button', { name: 'Start Sketch' }) + ).not.toBeDisabled({ timeout: 15000 }) + + // Expect the network to be up + await networkToggle.hover() + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() + + await scene.settled(cmdBar) + + // Click off the code pane. + await page.mouse.click(100, 100) + + // select a line + await page + .getByText(`startProfile(sketch001, at = ${commonPoints.startAt})`) + .click() + + // enter sketch again + await toolbar.editSketch() + + // Click the line tool + await page.getByRole('button', { name: 'line Line', exact: true }).click() + + await page.waitForTimeout(150) + + const camCommand: EngineCommand = { + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_look_at', + center: { x: 109, y: 0, z: -152 }, + vantage: { x: 115, y: -505, z: -152 }, + up: { x: 0, y: 0, z: 1 }, + }, + } + const updateCamCommand: EngineCommand = { + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_get_settings', + }, + } + await toolbar.openPane('debug') + await u.sendCustomCmd(camCommand) + await page.waitForTimeout(100) + await u.sendCustomCmd(updateCamCommand) + await page.waitForTimeout(100) + + // click to continue profile + await page.mouse.click(1007, 400) + await page.waitForTimeout(100) + // Ensure we can continue sketching + await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) + await expect + .poll(u.normalisedEditorCode) + .toBe(`@settings(defaultLengthUnit = in) sketch001 = startSketchOn(XZ) @@ -247,12 +243,12 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) |> line(end = [-12.34, 12.34]) `) - await page.waitForTimeout(100) - await page.mouse.click(startXPx, 500 - PUR * 20) + await page.waitForTimeout(100) + await page.mouse.click(startXPx, 500 - PUR * 20) - await expect - .poll(u.normalisedEditorCode) - .toBe(`@settings(defaultLengthUnit = in) + await expect + .poll(u.normalisedEditorCode) + .toBe(`@settings(defaultLengthUnit = in) sketch001 = startSketchOn(XZ) @@ -263,106 +259,105 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34]) `) - // Unequip line tool - await page.keyboard.press('Escape') - // Make sure we didn't pop out of sketch mode. - await expect( - page.getByRole('button', { name: 'Exit Sketch' }) - ).toBeVisible() - await expect( - page.getByRole('button', { name: 'line Line', exact: true }) - ).not.toHaveAttribute('aria-pressed', 'true') - - // Exit sketch - await page.keyboard.press('Escape') - await expect( - page.getByRole('button', { name: 'Exit Sketch' }) - ).not.toBeVisible() + // Unequip line tool + await page.keyboard.press('Escape') + // Make sure we didn't pop out of sketch mode. + await expect( + page.getByRole('button', { name: 'Exit Sketch' }) + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'line Line', exact: true }) + ).not.toHaveAttribute('aria-pressed', 'true') + + // Exit sketch + await page.keyboard.press('Escape') + await expect( + page.getByRole('button', { name: 'Exit Sketch' }) + ).not.toBeVisible() + } + ) + + test( + 'Paused stream freezes view frame, unpause reconnect is seamless to user', + { tag: ['@electron', '@skipLocalEngine'] }, + async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => { + const networkToggle = page.getByTestId('network-toggle') + const networkToggleConnectedText = page.getByText('Connected') + const networkToggleWeakText = page.getByText('Network health (Weak)') + + if (!tronApp) { + fail() } - ) - - test( - 'Paused stream freezes view frame, unpause reconnect is seamless to user', - { tag: ['@electron', '@skipLocalEngine'] }, - async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => { - const networkToggle = page.getByTestId('network-toggle') - const networkToggleConnectedText = page.getByText('Connected') - const networkToggleWeakText = page.getByText('Network health (Weak)') - - if (!tronApp) { - fail() - } - await tronApp.cleanProjectDir({ - app: { - stream_idle_mode: 5000, - }, - }) + await tronApp.cleanProjectDir({ + app: { + stream_idle_mode: 5000, + }, + }) - await page.addInitScript(async () => { - localStorage.setItem( - 'persistCode', - `sketch001 = startSketchOn(XY) + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `sketch001 = startSketchOn(XY) profile001 = startProfile(sketch001, at = [0.0, 0.0]) |> line(end = [10.0, 0]) |> line(end = [0, 10.0]) |> close()` - ) - }) - - const dim = { width: 1200, height: 500 } - await page.setBodyDimensions(dim) - - await test.step('Go to modeling scene', async () => { - await homePage.goToModelingScene() - await scene.settled(cmdBar) - }) - - await test.step('Verify pausing behavior', async () => { - // Wait 5s + 1s to pause. - await page.waitForTimeout(6000) - - // We should now be paused. To the user, it should appear we're still - // connected. - await networkToggle.hover() - await expect( - networkToggleConnectedText.or(networkToggleWeakText) - ).toBeVisible() - - const center = { - x: dim.width / 2, - y: dim.height / 2, - } - - let probe = { x: 0, y: 0 } - - // ... and the model's still visibly there - probe.x = center.x + dim.width / 100 - probe.y = center.y - await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) - probe = { ...center } - - // Now move the mouse around to unpause! - await circleMove(page, probe.x, probe.y, 20, 10) - - // ONCE AGAIN! Check the view area hasn't changed at all. - // Check the pixel a couple times as it reconnects. - // NOTE: Remember, idle behavior is still on at this point - - // if this test takes longer than 5s shit WILL go south! - probe.x = center.x + dim.width / 100 - probe.y = center.y - await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) - await page.waitForTimeout(1000) - await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) - probe = { ...center } - - // Ensure we're still connected - await networkToggle.hover() - await expect( - networkToggleConnectedText.or(networkToggleWeakText) - ).toBeVisible() - }) - } - ) - } -) + ) + }) + + const dim = { width: 1200, height: 500 } + await page.setBodyDimensions(dim) + + await test.step('Go to modeling scene', async () => { + await homePage.goToModelingScene() + await scene.settled(cmdBar) + }) + + await test.step('Verify pausing behavior', async () => { + // Wait 5s + 1s to pause. + await page.waitForTimeout(6000) + + // We should now be paused. To the user, it should appear we're still + // connected. + await networkToggle.hover() + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() + + const center = { + x: dim.width / 2, + y: dim.height / 2, + } + + let probe = { x: 0, y: 0 } + + // ... and the model's still visibly there + probe.x = center.x + dim.width / 100 + probe.y = center.y + await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) + probe = { ...center } + + // Now move the mouse around to unpause! + await circleMove(page, probe.x, probe.y, 20, 10) + + // ONCE AGAIN! Check the view area hasn't changed at all. + // Check the pixel a couple times as it reconnects. + // NOTE: Remember, idle behavior is still on at this point - + // if this test takes longer than 5s shit WILL go south! + probe.x = center.x + dim.width / 100 + probe.y = center.y + await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) + await page.waitForTimeout(1000) + await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15) + probe = { ...center } + + // Ensure we're still connected + await networkToggle.hover() + await expect( + networkToggleConnectedText.or(networkToggleWeakText) + ).toBeVisible() + }) + } + ) +}) From b3296bc86388f59e049b127bcf680aae9efd050f Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Thu, 15 May 2025 11:23:38 -0400 Subject: [PATCH 17/19] use a const for time --- src/components/EngineStream.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 28ad829e60..641a8fc3c7 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -43,6 +43,8 @@ import { DEFAULT_DEFAULT_LENGTH_UNIT } from '@src/lib/constants' import { createThumbnailPNGOnDesktop } from '@src/lib/screenshot' import type { SettingsViaQueryString } from '@src/lib/settings/settingsTypes' +const TIME_1_SECOND = 1000 + export const EngineStream = (props: { pool: string | null authToken: string | undefined @@ -58,7 +60,7 @@ export const EngineStream = (props: { const [firstPlay, setFirstPlay] = useState(true) const [isRestartRequestStarting, setIsRestartRequestStarting] = useState(false) - const [attemptTimes, setAttemptTimes] = useState<[number, number]>([0, 1000]) + const [attemptTimes, setAttemptTimes] = useState<[number, number]>([0, TIME_1_SECOND]) // These will be passed to the engineStreamActor to handle. const videoRef = useRef(null) @@ -170,7 +172,7 @@ export const EngineStream = (props: { setFirstPlay(false) // Reset the restart timeouts - setAttemptTimes([0, 1000]) + setAttemptTimes([0, TIME_1_SECOND]) console.log('firstPlay true, zoom to fit') kmp @@ -257,7 +259,7 @@ export const EngineStream = (props: { if ((hasEngineConnectionInst && !isDisconnected) || inIdleMode) return attemptRestartIfNecessary() - }, 1000) + }, TIME_1_SECOND) engineCommandManager.addEventListener( EngineCommandManagerEvents.EngineRestartRequest, From da9d294c9ccd70b62cda7c2f832cb7635c6fbdb1 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Thu, 15 May 2025 11:26:41 -0400 Subject: [PATCH 18/19] TEST -> isPlaywright instead --- src/components/EngineStream.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 641a8fc3c7..63f5e108dd 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -1,4 +1,4 @@ -import { TEST } from '@src/env' +import { isPlaywright } from '@src/lib/isPlaywright' import { useAppState } from '@src/AppState' import { ClientSideScene } from '@src/clientSideScene/ClientSideSceneComp' import { ViewControlContextMenu } from '@src/components/ViewControlMenu' @@ -60,7 +60,10 @@ export const EngineStream = (props: { const [firstPlay, setFirstPlay] = useState(true) const [isRestartRequestStarting, setIsRestartRequestStarting] = useState(false) - const [attemptTimes, setAttemptTimes] = useState<[number, number]>([0, TIME_1_SECOND]) + const [attemptTimes, setAttemptTimes] = useState<[number, number]>([ + 0, + TIME_1_SECOND, + ]) // These will be passed to the engineStreamActor to handle. const videoRef = useRef(null) From b8e6d53c2835b913f1c5a655be796f4a183e5c98 Mon Sep 17 00:00:00 2001 From: lee-at-zoo-corp Date: Thu, 15 May 2025 11:40:11 -0400 Subject: [PATCH 19/19] whoops --- src/components/EngineStream.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 63f5e108dd..64c4c16b72 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -249,7 +249,7 @@ export const EngineStream = (props: { const connectionCheckIntervalId = setInterval(() => { // SKIP DURING TESTS BECAUSE IT WILL MESS WITH REUSING THE // ELECTRON INSTANCE. - if (TEST) { + if (isPlaywright()) { return }