From 7b258614de1fdc7e45789fee647a005cebdfac4f Mon Sep 17 00:00:00 2001 From: Diogenes Analytics Date: Fri, 15 Mar 2024 18:45:17 -0400 Subject: [PATCH] Refactored flask live instance fixture --- tests/conftest.py | 310 +++++++-------------------------------- tests/data_structures.py | 17 +++ tests/requirements.txt | 1 + tests/server.py | 270 ++++++++++++++++++++++++++++++++++ tests/test_fixtures.py | 78 +++++++++- tests/test_website.py | 58 ++++---- 6 files changed, 453 insertions(+), 281 deletions(-) create mode 100644 tests/data_structures.py create mode 100644 tests/server.py diff --git a/tests/conftest.py b/tests/conftest.py index d1b39ae..9e21071 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,25 +3,22 @@ import base64 import json import os -import random import shutil -import threading from pathlib import Path from typing import Any from typing import Dict from typing import Generator -from typing import Set from typing import Tuple import pytest from flask import Flask -from flask import render_template -from flask import request -from flask import send_from_directory from PIL import Image from selenium.webdriver.common.keys import Keys from werkzeug.datastructures import FileStorage -from werkzeug.datastructures import ImmutableMultiDict + +from tests.server import TEST_SERVER_INFO +from tests.server import build_flask_app +from tests.server import run_threaded_flask_app def pytest_configure(config): @@ -34,23 +31,24 @@ def pytest_configure(config): config.addinivalue_line("markers", "website: custom marker for website tests.") -def generate_unique_random_ports(num_ports: int) -> Generator[int, None, None]: - """Generator that only yield unique random ports.""" - # create set of used ports - used_ports: Set[int] = set() +def get_server_info() -> Tuple[int, str]: + """Convenience function to get test server port and submit route.""" + return TEST_SERVER_INFO["port"], TEST_SERVER_INFO["submit_route"] - # loop over ports - while len(used_ports) < num_ports: - # get random port - port = random.randint(5001, 65535) - # check it is unique - if port not in used_ports: - # send it forward - yield port +def base_custom_config() -> Dict[str, Any]: + """Defines the basic JSON config file attributes.""" + # get port and submit route + port, submit = get_server_info() - # mark it as used - used_ports.add(port) + # build base config + return { + "subject": "Testing", + "title": "Testing", + "form_backend_url": f"http://localhost:{port}{submit}", + "email": "foo@bar.com", + "questions": [], + } def create_temp_websrc_dir(src: Path, dst: Path, src_files: Tuple[str, ...]) -> Path: @@ -76,151 +74,6 @@ def create_temp_websrc_dir(src: Path, dst: Path, src_files: Tuple[str, ...]) -> return sub_dir -def get_html_tag_from_mimetype(file: FileStorage, encoded_data: str) -> str: - """Generate an HTML tag based on the MIME type of the file.""" - # create data URL for reuse below - data_url = f"data:{file.mimetype};base64,{encoded_data}" - - # match the mimetype - match file.mimetype.split("/")[0]: - case "image": - tag = f"" - case "video": - tag = ( - f"" - ) - case "audio": - tag = ( - f"" - ) - case _: - tag = f"Download {file.filename}" - - return tag - - -def process_form_data(form_data: ImmutableMultiDict) -> Dict[str, Any]: - """Process form data to handle multi-values.""" - # setup processed results - processed_data: Dict[str, Any] = {} - - # check form key/values - for key, value in form_data.items(multi=True): - # check if key indicates file(s) - if key in request.files: - processed_data[key] = "" - - # check to see if there are multiple values - elif key in processed_data: - processed_data[key] += f", {value}" - - # handle normally - else: - processed_data[key] = value - - return processed_data - - -def process_uploaded_files(processed_data: Dict[str, Any]) -> None: - """Process uploaded files and generate HTML tags.""" - # get list of tuples for key/files pairs - for key, files in request.files.lists(): - # loop over each file - for file in files: - # make sure it exists - if file.filename: - # get data from file - file_data = file.read() - - # convert to base64 for data URL creation later ... - encoded_data = base64.b64encode(file_data).decode("utf-8") - - # create tag - tag = get_html_tag_from_mimetype(file, encoded_data) - - # update current results - if key in processed_data: - processed_data[key] += "
" + tag - else: - processed_data[key] = tag - - -def build_flask_app(serve_directory: Path, port: int, submit_route: str) -> Flask: - """Assembles Flask app to serve static site.""" - # instantiate app - app = Flask(__name__) - - # update the port - app.config["PORT"] = port - - # define routes - @app.route("/") - def index(): - """Serve the index file in the project dir.""" - return send_from_directory(serve_directory, "index.html") - - @app.route("/") - def other_root_files(path): - """Serve any other files (e.g. config.json) from the project dir.""" - return send_from_directory(serve_directory, path) - - @app.route("/styles/") - def serve_styles(path): - """Send any CSS files from the temp dir.""" - css_file = os.path.join("styles", path) - if os.path.exists(os.path.join(serve_directory, css_file)): - return send_from_directory(serve_directory, css_file) - else: - return "CSS file not found\n", 404 - - @app.route("/scripts/") - def serve_scripts(path): - """Send any JavaScript files from the temp dir.""" - js_file = os.path.join("scripts", path) - if os.path.exists(os.path.join(serve_directory, js_file)): - return send_from_directory(serve_directory, js_file) - else: - return "JavaScript file not found\n", 404 - - @app.route(submit_route, methods=["POST"]) - def submit_form(): - """Render HTML form data as a response form.""" - # log data - print(f"Form data received: {request.form}") - - # process data - processed_data = process_form_data(request.form) - - # log processed data - print(f"Processed data: {processed_data}") - - # process any files - process_uploaded_files(processed_data) - - # log files added - print(f"Added uploaded files: {processed_data}") - - # now render response - return render_template("form_response_template.html", form_data=processed_data) - - # return configured and route decorated Flask app - return app - - -def run_threaded_flask_app(app: Flask, port: int) -> None: - """Run a Flask app using threading.""" - # launch Flask app for project dir in thread - thread = threading.Thread(target=app.run, kwargs={"port": port}) - thread.daemon = True - thread.start() - - def load_config_file(directory: Path) -> Dict[str, Any]: """Load the JSON config file at directory.""" # open the config file in the project dir @@ -236,10 +89,13 @@ def write_config_file(config: Dict[str, Any], src_path: Path) -> None: json.dump(config, json_file, indent=2) -def prepare_default_config(config: Dict[str, Any], src_path: Path, port: int) -> None: +def prepare_default_config(config: Dict[str, Any], src_path: Path) -> None: """Update the default config copy with values approprate for testing.""" + # get port and submit route + port, submit = get_server_info() + # update form backend - config["form_backend_url"] = f"http://localhost:{port}/submit" + config["form_backend_url"] = f"http://localhost:{port}{submit}" # update input[type=file] accept attr for question in config["questions"]: @@ -404,46 +260,22 @@ def session_websrc_tmp_dir( shutil.rmtree(temp_dir) -@pytest.fixture(scope="function") -def function_websrc_tmp_dir( - project_directory: Path, tmp_path: Path, website_files: Tuple[str, ...] -) -> Generator[Path, None, None]: - """Create a per-function copy of the website source code for editing.""" - # create a temporary directory - temp_dir = create_temp_websrc_dir(project_directory, tmp_path, website_files) - - # provide the temporary directory path to the test function - yield temp_dir - - # remove the temporary directory and its contents - shutil.rmtree(temp_dir) - - @pytest.fixture(scope="session") def default_site_config(project_directory: Path) -> Dict[str, Any]: """Load the default config.json file.""" return load_config_file(project_directory) -@pytest.fixture(scope="session") -def submit_route() -> str: - """Defines the route used for the form submission testing.""" - return "/submit" - - @pytest.fixture(scope="session") def session_web_app( - default_site_config: Dict[str, Any], session_websrc_tmp_dir: Path, submit_route: str + default_site_config: Dict[str, Any], session_websrc_tmp_dir: Path ) -> Flask: """Create a session-scoped Flask app for testing with the website source.""" - # set port - port = 5000 - # now update config.json with new backend url - prepare_default_config(default_site_config, session_websrc_tmp_dir, port) + prepare_default_config(default_site_config, session_websrc_tmp_dir) # create app - return build_flask_app(session_websrc_tmp_dir, port, submit_route) + return build_flask_app(session_websrc_tmp_dir) @pytest.fixture(scope="session") @@ -454,38 +286,7 @@ def live_session_web_app_url(session_web_app: Flask) -> str: assert port is not None # start threaded app - run_threaded_flask_app(session_web_app, port) - - # get url - return f"http://localhost:{port}" - - -@pytest.fixture(scope="function") -def unique_random_port() -> int: - """Generate a unique random port greater than 5000.""" - # control the number of ports generated - num_ports = 100 - return next(generate_unique_random_ports(num_ports)) - - -@pytest.fixture(scope="function") -def function_web_app( - function_websrc_tmp_dir: Path, submit_route: str, unique_random_port: int -) -> Flask: - """Create a function-scoped Flask app for testing with the website source.""" - # create app - return build_flask_app(function_websrc_tmp_dir, unique_random_port, submit_route) - - -@pytest.fixture(scope="function") -def live_function_web_app_url(function_web_app: Flask) -> str: - """Runs session-scoped Flask app in a thread.""" - # get port - port = function_web_app.config.get("PORT") - assert port is not None - - # start threaded app - run_threaded_flask_app(function_web_app, port) + run_threaded_flask_app(session_web_app) # get url return f"http://localhost:{port}" @@ -494,30 +295,31 @@ def live_function_web_app_url(function_web_app: Flask) -> str: @pytest.fixture(scope="function") def multiple_select_options_config() -> Dict[str, Any]: """Custom config file fixture for testing multiple select options.""" - return { - "subject": "Testing Multiple Select Options", - "title": "Testing Multi-Select Options", - "form_backend_url": None, - "email": "foo@bar.com", - "questions": [ - { - "label": "Select your country", - "name": "country", - "type": "selectbox", - "required": True, - "options": [ - { - "label": "--Select all that apply--", - "value": "", - "selected": True, - "disabled": True, - }, - {"label": "USA", "value": "USA"}, - {"label": "Canada", "value": "CAN"}, - {"label": "United Kingdom", "value": "UK"}, - {"label": "Australia", "value": "AUS"}, - ], - "custom": {"multiple": True}, - } - ], - } + # get base config + config = base_custom_config() + + # update questions + config["questions"] = [ + { + "label": "Select your country", + "name": "country", + "type": "selectbox", + "required": True, + "options": [ + { + "label": "--Select all that apply--", + "value": "", + "selected": True, + "disabled": True, + }, + {"label": "USA", "value": "USA"}, + {"label": "Canada", "value": "CAN"}, + {"label": "United Kingdom", "value": "UK"}, + {"label": "Australia", "value": "AUS"}, + ], + "custom": {"multiple": True}, + } + ] + + # updated + return config diff --git a/tests/data_structures.py b/tests/data_structures.py new file mode 100644 index 0000000..ee2b4fd --- /dev/null +++ b/tests/data_structures.py @@ -0,0 +1,17 @@ +"""Custom classes used in testing.""" + + +class ImmutableDict(dict): + """Implements an immutable dictionary for safely using dictionary fixtures.""" + + def __init__(self, *args, **kwargs): + """Pass through to parent constructor nothing fancy here.""" + super().__init__(*args, **kwargs) + + def __setitem__(self, key, value): + """Remove item assignment.""" + raise TypeError("ImmutableDict does not support item assignment") + + def __delitem__(self, key): + """Remove item deletion.""" + raise TypeError("ImmutableDict does not support item deletion") diff --git a/tests/requirements.txt b/tests/requirements.txt index 1bcade8..68d463c 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -68,6 +68,7 @@ soupsieve==2.5 tabcompleter==1.3.0 trio==0.24.0 trio-websocket==0.11.1 +types-requests==2.31.0.20240311 typing_extensions==4.10.0 urllib3==2.2.1 Werkzeug==3.0.1 diff --git a/tests/server.py b/tests/server.py new file mode 100644 index 0000000..df38e92 --- /dev/null +++ b/tests/server.py @@ -0,0 +1,270 @@ +"""Defines functions related to the custom Flask testing server.""" + +import base64 +import os +import random +import secrets +import threading +from pathlib import Path +from typing import Any +from typing import Dict +from typing import Generator +from typing import Set + +from flask import Blueprint +from flask import Flask +from flask import jsonify +from flask import render_template +from flask import request +from flask import send_from_directory +from flask import session +from werkzeug.datastructures import FileStorage +from werkzeug.datastructures import ImmutableMultiDict + +from tests.data_structures import ImmutableDict + + +TEST_SERVER_INFO = ImmutableDict( + { + "port": 5000, + "secret_key": secrets.token_hex(16), + "submit_route": "/submit", + } +) + +CONFIG_DATA_MAP: Dict[str, Any] = {} + + +def generate_unique_random_ports(num_ports: int) -> Generator[int, None, None]: + """Generator that only yield unique random ports.""" + # create set of used ports + used_ports: Set[int] = set() + + # loop over ports + while len(used_ports) < num_ports: + # get random port + port = random.randint(5001, 65535) + + # check it is unique + if port not in used_ports: + # send it forward + yield port + + # mark it as used + used_ports.add(port) + + +def get_html_tag_from_mimetype(file: FileStorage, encoded_data: str) -> str: + """Generate an HTML tag based on the MIME type of the file.""" + # create data URL for reuse below + data_url = f"data:{file.mimetype};base64,{encoded_data}" + + # match the mimetype + match file.mimetype.split("/")[0]: + case "image": + tag = f"" + case "video": + tag = ( + f"" + ) + case "audio": + tag = ( + f"" + ) + case _: + tag = f"Download {file.filename}" + + return tag + + +def process_form_data(form_data: ImmutableMultiDict) -> Dict[str, Any]: + """Process form data to handle multi-values.""" + # setup processed results + processed_data: Dict[str, Any] = {} + + # check form key/values + for key, value in form_data.items(multi=True): + # check if key indicates file(s) + if key in request.files: + processed_data[key] = "" + + # check to see if there are multiple values + elif key in processed_data: + processed_data[key] += f", {value}" + + # handle normally + else: + processed_data[key] = value + + return processed_data + + +def process_uploaded_files(processed_data: Dict[str, Any]) -> None: + """Process uploaded files and generate HTML tags.""" + # get list of tuples for key/files pairs + for key, files in request.files.lists(): + # loop over each file + for file in files: + # make sure it exists + if file.filename: + # get data from file + file_data = file.read() + + # convert to base64 for data URL creation later ... + encoded_data = base64.b64encode(file_data).decode("utf-8") + + # create tag + tag = get_html_tag_from_mimetype(file, encoded_data) + + # update current results + if key in processed_data: + processed_data[key] += "
" + tag + else: + processed_data[key] = tag + + +def create_main_blueprint( + serve_directory: Path, config_data_map: Dict[str, Any] +) -> Blueprint: + """Builds a Flask Blueprint for all main routes.""" + main_bp = Blueprint("main", __name__) + + @main_bp.route("/") + def index(): + """Serve the index file in the project dir.""" + # get token from query parameters + token = request.args.get("token") + + # check if token exists + if token: + # get config data + config_data = config_data_map.get(token) + + # update session data + session["config_data"] = config_data + + # notify + print(f"Received token: {token}") + print(f"Website will be configured using: {config_data}") + + return send_from_directory(serve_directory, "index.html") + + @main_bp.route("/") + def other_root_files(path): + """Serve any other files (e.g. config.json) from the project dir.""" + if "config.json" in path and (config_data := session.get("config_data")): + print(f"Serving updated config.json data: {config_data}") + return jsonify(config_data) + else: + return send_from_directory(serve_directory, path) + + @main_bp.route("/styles/") + def serve_styles(path): + """Send any CSS files from the temp dir.""" + css_file = os.path.join("styles", path) + if os.path.exists(os.path.join(serve_directory, css_file)): + return send_from_directory(serve_directory, css_file) + else: + return "CSS file not found\n", 404 + + @main_bp.route("/scripts/") + def serve_scripts(path): + """Send any JavaScript files from the temp dir.""" + js_file = os.path.join("scripts", path) + if os.path.exists(os.path.join(serve_directory, js_file)): + return send_from_directory(serve_directory, js_file) + else: + return "JavaScript file not found\n", 404 + + return main_bp + + +def create_config_blueprint(config_data_map: Dict[str, Any]) -> Blueprint: + """Builds a Flask Blueprint for all config updating routes.""" + config_bp = Blueprint("config", __name__) + + @config_bp.route("/update_config", methods=["POST"]) + def update_config(): + """Update session with new JSON data.""" + if request.is_json: + config_data = request.json + token = secrets.token_urlsafe(16) + session["config_data"] = config_data + config_data_map[token] = config_data + print(f"Updating config data: {config_data}") + return jsonify({"token": token}), 200 + else: + return "Invalid request format. Only JSON requests are accepted.\n", 400 + + @config_bp.route("/reset_config") + def reset_config(): + """Clears the session cache of any config data.""" + session.pop("config_data", None) + return "Configuration reset successfully!\n", 200 + + return config_bp + + +def create_submit_blueprint() -> Blueprint: + """Builds a Flask Blueprint for all form submission routes.""" + submit_bp = Blueprint("submit", __name__) + + @submit_bp.route(TEST_SERVER_INFO["submit_route"], methods=["POST"]) + def submit_form(): + """Render HTML form data as a response form.""" + # notify what form data was received + print(f"Form data received: {request.form}") + + # notify what data was processed + processed_data = process_form_data(request.form) + print(f"Processed data: {processed_data}") + + # notify what files were added (if any) + process_uploaded_files(processed_data) + print(f"Added uploaded files: {request.files}") + + # render the contact form response + return render_template("form_response_template.html", form_data=processed_data) + + return submit_bp + + +def build_flask_app(serve_directory: Path) -> Flask: + """Assembles Flask app to serve static site.""" + # get instance + app = Flask(__name__) + + # set port + app.config["PORT"] = TEST_SERVER_INFO["port"] + + # set secret key + app.config["SECRET_KEY"] = TEST_SERVER_INFO["secret_key"] + + # set up config data map + config_data_map = CONFIG_DATA_MAP + + # build blueprints + main_bp = create_main_blueprint(serve_directory, config_data_map) + config_bp = create_config_blueprint(config_data_map) + submit_bp = create_submit_blueprint() + + # add blueprints to Flask app + app.register_blueprint(main_bp) + app.register_blueprint(config_bp) + app.register_blueprint(submit_bp) + + return app + + +def run_threaded_flask_app(app: Flask) -> None: + """Run a Flask app using threading.""" + # launch Flask app for project dir in thread + thread = threading.Thread(target=app.run) + thread.daemon = True + thread.start() diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 595a326..91a1a9b 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -118,7 +118,6 @@ def test_serve_scripts_route(session_web_app: Flask) -> None: @pytest.mark.fixture def test_submit_form_route( session_web_app: Flask, - submit_route: str, dummy_form_post_data: Dict[str, Any], dummy_txt_file_data_url: str, ) -> None: @@ -128,7 +127,7 @@ def test_submit_form_route( # submit response response = client.post( - submit_route, data=dummy_form_post_data, content_type="multipart/form-data" + "/submit", data=dummy_form_post_data, content_type="multipart/form-data" ) # assert that the response status code is 200 (OK) @@ -180,6 +179,81 @@ def test_submit_form_route( ), "Form data in HTML response does not match expected form data" +@pytest.mark.flask +@pytest.mark.fixture +def test_update_config_route(session_web_app: Flask) -> None: + """Test the route for updating the configuration.""" + client = session_web_app.test_client() + + # send a POST request with JSON data to update the configuration + new_config = {"key": "value"} + post_response = client.post("/update_config", json=new_config) + + # check that the POST request was successful (status code 200) + assert post_response.status_code == 200 + + # check json exists + assert post_response.json is not None + + # retrieve the token from the response + token = post_response.json.get("token") + assert token is not None + + # send a GET request to "/config" to retrieve the updated config + get_response = client.get("/config.json") + + # check if the GET request was successful (status code 200) + assert get_response.status_code == 200 + + # check the response content to verify the updated config data + config_data = json.loads(get_response.data) + assert config_data == new_config + + +@pytest.mark.flask +@pytest.mark.fixture +def test_reset_config_route(session_web_app: Flask) -> None: + """Test the route for resetting the configuration.""" + client = session_web_app.test_client() + + # store original config data + old_config_data_response = client.get("/config.json") + old_config_data = json.loads(old_config_data_response.data) + + # send a POST request with JSON data to update the configuration + new_config = {"key": "value"} + post_response = client.post("/update_config", json=new_config) + + # check that the POST request was successful (status code 200) + assert post_response.status_code == 200 + + # now, send a GET request to "/config" to retrieve the updated config + get_response = client.get("/config.json") + + # check if the GET request was successful (status code 200) + assert get_response.status_code == 200 + + # check the response content to verify the updated config data + config_data = json.loads(get_response.data) + assert config_data == new_config + + # send a GET request to reset the configuration + reset_response = client.get("/reset_config") + + # check that the request was successful (status code 200) + assert reset_response.status_code == 200 + + # now, send a GET request to "/config" to retrieve the reset config + reset_config_response = client.get("/config.json") + + # check if the GET request for reset config was successful (status code 200) + assert reset_config_response.status_code == 200 + + # check the response content to verify the reset config data + reset_config_data = json.loads(reset_config_response.data) + assert reset_config_data == old_config_data + + @pytest.mark.flask @pytest.mark.fixture def test_port_in_app_config(session_web_app: Flask) -> None: diff --git a/tests/test_website.py b/tests/test_website.py index e7471ca..7c869ae 100644 --- a/tests/test_website.py +++ b/tests/test_website.py @@ -11,13 +11,13 @@ from typing import Tuple import pytest +import requests from bs4 import BeautifulSoup from flask import Flask from selenium.webdriver.common.by import By from selenium.webdriver.remote.webelement import WebElement from seleniumbase import BaseCase -from tests.conftest import write_config_file from tests.schema import check_config_schema @@ -290,9 +290,7 @@ def test_custom_title_works( @pytest.mark.website -def test_form_backend_updated( - sb: BaseCase, live_session_web_app_url: str, submit_route: str -) -> None: +def test_form_backend_updated(sb: BaseCase, live_session_web_app_url: str) -> None: """Check that the form backend url has been updated correctly.""" # open the webpage sb.open(live_session_web_app_url) @@ -310,7 +308,7 @@ def test_form_backend_updated( assert form_target is not None # now check that it is the right url - assert form_target == live_session_web_app_url + submit_route + assert form_target == live_session_web_app_url + "/submit" @pytest.mark.website @@ -530,26 +528,31 @@ def test_form_download_required_constraint( sb.save_screenshot_to_logs() +@pytest.mark.debug @pytest.mark.feature def test_select_multiple_options( sb: BaseCase, - live_function_web_app_url: str, - submit_route: str, - function_websrc_tmp_dir: Path, - function_web_app: Flask, + live_session_web_app_url: str, multiple_select_options_config: Dict[str, Any], ) -> None: """Confirm multiple options can be selected.""" - # add form backend - multiple_select_options_config["form_backend_url"] = ( - live_function_web_app_url + submit_route + # update config + response = requests.post( + live_session_web_app_url + "/update_config", json=multiple_select_options_config ) - # update config file for testing multi select options - write_config_file(multiple_select_options_config, function_websrc_tmp_dir) + # check response + assert response.status_code == 200 + + # get token + token = response.json().get("token") + assert token is not None + + # update site URL + site_url = f"{live_session_web_app_url}?token={token}" # open site - sb.open(live_function_web_app_url) + sb.open(site_url) # get question name question_name = multiple_select_options_config["questions"][0]["name"] @@ -611,26 +614,31 @@ def test_select_multiple_options( sb.save_screenshot_to_logs() +@pytest.mark.debug @pytest.mark.feature def test_select_default_submission_rejected( sb: BaseCase, - live_function_web_app_url: str, - submit_route: str, - function_websrc_tmp_dir: Path, - function_web_app: Flask, + live_session_web_app_url: str, multiple_select_options_config: Dict[str, Any], ) -> None: """Confirm that default select options will not pass for submission.""" - # add form backend - multiple_select_options_config["form_backend_url"] = ( - live_function_web_app_url + submit_route + # update config + response = requests.post( + live_session_web_app_url + "/update_config", json=multiple_select_options_config ) - # update config file for testing multi select options - write_config_file(multiple_select_options_config, function_websrc_tmp_dir) + # check response + assert response.status_code == 200 + + # get token + token = response.json().get("token") + assert token is not None + + # update site URL + site_url = f"{live_session_web_app_url}?token={token}" # open site - sb.open(live_function_web_app_url) + sb.open(site_url) # get form form_element = sb.get_element("form")