Repository: streamlit/streamlit

Issue:
Make it easy to screencast a Streamlit app
See spec here: https://docs.google.com/presentation/d/18bNul9a6rjScGhxRmGbZbCcU3uYp_b3ckxA9DVFJlKM/edit

And see a crappy demo I wrote with some code you can steal:
https://gist.github.com/tvst/c114620cf36b77732d5d67f411c55f12

Questions:
* What browsers support this?
* Can we record as mp4 / h264?
* Can we record in a format that works in both Windows and Mac without extra installs? (Linux is not a problem -- users know how to open video in different formats)


Discussion:

Resolving patch:
diff --git a/e2e/scripts/st_chart_utc_time.py b/e2e/scripts/st_chart_utc_time.py
--- a/e2e/scripts/st_chart_utc_time.py
+++ b/e2e/scripts/st_chart_utc_time.py
@@ -1,3 +1,18 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018-2020 Streamlit Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
 from datetime import date
 
 import pandas as pd
diff --git a/e2e/scripts/st_latex.py b/e2e/scripts/st_latex.py
--- a/e2e/scripts/st_latex.py
+++ b/e2e/scripts/st_latex.py
@@ -1,3 +1,18 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018-2020 Streamlit Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
 import streamlit as st
 
 st.latex(r"\LaTeX")


Test patch:
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -16,22 +16,46 @@
  */
 
 import React from "react"
-import { mount, CommonWrapper } from "enzyme"
+import { shallow, mount, ReactWrapper } from "enzyme"
 import { ForwardMsg } from "autogen/proto"
 import { MetricsManager } from "./lib/MetricsManager"
 import { getMetricsManagerForTest } from "./lib/MetricsManagerTestUtils"
 import { SessionInfo, Args as SessionInfoArgs } from "./lib/SessionInfo"
 
-import App from "./App"
+import { App, Props } from "./App"
+import MainMenu from "./components/core/MainMenu"
 
-const getWrapper = (): CommonWrapper => {
+const getProps = (): Props => ({
+  screenCast: {
+    toggleRecordAudio: jest.fn(),
+    startRecording: jest.fn(),
+    stopRecording: jest.fn(),
+    fileName: "",
+    recording: false,
+    recordAudio: false,
+    countdown: -1,
+    startAnimation: false,
+    showRecordedDialog: false,
+    showScreencastDialog: false,
+    showUnsupportedDialog: false,
+  },
+})
+
+const getWrapper = (): ReactWrapper => {
   const mountPoint = document.createElement("div")
   mountPoint.setAttribute("id", "ConnectionStatus")
   document.body.appendChild(mountPoint)
+  const props = getProps()
 
-  return mount(<App />, { attachTo: mountPoint })
+  return mount(<App {...props} />, { attachTo: mountPoint })
 }
 
+jest.mock("moment", () =>
+  jest.fn().mockImplementation(() => ({
+    format: () => "date",
+  }))
+)
+
 describe("App", () => {
   beforeEach(() => {
     SessionInfo.current = new SessionInfo({
@@ -57,7 +81,8 @@ describe("App", () => {
   })
 
   it("should reload when streamlit server version changes", () => {
-    const wrapper = getWrapper()
+    const props = getProps()
+    const wrapper = shallow(<App {...props} />)
 
     window.location.reload = jest.fn()
 
@@ -77,4 +102,32 @@ describe("App", () => {
 
     expect(window.location.reload).toHaveBeenCalled()
   })
+
+  it("should start screencast recording when the MainMenu is clicked", () => {
+    const props = getProps()
+    const wrapper = shallow(<App {...props} />)
+
+    wrapper.setState({
+      reportName: "reportName",
+    })
+
+    wrapper
+      .find(MainMenu)
+      .props()
+      .screencastCallback()
+
+    expect(props.screenCast.startRecording).toHaveBeenCalledWith(
+      "streamlit-reportName-date"
+    )
+  })
+
+  it("should stop screencast when esc is pressed", () => {
+    const props = getProps()
+    const wrapper = shallow(<App {...props} />)
+
+    // @ts-ignore
+    wrapper.instance().keyHandlers.esc()
+
+    expect(props.screenCast.stopRecording).toBeCalled()
+  })
 })
diff --git a/frontend/src/hocs/withScreencast/components/ScreencastDialog/ScreencastDialog.test.tsx b/frontend/src/hocs/withScreencast/components/ScreencastDialog/ScreencastDialog.test.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/src/hocs/withScreencast/components/ScreencastDialog/ScreencastDialog.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2018-2020 Streamlit Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react"
+import { shallow } from "enzyme"
+import { ModalHeader, ModalFooter, Button } from "reactstrap"
+
+import ScreencastDialog, { Props } from "./ScreencastDialog"
+
+const getProps = (props: object = {}): Props => ({
+  onClose: jest.fn(),
+  startRecording: jest.fn(),
+  toggleRecordAudio: jest.fn(),
+  recordAudio: false,
+  ...props,
+})
+
+describe("ScreencastDialog", () => {
+  it("renders without crashing", () => {
+    const props = getProps()
+    const wrapper = shallow(<ScreencastDialog {...props} />)
+
+    expect(wrapper.html()).not.toBeNull()
+  })
+
+  it("should render a header", () => {
+    const props = getProps()
+    const wrapper = shallow(<ScreencastDialog {...props} />)
+    const headerWrapper = wrapper.find(ModalHeader)
+
+    // @ts-ignore
+    headerWrapper.props().toggle()
+
+    expect(headerWrapper.props().children).toBe("Record a screencast")
+    expect(props.onClose).toBeCalled()
+  })
+
+  describe("Modal body", () => {
+    it("should have a record audio option to be selected", () => {
+      const props = getProps()
+      const wrapper = shallow(<ScreencastDialog {...props} />)
+
+      const labelWrapper = wrapper.find("p label")
+
+      labelWrapper.find("input").simulate("change", {
+        target: {
+          checked: true,
+        },
+      })
+
+      expect(labelWrapper.text()).toBe(" Also record audio")
+      expect(wrapper.find("input").props().checked).toBeTruthy()
+      expect(props.toggleRecordAudio).toBeCalled()
+    })
+
+    it("should have the stop recording explanation message", () => {
+      const props = getProps()
+      const wrapper = shallow(<ScreencastDialog {...props} />)
+
+      expect(
+        wrapper
+          .find("p")
+          .last()
+          .text()
+      ).toBe("Press Esc any time to stop recording.")
+    })
+  })
+
+  describe("Modal footer", () => {
+    it("should have an start button", () => {
+      const props = getProps()
+      const wrapper = shallow(<ScreencastDialog {...props} />)
+      const buttonWrapper = wrapper.find(ModalFooter).find(Button)
+
+      buttonWrapper.simulate("click")
+
+      expect(buttonWrapper.props().children).toBe("Start recording!")
+      expect(props.startRecording).toBeCalled()
+      expect(props.onClose).toBeCalled()
+    })
+  })
+})
diff --git a/frontend/src/hocs/withScreencast/components/UnsupportedBrowserDialog/UnsupportedBrowserDialog.test.tsx b/frontend/src/hocs/withScreencast/components/UnsupportedBrowserDialog/UnsupportedBrowserDialog.test.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/src/hocs/withScreencast/components/UnsupportedBrowserDialog/UnsupportedBrowserDialog.test.tsx
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2018-2020 Streamlit Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react"
+import { shallow } from "enzyme"
+import { ModalHeader, ModalBody } from "reactstrap"
+
+import UnsupportedBrowserDialog from "./UnsupportedBrowserDialog"
+
+describe("UnsupportedBrowserDialog", () => {
+  it("renders without crashing", () => {
+    const wrapper = shallow(<UnsupportedBrowserDialog onClose={() => {}} />)
+
+    expect(wrapper.html()).not.toBeNull()
+  })
+
+  it("should render a header", () => {
+    const onClose = jest.fn()
+    const wrapper = shallow(<UnsupportedBrowserDialog onClose={onClose} />)
+    const headerWrapper = wrapper.find(ModalHeader)
+
+    // @ts-ignore
+    headerWrapper.props().toggle()
+
+    expect(headerWrapper.props().children).toBe("Record a screencast")
+    expect(onClose).toBeCalled()
+  })
+
+  it("should render a body with the correct message", () => {
+    const wrapper = shallow(<UnsupportedBrowserDialog onClose={() => {}} />)
+    const bodyWrapper = wrapper.find(ModalBody)
+
+    expect(bodyWrapper.find("span[aria-label='Alien Monster']").text()).toBe(
+      "👾"
+    )
+    expect(bodyWrapper.find("p").text()).toBe(
+      "Due to limitations with some browsers, this feature is only supported on recent desktop versions of Chrome, Firefox, and Edge."
+    )
+  })
+})
diff --git a/frontend/src/hocs/withScreencast/components/VideoRecordedDialog/VideoRecordedDialog.test.tsx b/frontend/src/hocs/withScreencast/components/VideoRecordedDialog/VideoRecordedDialog.test.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/src/hocs/withScreencast/components/VideoRecordedDialog/VideoRecordedDialog.test.tsx
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2018-2020 Streamlit Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react"
+import { shallow } from "enzyme"
+import { ModalHeader, ModalBody, Button } from "reactstrap"
+
+import VideoRecordedDialog, { Props } from "./VideoRecordedDialog"
+
+URL.createObjectURL = jest.fn()
+
+const getProps = (props: object = {}): Props => ({
+  fileName: "test",
+  onClose: jest.fn(),
+  videoBlob: new Blob(),
+  ...props,
+})
+
+describe("VideoRecordedDialog", () => {
+  it("renders without crashing", () => {
+    const wrapper = shallow(<VideoRecordedDialog {...getProps()} />)
+
+    expect(wrapper.html()).not.toBeNull()
+  })
+
+  it("should render a header", () => {
+    const props = getProps()
+    const wrapper = shallow(<VideoRecordedDialog {...props} />)
+    const headerWrapper = wrapper.find(ModalHeader)
+
+    // @ts-ignore
+    headerWrapper.props().toggle()
+
+    expect(headerWrapper.props().children).toBe("Next steps")
+    expect(props.onClose).toBeCalled()
+  })
+
+  it("should render a video", () => {
+    const wrapper = shallow(<VideoRecordedDialog {...getProps()} />)
+    const bodyWrapper = wrapper.find(ModalBody)
+
+    expect(bodyWrapper.find("video").length).toBe(1)
+    expect(URL.createObjectURL).toBeCalled()
+  })
+
+  it("should render a download button", () => {
+    const props = getProps()
+    const wrapper = shallow(<VideoRecordedDialog {...props} />)
+    const buttonWrapper = wrapper.find(ModalBody).find(Button)
+
+    buttonWrapper.simulate("click")
+
+    expect(buttonWrapper.length).toBe(1)
+    expect(props.onClose).toBeCalled()
+  })
+})
diff --git a/frontend/src/hocs/withScreencast/withScreencast.test.tsx b/frontend/src/hocs/withScreencast/withScreencast.test.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/src/hocs/withScreencast/withScreencast.test.tsx
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright 2018-2020 Streamlit Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+jest.mock("lib/ScreenCastRecorder")
+
+import React, { ComponentType } from "react"
+import { shallow } from "enzyme"
+
+import withScreencast from "./withScreencast"
+import { ScreencastDialog, UnsupportedBrowserDialog } from "./components"
+
+const testComponent: ComponentType = () => <div>test</div>
+
+describe("withScreencast HOC", () => {
+  it("renders without crashing", () => {
+    const WithHoc = withScreencast(testComponent)
+    const wrapper = shallow(<WithHoc />)
+
+    expect(wrapper.html()).not.toBeNull()
+  })
+
+  it("wrapped component should have screenCast prop", () => {
+    const WithHoc = withScreencast(testComponent)
+    const wrapper = shallow(<WithHoc />)
+
+    // @ts-ignore
+    expect(wrapper.find(testComponent).props().screenCast).toBeDefined()
+  })
+
+  it("it should show a configuration dialog before start recording", () => {
+    const WithHoc = withScreencast(testComponent)
+    const wrapper = shallow(<WithHoc />)
+
+    // @ts-ignore
+    wrapper.instance().checkSupportedBrowser = () => true
+
+    // @ts-ignore
+    wrapper
+      .find(testComponent)
+      .props()
+      .screenCast.startRecording("screencast-filename")
+
+    expect(wrapper.find(ScreencastDialog).length).toBe(1)
+  })
+
+  it("it should show an unsupported dialog", () => {
+    const WithHoc = withScreencast(testComponent)
+    const wrapper = shallow(<WithHoc />)
+
+    // @ts-ignore
+    wrapper.instance().checkSupportedBrowser = () => false
+
+    // @ts-ignore
+    wrapper
+      .find(testComponent)
+      .props()
+      .screenCast.startRecording("screencast-filename")
+
+    expect(wrapper.find(UnsupportedBrowserDialog).length).toBe(1)
+  })
+})
diff --git a/lib/tests/streamlit/echo_test_data/root_level_echo.py b/lib/tests/streamlit/echo_test_data/root_level_echo.py
--- a/lib/tests/streamlit/echo_test_data/root_level_echo.py
+++ b/lib/tests/streamlit/echo_test_data/root_level_echo.py
@@ -1,3 +1,18 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018-2020 Streamlit Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
 import streamlit as st
 
 with st.echo():