From 37d03a1018f931b75d21900d73a3ebc9e11defb6 Mon Sep 17 00:00:00 2001
From: nhertz <nhertz@nrao.edu>
Date: Thu, 9 Sep 2021 16:14:04 -0600
Subject: [PATCH] Modified `carta_envoy` to serve CARTA wrapped in a simple
 HTML countdown clock that dies elegantly to improve the UX when the timeout
 expires

---
 .../pexable/carta_envoy/carta_envoy/carta.py  |   2 +-
 .../carta_envoy/carta_envoy/connect.py        |  41 +++-
 .../carta_envoy/carta_envoy/launchers.py      | 144 ++++++++----
 .../carta_envoy/carta_envoy/templates.py      | 160 +++++++++++++
 .../executables/pexable/carta_envoy/setup.py  |   2 +-
 .../pexable/carta_envoy/test/test_connect.py  |   2 +-
 .../carta_envoy/test/test_launchers.py        | 216 +++++++-----------
 .../carta_envoy/test/test_templates.py        |   7 +
 8 files changed, 398 insertions(+), 176 deletions(-)
 create mode 100644 apps/cli/executables/pexable/carta_envoy/carta_envoy/templates.py
 create mode 100644 apps/cli/executables/pexable/carta_envoy/test/test_templates.py

diff --git a/apps/cli/executables/pexable/carta_envoy/carta_envoy/carta.py b/apps/cli/executables/pexable/carta_envoy/carta_envoy/carta.py
index e3bdf24f7..77da18ada 100644
--- a/apps/cli/executables/pexable/carta_envoy/carta_envoy/carta.py
+++ b/apps/cli/executables/pexable/carta_envoy/carta_envoy/carta.py
@@ -5,9 +5,9 @@ import os
 import sys
 from pathlib import Path
 from typing import Dict
-from pycapo import CapoConfig
 
 from carta_envoy.launchers import CartaLauncher
+from pycapo import CapoConfig
 
 # pylint: disable=C0301, E0401, W0622, W1203
 
diff --git a/apps/cli/executables/pexable/carta_envoy/carta_envoy/connect.py b/apps/cli/executables/pexable/carta_envoy/carta_envoy/connect.py
index 1a540ea9e..69701ab2b 100644
--- a/apps/cli/executables/pexable/carta_envoy/carta_envoy/connect.py
+++ b/apps/cli/executables/pexable/carta_envoy/carta_envoy/connect.py
@@ -29,10 +29,11 @@ class RedisConnect:
         return {
             "front_end_id": generate_random_str(),
             "back_end_id": generate_random_str(),
+            "wrapper_id": generate_random_str(),
             "system_name": generate_random_str(),
         }
 
-    def generate_url(self) -> str:
+    def generate_carta_url(self) -> str:
         self.logger.info("Generating CARTA url...")
         front_end_id = self.generated_ids["front_end_id"]
         back_end_id = self.generated_ids["back_end_id"]
@@ -50,6 +51,20 @@ class RedisConnect:
         self.logger.info(f"Carta URL: {carta_url}")
         return carta_url
 
+    def generate_wrapper_url(self) -> str:
+        """
+        Generate URL for wrapper HTML; will be given to user
+
+        :return: URL to access CARTA wrapper page
+        """
+        self.logger.info("Generating wrapper url...")
+        random_id = self.generated_ids["wrapper_id"]
+        proxy = self.settings["reverse_proxy"]
+        wrapper_url = f"https://{proxy}/{random_id}/"
+
+        self.logger.info(f"Wrapper URL: {wrapper_url}")
+        return wrapper_url
+
     def get_available_port(self) -> int:
         """
         Asks the operating system for an available port on localhost using the socket module
@@ -66,21 +81,27 @@ class RedisConnect:
         self.logger.info("Preparing Redis server...")
         front_end_port = self.get_available_port()
         back_end_port = self.get_available_port()
-        carta_url = self.generate_url()
+        wrapper_port = self.get_available_port()
+        carta_url = self.generate_carta_url()
+        wrapper_url = self.generate_wrapper_url()
         self.conn = self.connect_to_redis()
         redis_values = self.get_redis_values(
             self.settings["reverse_proxy"],
             self.generated_ids["front_end_id"],
             self.generated_ids["back_end_id"],
+            self.generated_ids["wrapper_id"],
             front_end_port,
             back_end_port,
+            wrapper_port,
         )
         self.set_redis_values(redis_values, int(self.settings["timeout"]))
 
         return {
             "front_end_port": front_end_port,
             "back_end_port": back_end_port,
+            "wrapper_port": wrapper_port,
             "carta_url": carta_url,
+            "wrapper_url": wrapper_url,
         }
 
     def connect_to_redis(self) -> redis.Redis:
@@ -105,8 +126,10 @@ class RedisConnect:
         reverse_proxy_host: str,
         front_end_id: str,
         back_end_id: str,
+        carta_wrapper_id: str,
         front_end_port: int,
         back_end_port: int,
+        wrapper_port: int,
     ) -> Dict[str, str]:
         """
         Get Redis key/value pairs for CARTA front end and back end setup
@@ -114,14 +137,17 @@ class RedisConnect:
         :param reverse_proxy_host: Hostname for the reverse proxy
         :param front_end_id: Random string ID for CARTA front end
         :param back_end_id: Random string ID for CARTA back end
+        :param carta_wrapper_id: Random string ID for CARTA wrapper
         :param front_end_port: Port to run the CARTA front end with
         :param back_end_port: Port to run the CARTA back end with
+        :param wrapper_port: Port to run the CARTA wrapper with
         :return: Dictionary of values
         """
         self.logger.info("Determining values for Redis...")
         service_name = generate_random_str()
         front_end = f"carta-front-{service_name}"
         back_end = f"carta-back-{service_name}"
+        carta_wrapper = f"carta-wrapper-{service_name}"
         hostname = socket.getfqdn()
         values = {
             # FRONT END
@@ -135,6 +161,11 @@ class RedisConnect:
             f"traefik/http/services/{back_end}/loadbalancer/servers/0/url": f"http://{hostname}:{back_end_port}/",
             f"traefik/http/routers/{back_end}/middlewares/0": "upgradeWSHeader@file",
             f"traefik/http/routers/{back_end}/middlewares/1": "stripPrefixFE@file",
+            # WRAPPER
+            f"traefik/http/routers/{carta_wrapper}/rule": f"Host(`{reverse_proxy_host}`) && PathPrefix(`/{carta_wrapper_id}/`)",
+            f"traefik/http/routers/{carta_wrapper}/service": carta_wrapper,
+            f"traefik/http/services/{carta_wrapper}/loadbalancer/servers/0/url": f"http://{hostname}:{wrapper_port}/",
+            f"traefik/http/routers/{carta_wrapper}/middlewares/0": "stripPrefixFE@file",
         }
 
         unique_values = self.check_for_duplicate_values(values, front_end_port, back_end_port)
@@ -209,17 +240,17 @@ class NotificationConnect:
         self.settings = settings
         self.url = settings["notification_url"]
 
-    def send_session_ready(self, carta_url: str):
+    def send_session_ready(self, wrapper_url: str):
         if "user_email" not in self.settings:
             self.logger.info("Not sending notification because no user email supplied")
             return
 
-        self.logger.info(f"Sending session ready notification with CARTA wrapper URL {carta_url}")
+        self.logger.info(f"Sending session ready notification with CARTA wrapper URL {wrapper_url}")
         requests.post(
             f"{self.url}/notify/carta_ready/send",
             json={
                 "destination_email": self.settings["user_email"],
-                "carta_url": carta_url,
+                "carta_url": wrapper_url,
             },
         )
 
diff --git a/apps/cli/executables/pexable/carta_envoy/carta_envoy/launchers.py b/apps/cli/executables/pexable/carta_envoy/carta_envoy/launchers.py
index 61a8839c6..87e8b963d 100644
--- a/apps/cli/executables/pexable/carta_envoy/carta_envoy/launchers.py
+++ b/apps/cli/executables/pexable/carta_envoy/carta_envoy/launchers.py
@@ -1,25 +1,67 @@
+""" This is the CARTA launcher. """
 import logging
+import math
 import os
 import re
 import signal
 import subprocess
 import sys
+from http.server import HTTPServer, SimpleHTTPRequestHandler
 from pathlib import Path
+from threading import Thread
 from types import FrameType
 from typing import Optional
 
+import pendulum as pendulum
 from carta_envoy.connect import ArchiveConnect, NotificationConnect, RedisConnect
+from carta_envoy.templates import CartaWrapper
+from carta_envoy.utilities import (
+    CARTA_HTML_FILENAME,
+    CARTA_TEMPLATE,
+    CARTA_URL_REPLACE_TEXT,
+)
+from pendulum import DateTime
 
-# pylint: disable=R0913, R1721, W0603, W1203
+# pylint: disable=E0401, R0913, R1721, W0603, W1203
 
 """
 Setup and Launch a CARTA session
 """
 
+logger = logging.getLogger("carta_envoy")
 CARTA_PROCESS: Optional[subprocess.Popen] = None
-CARTA_URL_REPLACE_TEXT = "CARTA_URL_GOES_HERE"
-CARTA_HTML_TEMPLATE_FILENAME = "carta_url_template.html"
-CARTA_HTML_FILENAME = "carta_url_page.html"
+
+
+class CartaWrapperLauncher:
+    """ Serves an HTML wrapper for CARTA """
+
+    @staticmethod
+    def run(port: int, server_class=HTTPServer, handler_class=SimpleHTTPRequestHandler):
+        """
+        Run simple web server for hosting CARTA wrapper HTML
+
+        :param port: Port to run the server on
+        :param server_class: The class that handles serverly duties
+        :param handler_class: The class that handles HTTP requests to the server
+        """
+        server_address = ("", port)
+        with server_class(server_address, handler_class) as httpd:
+            httpd.serve_forever()
+
+    @staticmethod
+    def deploy_wrapper_html(product_dir: Path, carta_url: str, session_timeout_date: str):
+        """
+        Deploy wrapper HTML to product directory as `wrapper.html`
+
+        :param product_dir: Path to deploy HTML to
+        :param carta_url: URL for CARTA instance
+        :param session_timeout_date: Datetime at which this CARTA session will expire
+        """
+        wrapper_html = CartaWrapper().render_template(carta_url, session_timeout_date)
+
+        # Deploy filled-out wrapper HTML to product directory
+        with open(product_dir / "index.html", "w") as complete_wrapper_file:
+            complete_wrapper_file.write(wrapper_html)
 
 
 class CartaLauncher:
@@ -61,12 +103,11 @@ class CartaLauncher:
 
     def launch(self):
         """
-        Launches CARTA.
-
-        :return:
+        Launches CARTA and its wrapper.
         """
-        self.logger.info("RUNNING CARTA!")
+        self.logger.info("RUNNING CARTA WITH WRAPPER!")
         prepare = self.prepare_for_carta()
+        session_timeout_date = str(self._calculate_timeout_date(int(self.settings["timeout"])))
 
         self.run_carta(
             self.settings["carta_path"],
@@ -74,7 +115,10 @@ class CartaLauncher:
             Path(self.settings["data_location"]),
             prepare["front_end_port"],
             prepare["back_end_port"],
+            prepare["wrapper_port"],
             prepare["carta_url"],
+            prepare["wrapper_url"],
+            session_timeout_date,
         )
 
     def run_carta(
@@ -84,7 +128,10 @@ class CartaLauncher:
         file_browser_path: Path,
         front_end_port: int,
         back_end_port: int,
+        wrapper_port: int,
         carta_url: str,
+        wrapper_url: str,
+        session_timeout_date: str,
     ):
         """
         Runs CARTA executable. Will close after a certain amount of time.
@@ -94,7 +141,10 @@ class CartaLauncher:
         :param file_browser_path: Path to use for CARTA file browser
         :param front_end_port: Port to run the CARTA front end with
         :param back_end_port: Port to run the CARTA back end with
+        :param wrapper_port: Port to run the CARTA wrapper with
         :param carta_url: URL used to access CARTA from a web browser
+        :param wrapper_url: URL used to access CARTA wrapper from a web browser
+        :param session_timeout_date: Date at which CARTa session will expire
         """
         global CARTA_PROCESS
 
@@ -142,38 +192,47 @@ class CartaLauncher:
                 self.teardown()
                 sys.exit(f"ERROR: Failed to launch CARTA: {err}")
             else:
-                carta_html = self.create_frame_html(carta_url=carta_url, html_dir=file_browser_path)
+                CartaWrapperLauncher.deploy_wrapper_html(
+                    file_browser_path, carta_url, session_timeout_date
+                )
 
                 # CARTA is running and accessible, so send CARTA URL to AAT system or notify user
-                self.notify_ready(carta_url=carta_url, carta_html=carta_html)
+                self.notify_ready(wrapper_url)
 
                 # Activate timeout handler
                 signal.signal(signal.SIGALRM, self.signal)
                 signal.alarm(60 * timeout_minutes)
-                self.logger.info(f"To access CARTA, enter this URL into a web browser: {carta_url}")
+                self.logger.info(
+                    f"To access CARTA, enter this URL into a web browser: {wrapper_url}"
+                )
                 self.logger.info("Press Ctrl+C to quit.")
 
+                wrapper_server_thread = Thread(
+                    target=CartaWrapperLauncher.run, args=[wrapper_port], daemon=True
+                )
+                wrapper_server_thread.run()
+
                 # Loop until process ends or timeout elapses, whichever happens first
                 while CARTA_PROCESS.returncode is None:
                     CARTA_PROCESS.poll()
+                    assert wrapper_server_thread.is_alive()
         else:
             self.logger.info("Running locally...")
 
-    def notify_ready(self, carta_url: str, carta_html: Path):
+    def notify_ready(self, wrapper_url: str):
         """
         Sends URL notification to user and request handler
 
-        :param carta_url: URL to CARTA session
-
-        :return:
+        :param wrapper_url: URL to CARTA session
         """
+
         self.logger.info("SENDING URL NOTIFICATION TO: ")
         if self.settings["send_ready"] == "true":
-            self.logger.info("User Email")
-            self.notification.send_session_ready(carta_url)
+            self.logger.info(f"User Email")
+            self.notification.send_session_ready(wrapper_url)
         elif self.settings["send_ready"] == "false":
-            self.logger.info("AAT Request Handler")
-            self.archive_connect.send_carta_url_to_rh(carta_url)
+            self.logger.info(f"AAT Request Handler, with {wrapper_url}")
+            self.archive_connect.send_carta_url_to_rh(wrapper_url)
 
     def teardown(self):
         """
@@ -200,7 +259,8 @@ class CartaLauncher:
         else:
             self.logger.warning("WARNING: CARTA not running.")
 
-    def create_frame_html(self, carta_url: str, html_dir: Path) -> Path:
+    @staticmethod
+    def create_frame_html(carta_url: str, html_dir: Path) -> Path:
         """
         Generate the HTML page containing the CARTA URL in a frame.
 
@@ -208,26 +268,32 @@ class CartaLauncher:
         :param html_dir: where HTML will be written
         :return: HTML file we just created
         """
-        template_text = """
-<!DOCTYPE html>
-<html>
-<head title="CARTA Session in a Frame">
-    <h4 style="color:#006400; font-size:24px;">
-        Your CARTA Session</h4>
-</head>
-<body>
-<iframe
-        title="CARTA"
-        style="position: absolute; height: 100%; border: none"
-        src="CARTA_URL_GOES_HERE" style="overflow:hidden;height:100%;width:100%" height="100%" width="100%">
-
-</iframe>
-</body>
-
-</html>
-"""
-        new_content = template_text.replace(CARTA_URL_REPLACE_TEXT, carta_url)
+
+        new_content = CARTA_TEMPLATE.replace(CARTA_URL_REPLACE_TEXT, carta_url)
         html_file = html_dir / CARTA_HTML_FILENAME
         html_file.write_text(new_content)
 
         return html_file
+
+    @staticmethod
+    def _calculate_timeout_date(timeout_minutes: int) -> DateTime:
+        """
+        Take the timeout of the CARTA session amd use it to calculate the datetime that the session will expire at
+
+        :param timeout_minutes: Timeout of the CARTA session
+        :return: Datetime at which CARTA session will expire
+        """
+        logger.info(f"Creating timestamp for end of timeout ({timeout_minutes} minutes)")
+        if timeout_minutes > 59:
+            timeout_hours, timeout_remainder = math.modf(timeout_minutes / 60)
+            timeout_hours = int(timeout_hours)
+            timeout_minutes = int(timeout_remainder * 60)
+            logger.info(
+                f"Timeout longer than 1 hour. Splitting into {timeout_hours}:{timeout_minutes}"
+            )
+            timeout_date = pendulum.now().add(hours=timeout_hours, minutes=timeout_minutes)
+        else:
+            timeout_date = pendulum.now().add(minutes=timeout_minutes)
+
+        logger.info(f"Final timeout date of {timeout_date}")
+        return timeout_date
diff --git a/apps/cli/executables/pexable/carta_envoy/carta_envoy/templates.py b/apps/cli/executables/pexable/carta_envoy/carta_envoy/templates.py
new file mode 100644
index 000000000..f552a8e85
--- /dev/null
+++ b/apps/cli/executables/pexable/carta_envoy/carta_envoy/templates.py
@@ -0,0 +1,160 @@
+class CartaWrapper:
+    template = """<!DOCTYPE html>
+<html lang="en">
+  <!-- Wrapper page for a CARTA session: the goal here is to minimize 3rd
+   party libraries and have all the styling stuff inline, so no other
+   files to worry about. Two variables will need to be filled in:
+
+   session_end_date: UTC date session ends, 2021-09-03T22:20:14Z
+   session_url:      the URL to the CARTA session
+-->
+
+  <head>
+    <title>NRAO Archive CARTA Session</title>
+    <style type="text/css">
+      body,
+      html {
+        margin: 0;
+        padding: 0;
+        height: 100%;
+        overflow: hidden;
+      }
+
+      .cartaSessionClock {
+        position: absolute;
+        bottom: 0;
+        right: 0;
+        font-weight: bold;
+        padding: 10px;
+        z-index: 100;
+        background-color: rgb(54, 69, 79);
+        border-radius: 5px;
+        color: white;
+        width: 30ch;
+        font-family: "Courier New", monospace;
+        box-shadow: rgba(6, 24, 44, 0.4) 0px 0px 0px 2px,
+          rgba(6, 24, 44, 0.65) 0px 4px 6px -1px,
+          rgba(255, 255, 255, 0.08) 0px 1px 0px inset;
+      }
+    </style>
+    <script type="text/javascript">
+      // ISO UTC date session ends.
+      const session_end_date = "{{session_end_date}}";
+
+      // URL to CARTA session
+
+      const session_url = "{{carta_url}}";
+
+      // Calculated session expiration time in MS.
+      const session_expiration = new Date(session_end_date).getTime();
+
+      function hide(elementId) {
+        document.getElementById(elementId).style.display = "none";
+      }
+
+      function nuke(elementId) {
+        document.getElementById(elementId).src = "about:blank";
+      }
+
+      function show(elementId) {
+        document.getElementById(elementId).style.display = "inline";
+      }
+
+      function format_clock(days, hours, minutes, seconds) {
+        let clockString = "";
+        let daysString = `${days}d`;
+        let hoursString = `${hours}h`;
+        let minutesString = `${minutes}m`;
+        let secondsString = `${seconds}s`;
+
+        if (days > 0) {
+          clockString = `${daysString} ${hoursString} ${minutesString} ${secondsString}`;
+        } else if (hours > 0) {
+          clockString = `${hoursString} ${minutesString} ${secondsString}`;
+        } else if (minutes > 0) {
+          clockString = `${minutesString} ${secondsString}`;
+        } else {
+          clockString = `${secondsString}`;
+        }
+        
+        return `Session timer: ${clockString}`;
+      }
+
+      function update_clock_display(message) {
+        const x = document.getElementsByClassName("cartaSessionClock");
+        for (let i = 0; i < x.length; i++) {
+          x[i].innerHTML = message;
+        }
+      }
+
+      function pad(n, width, z) {
+        z = z || "0";
+        n = n + "";
+        return n.length >= width
+          ? n
+          : new Array(width - n.length + 1).join(z) + n;
+      }
+
+      window.onload = function () {
+        const session_timeout = session_expiration - new Date().getTime();
+        document.getElementById("cartaFrame").src = session_url;
+        setTimeout("hide('cartaContent')", session_timeout);
+        setTimeout("show('expiredContent')", session_timeout);
+        setTimeout("nuke('cartaContent')", session_timeout + 5);
+      };
+
+      // Update the count down every 1 second
+      let x = setInterval(function () {
+        // Source: https://www.w3schools.com/howto/howto_js_countdown.asp.
+
+        // Get today's date and time.
+        const now = new Date().getTime();
+
+        // Find the distance between now and when the session expires.
+        const distance = session_expiration - now;
+
+        // Time calculations for days, hours, minutes and seconds
+        const days = pad(Math.floor(distance / (1000 * 60 * 60 * 24)), 2);
+        const hours = pad(
+          Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
+          2
+        );
+        const minutes = pad(
+          Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)),
+          2
+        );
+        const seconds = pad(Math.floor((distance % (1000 * 60)) / 1000), 2);
+
+        // Display the result.
+        update_clock_display(format_clock(days, hours, minutes, seconds));
+
+        // If the count down is finished, write some text.
+        if (distance < 0) {
+          clearInterval(x);
+          update_clock_display("Session timer: EXPIRED");
+        }
+      }, 1000);
+    </script>
+  </head>
+  <body>
+    <div id="cartaContent" style="display: inline">
+      <div class="cartaSessionClock">Session timer:</div>
+      <iframe
+        id="cartaFrame"
+        height="100%"
+        title="CARTA Image Viewer"
+        width="100%"
+      >
+      </iframe>
+    </div>
+    <div id="expiredContent" style="display: none">
+      <h3 style="margin: 10px">Your CARTA session has expired!</h3>
+      <div class="cartaSessionClock">Session timer:</div>
+    </div>
+  </body>
+</html>"""
+
+    def render_template(self, carta_url: str, session_timeout_date: str):
+        return self.template.replace("{{session_end_date}}", session_timeout_date).replace(
+            "{{carta_url}}", carta_url
+        )
diff --git a/apps/cli/executables/pexable/carta_envoy/setup.py b/apps/cli/executables/pexable/carta_envoy/setup.py
index fbda7888a..24023da90 100644
--- a/apps/cli/executables/pexable/carta_envoy/setup.py
+++ b/apps/cli/executables/pexable/carta_envoy/setup.py
@@ -8,7 +8,7 @@ from setuptools import find_packages, setup
 VERSION = open("carta_envoy/_version.py").readlines()[-1].split()[-1].strip("\"'")
 README = Path("README.md").read_text()
 
-requires = ["pycapo", "redis", "requests"]
+requires = ["pycapo", "redis", "requests", "pendulum"]
 
 setup(
     name="ssa-" + Path().absolute().name,
diff --git a/apps/cli/executables/pexable/carta_envoy/test/test_connect.py b/apps/cli/executables/pexable/carta_envoy/test/test_connect.py
index 01a317d9d..c34209beb 100644
--- a/apps/cli/executables/pexable/carta_envoy/test/test_connect.py
+++ b/apps/cli/executables/pexable/carta_envoy/test/test_connect.py
@@ -132,7 +132,7 @@ class TestNotificationConnect:
     def test_send_session_ready(self):
         with patch("carta_envoy.connect.requests.post") as mocked_post:
             url = "I am carta"
-            notify_connect.send_session_ready(carta_url=url)
+            notify_connect.send_session_ready(wrapper_url=url)
             mocked_post.assert_called_with(
                 f"{test_settings['notification_url']}/notify/carta_ready/send",
                 json={"destination_email": test_settings["user_email"], "carta_url": url},
diff --git a/apps/cli/executables/pexable/carta_envoy/test/test_launchers.py b/apps/cli/executables/pexable/carta_envoy/test/test_launchers.py
index 971fc8cf9..a2ee83c4c 100644
--- a/apps/cli/executables/pexable/carta_envoy/test/test_launchers.py
+++ b/apps/cli/executables/pexable/carta_envoy/test/test_launchers.py
@@ -1,20 +1,18 @@
 """
 Tests for carta_envoy.launchers
 """
+import logging
+import os
 from pathlib import Path
 from unittest.mock import MagicMock, patch
 
 # pylint: disable=E0401, R0201
+from carta_envoy.launchers import CartaLauncher, CartaWrapperLauncher
 
-from carta_envoy.utilities import (
-    CARTA_URL_REPLACE_TEXT,
-    CARTA_HTML_FILENAME,
-    CARTA_TEMPLATE,
-)
-from carta_envoy.launchers import CartaLauncher
-
+logger = logging.getLogger("casa_envoy")
 
 UI_URL = "http://localhost:4444/workspaces"
+CARTA_URL = UI_URL + "/carta/requests/-1/html"
 test_settings = {
     "timeout": 1,
     "carta_path": "/fake/path/to/nowhere",
@@ -33,11 +31,27 @@ launcher = CartaLauncher(settings=test_settings)
 
 SUBPROCESS_COMMAND_PATCH = "carta_envoy.launchers.subprocess.Popen"
 
-CARTA_HTML_TEST_PATH = "/path/to/nowhere"
-WRAPPER_URL = "http://localhost:4444/workspaces/carta/requests/-1/html"
-
 BACK_END_PORT = 7777
 FRONT_END_PORT = 6464
+WRAPPER_PORT = 1010
+
+
+class TestCartaWrapperLauncher:
+    # NOTE: Not testing run method as all it does is run a web server, which we don't want
+    #  to do in a test
+
+    def test_deploy_wrapper_html(self):
+        path_to_wrapper = Path("index.html")
+        assert not path_to_wrapper.exists()
+
+        CartaWrapperLauncher.deploy_wrapper_html(Path(), "carta_url", "timeout_date")
+        assert path_to_wrapper.exists()
+        html_content = path_to_wrapper.read_text()
+        assert all(keyword in html_content for keyword in ['"carta_url"', '"timeout_date"'])
+
+        # Clean up wrapper
+        os.remove("index.html")
+        assert not path_to_wrapper.exists()
 
 
 class TestCartaLauncher:
@@ -79,12 +93,14 @@ class TestCartaLauncher:
                 file_browser_path=Path(),
                 front_end_port=FRONT_END_PORT,
                 back_end_port=BACK_END_PORT,
+                wrapper_port=WRAPPER_PORT,
                 carta_url="carta_url",
+                wrapper_url="wrapper_url",
+                session_timeout_date="fake_date",
             )
             assert mock_subprocess.call_count == 0
 
-    @patch("carta_envoy.launchers.CartaLauncher.create_frame_html")
-    def test_run_carta_with_aat(self, mock_frame):
+    def test_run_carta_with_aat(self):
         """
         Test that CARTA runs successfully with setup and teardown
         """
@@ -94,33 +110,40 @@ class TestCartaLauncher:
         launcher.archive_connect = mock_archive_connect
 
         with patch(SUBPROCESS_COMMAND_PATCH) as mock_subprocess:
-            launcher.run_carta(
-                path_to_carta="fake_carta_path",
-                timeout_minutes=1,
-                file_browser_path=Path(),
-                front_end_port=FRONT_END_PORT,
-                back_end_port=BACK_END_PORT,
-                carta_url="carta_url",
-            )
-            assert mock_subprocess.call_count == 1
-            mock_subprocess.assert_called_with(
-                [
-                    "fake_carta_path",
-                    f"--port={BACK_END_PORT}",
-                    f"--fport={FRONT_END_PORT}",
-                    "--folder=.",
-                    "--root=.",
-                ],
-                preexec_fn=None,
-                stdin=-1,
-                stdout=-1,
-                stderr=-1,
-            )
-            assert mock_frame.call_count == 1
-            mock_archive_connect.send_carta_url_to_rh.assert_called_with("carta_url")
+            with patch("carta_envoy.launchers.Thread") as fake_wrapper_thread:
+                fake_wrapper_thread.is_alive.return_value = True
+                launcher.run_carta(
+                    path_to_carta="fake_carta_path",
+                    timeout_minutes=1,
+                    file_browser_path=Path(),
+                    front_end_port=FRONT_END_PORT,
+                    back_end_port=BACK_END_PORT,
+                    wrapper_port=WRAPPER_PORT,
+                    carta_url="carta_url",
+                    wrapper_url="wrapper_url",
+                    session_timeout_date="fake_date",
+                )
+                assert mock_subprocess.call_count == 1
+                mock_subprocess.assert_called_with(
+                    [
+                        "fake_carta_path",
+                        f"--port={BACK_END_PORT}",
+                        f"--fport={FRONT_END_PORT}",
+                        "--folder=.",
+                        "--root=.",
+                    ],
+                    preexec_fn=None,
+                    stdin=-1,
+                    stdout=-1,
+                    stderr=-1,
+                )
+                mock_archive_connect.send_carta_url_to_rh.assert_called_with("wrapper_url")
+                assert fake_wrapper_thread.call_count == 1
+
+        # Clean up wrapper
+        os.remove("index.html")
 
-    @patch("carta_envoy.launchers.CartaLauncher.create_frame_html")
-    def test_run_carta_with_ws(self, mock_frame):
+    def test_run_carta_with_ws(self):
         """
         Test that CARTA runs successfully with setup and teardown
         """
@@ -130,100 +153,35 @@ class TestCartaLauncher:
         launcher.notification = mock_notification_connect
 
         with patch(SUBPROCESS_COMMAND_PATCH) as mock_subprocess:
-            launcher.run_carta(
-                path_to_carta="fake_carta_path",
-                timeout_minutes=1,
-                file_browser_path=Path(),
-                front_end_port=FRONT_END_PORT,
-                back_end_port=BACK_END_PORT,
-                carta_url="carta_url",
-            )
-            assert mock_subprocess.call_count == 1
-            mock_subprocess.assert_called_with(
-                [
-                    "fake_carta_path",
-                    f"--port={BACK_END_PORT}",
-                    f"--fport={FRONT_END_PORT}",
-                    "--folder=.",
-                    "--root=.",
-                ],
-                preexec_fn=None,
-                stdin=-1,
-                stdout=-1,
-                stderr=-1,
-            )
-            assert mock_frame.call_count == 1
-            mock_notification_connect.send_session_ready.assert_called_with("carta_url")
-
-    @patch("pathlib.Path.exists")
-    def test_generates_carta_html(self, mock_path):
-        """
-        Test that we can make a nice HTML page containing the CARTA URL in a frame
-        :return:
-        """
-
-        launcher.settings["useCarta"] = True
-        launcher.settings["send_ready"] = "true"
-        mock_notification_connect = MagicMock()
-        launcher.notification = mock_notification_connect
-
-        with patch("carta_envoy.launchers.CartaLauncher.create_frame_html") as mock_carta_html:
-            with patch(SUBPROCESS_COMMAND_PATCH):
+            with patch("carta_envoy.launchers.Thread") as fake_wrapper_thread:
+                fake_wrapper_thread.is_alive.return_value = True
                 launcher.run_carta(
-                    path_to_carta=test_settings["carta_path"],
+                    path_to_carta="fake_carta_path",
                     timeout_minutes=1,
-                    file_browser_path=Path(test_settings["data_location"]),
+                    file_browser_path=Path(),
                     front_end_port=FRONT_END_PORT,
                     back_end_port=BACK_END_PORT,
-                    carta_url="carta url",
+                    wrapper_port=WRAPPER_PORT,
+                    carta_url="carta_url",
+                    wrapper_url="wrapper_url",
+                    session_timeout_date="fake_date",
                 )
+                assert mock_subprocess.call_count == 1
+                mock_subprocess.assert_called_with(
+                    [
+                        "fake_carta_path",
+                        f"--port={BACK_END_PORT}",
+                        f"--fport={FRONT_END_PORT}",
+                        "--folder=.",
+                        "--root=.",
+                    ],
+                    preexec_fn=None,
+                    stdin=-1,
+                    stdout=-1,
+                    stderr=-1,
+                )
+                mock_notification_connect.send_session_ready.assert_called_with("wrapper_url")
+                assert fake_wrapper_thread.call_count == 1
 
-        assert mock_carta_html.call_count == 1
-
-    def test_serves_carta_wrapper(self):
-        """
-        Can we create HTML containing the CARTA URL in a frame and serve it up?
-
-        :return:
-        """
-        launcher.settings["useCarta"] = True
-        launcher.settings["send_ready"] = "true"
-        mock_notification_connect = MagicMock()
-        launcher.notification = mock_notification_connect
-
-        fake_carta_path = "fake_carta_path"
-
-        # 3. pretend to launch CARTA
-        with patch("pathlib.Path.write_text"):
-            with patch("pathlib.Path.read_text", return_value=CARTA_TEMPLATE):
-                with patch("pathlib.Path.exists", return_value=True):
-                    with patch("pathlib.Path.is_dir", return_value=True):
-                        with patch(SUBPROCESS_COMMAND_PATCH) as mock_subprocess:
-                            launcher.run_carta(
-                                path_to_carta=fake_carta_path,
-                                timeout_minutes=1,
-                                file_browser_path=Path(CARTA_HTML_TEST_PATH),
-                                front_end_port=FRONT_END_PORT,
-                                back_end_port=BACK_END_PORT,
-                                carta_url="carta url",
-                            )
-                            assert mock_subprocess.call_count == 1
-                            mock_subprocess.assert_called_with(
-                                [
-                                    fake_carta_path,
-                                    f"--port={BACK_END_PORT}",
-                                    f"--fport={FRONT_END_PORT}",
-                                    "--folder=" + CARTA_HTML_TEST_PATH,
-                                    "--root=" + CARTA_HTML_TEST_PATH,
-                                ],
-                                preexec_fn=None,
-                                stdin=-1,
-                                stdout=-1,
-                                stderr=-1,
-                            )
-                            mock_notification_connect.send_session_ready.assert_called_with("carta url")
-
-        carta_html = Path(CARTA_HTML_TEST_PATH) / CARTA_HTML_FILENAME
-        expected_html = CARTA_TEMPLATE.replace(CARTA_URL_REPLACE_TEXT, WRAPPER_URL)
-        with patch("pathlib.Path.read_text", return_value=expected_html):
-            assert WRAPPER_URL in carta_html.read_text()
+        # Clean up wrapper
+        os.remove("index.html")
diff --git a/apps/cli/executables/pexable/carta_envoy/test/test_templates.py b/apps/cli/executables/pexable/carta_envoy/test/test_templates.py
new file mode 100644
index 000000000..f7f7ebd6f
--- /dev/null
+++ b/apps/cli/executables/pexable/carta_envoy/test/test_templates.py
@@ -0,0 +1,7 @@
+from carta_envoy.templates import CartaWrapper
+
+
+def test_render_template():
+    test_template = CartaWrapper().render_template("hello", "world")
+    assert '"hello"' in test_template
+    assert '"world"' in test_template
-- 
GitLab