diff --git a/common/test/data/uploads/simple-question.css b/common/test/data/uploads/simple-question.css
new file mode 100644
index 00000000000..2bf79257d50
--- /dev/null
+++ b/common/test/data/uploads/simple-question.css
@@ -0,0 +1,11 @@
+/* Original Source: https://files.edx.org/custom-js-example/jsinput_example.css */
+
+.directions {
+ font-size: large
+}
+
+.feedback {
+ font-size: medium;
+ border: 2px solid cornflowerblue;
+ padding: 5px;
+}
\ No newline at end of file
diff --git a/common/test/data/uploads/simple-question.html b/common/test/data/uploads/simple-question.html
new file mode 100644
index 00000000000..7aeb0978076
--- /dev/null
+++ b/common/test/data/uploads/simple-question.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Simple Question
+
+
+
+
+
+
+
+
+
+
diff --git a/common/test/data/uploads/simple-question.js b/common/test/data/uploads/simple-question.js
new file mode 100644
index 00000000000..c214473470d
--- /dev/null
+++ b/common/test/data/uploads/simple-question.js
@@ -0,0 +1,88 @@
+/* Original Source: https://files.edx.org/custom-js-example/jsinput_example.js */
+
+/* globals Channel */
+
+(function() {
+ 'use strict';
+
+ // state will be populated via initial_state via the `setState` method. Defining dummy values here
+ // to make the expected structure clear.
+ var state = {
+ availableChoices: [],
+ selectedChoice: ''
+ },
+ channel,
+ select = document.getElementsByClassName('choices')[0],
+ feedback = document.getElementsByClassName('feedback')[0];
+
+ function populateSelect() {
+ // Populate the select from `state.availableChoices`.
+ var i, option;
+
+ // Clear out any pre-existing options.
+ while (select.firstChild) {
+ select.removeChild(select.firstChild);
+ }
+
+ // Populate the select with the available choices.
+ for (i = 0; i < state.availableChoices.length; i++) {
+ option = document.createElement('option');
+ option.value = i;
+ option.innerHTML = state.availableChoices[i];
+ if (state.availableChoices[i] === state.selectedChoice) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ }
+ feedback.innerText = "The currently selected answer is '" + state.selectedChoice + "'.";
+ }
+
+ function getGrade() {
+ // The following return value may or may not be used to grade server-side.
+ // If getState and setState are used, then the Python grader also gets access
+ // to the return value of getState and can choose it instead to grade.
+ return JSON.stringify(state.selectedChoice);
+ }
+
+ function getState() {
+ // Returns the current state (which can be used for grading).
+ return JSON.stringify(state);
+ }
+
+ // This function will be called with 1 argument when JSChannel is not used,
+ // 2 otherwise. In the latter case, the first argument is a transaction
+ // object that will not be used here
+ // (see http://mozilla.github.io/jschannel/docs/)
+ function setState() {
+ var stateString = arguments.length === 1 ? arguments[0] : arguments[1];
+ state = JSON.parse(stateString);
+ populateSelect();
+ }
+
+ // Establish a channel only if this application is embedded in an iframe.
+ // This will let the parent window communicate with this application using
+ // RPC and bypass SOP restrictions.
+ if (window.parent !== window) {
+ channel = Channel.build({
+ window: window.parent,
+ origin: '*',
+ scope: 'JSInput'
+ });
+
+ channel.bind('getGrade', getGrade);
+ channel.bind('getState', getState);
+ channel.bind('setState', setState);
+ }
+
+ select.addEventListener('change', function() {
+ state.selectedChoice = select.options[select.selectedIndex].text;
+ feedback.innerText = "You have selected '" + state.selectedChoice +
+ "'. Click Submit to grade your answer.";
+ });
+
+ return {
+ getState: getState,
+ setState: setState,
+ getGrade: getGrade
+ };
+}());
\ No newline at end of file
diff --git a/openedx/core/lib/xblock_serializer/block_serializer.py b/openedx/core/lib/xblock_serializer/block_serializer.py
index 9b6e970b114..84736a7762f 100644
--- a/openedx/core/lib/xblock_serializer/block_serializer.py
+++ b/openedx/core/lib/xblock_serializer/block_serializer.py
@@ -43,6 +43,10 @@ def __init__(self, block):
if py_lib_zip_file:
self.static_files.append(py_lib_zip_file)
+ js_input_files = utils.get_js_input_files_if_using(self.olx_str, course_key)
+ for js_input_file in js_input_files:
+ self.static_files.append(js_input_file)
+
def _serialize_block(self, block) -> etree.Element:
""" Serialize an XBlock to OLX/XML. """
if block.scope_ids.usage_id.block_type == 'html':
diff --git a/openedx/core/lib/xblock_serializer/test_api.py b/openedx/core/lib/xblock_serializer/test_api.py
index 2867b4ae082..c589b9a9e32 100644
--- a/openedx/core/lib/xblock_serializer/test_api.py
+++ b/openedx/core/lib/xblock_serializer/test_api.py
@@ -244,3 +244,46 @@ def test_capa_python_lib(self):
"""
)
+
+ def test_jsinput_extra_files(self):
+ """
+ Test JSInput problems with extra static files.
+ """
+ course = CourseFactory.create(display_name='JSInput Testing course', run="JSI")
+ jsinput_files = [
+ ("simple-question.html", "./common/test/data/uploads/simple-question.html"),
+ ("simple-question.js", "./common/test/data/uploads/simple-question.js"),
+ ("simple-question.css", "./common/test/data/uploads/simple-question.css"),
+ ("image.jpg", "./common/test/data/uploads/image.jpg"),
+ ("jschannel.js", "./common/static/js/capa/src/jschannel.js"),
+ ]
+ for filename, full_path in jsinput_files:
+ upload_file_to_course(
+ course_key=course.id,
+ contentstore=contentstore(),
+ source_file=full_path,
+ target_filename=filename,
+ )
+
+ jsinput_problem = BlockFactory.create(
+ parent_location=course.location,
+ category="problem",
+ display_name="JSInput Problem",
+ data="",
+ )
+
+ # The jsinput problem should contain the html_file along with extra static files:
+
+ serialized = api.serialize_xblock_to_olx(jsinput_problem)
+ assert len(serialized.static_files) == 5
+ for file in serialized.static_files:
+ self.assertIn(file.name, list(map(lambda f: f[0], jsinput_files)))
+
+ self.assertXmlEqual(
+ serialized.olx_str,
+ """
+
+
+
+ """
+ )
diff --git a/openedx/core/lib/xblock_serializer/test_utils.py b/openedx/core/lib/xblock_serializer/test_utils.py
index e86ce21eafc..7b011bf064a 100644
--- a/openedx/core/lib/xblock_serializer/test_utils.py
+++ b/openedx/core/lib/xblock_serializer/test_utils.py
@@ -1,6 +1,7 @@
"""
Test the OLX serialization utils
"""
+from __future__ import annotations
import unittest
import ddt
@@ -63,3 +64,61 @@ def test_has_python_script(self, olx: str, has_script: bool):
Test the _has_python_script() helper
"""
assert utils._has_python_script(olx) == has_script # pylint: disable=protected-access
+
+ @ddt.unpack
+ @ddt.data(
+ ('''''', "/static/question.html"),
+ ('''''', "/static/simple-question.html"),
+ ('''''', "/static/simple-question.html"),
+ ('''''', "/static/simple.question.html"),
+ ('''''', None),
+ ('''''', None),
+ ('''''', None),
+ ('''some url: /static/simple-question.html''', None),
+ )
+ def test_extract_local_html_path(self, olx: str, local_html_path: str | None):
+ """
+ Test the _extract_local_html_path() helper. Confirm that it correctly detects the
+ presence of a `/static/` url in the 'html_file` attribute of a `` tag.
+ """
+ assert utils._extract_local_html_path(olx) == local_html_path # pylint: disable=protected-access
+
+ def test_extract_static_assets(self):
+ """
+ Test the _extract_static_assets() helper. Confirm that it correctly extracts all the
+ static assets that have relative paths present in the html file.
+ """
+ html_file_content = """
+
+
+
+ Example Title
+
+
+
+
+
This is a non-existent css file: fake.css
+
+
+
+
+
+
+
+
+
+
+ """
+ expected = [
+ "simple-question.css",
+ "jsChannel.js",
+ "/some/path/simple-question.min.js",
+ "other/path/simple-question.min.js",
+ "mario.png"
+ ]
+ assert utils._extract_static_assets(html_file_content) == expected # pylint: disable=protected-access
diff --git a/openedx/core/lib/xblock_serializer/utils.py b/openedx/core/lib/xblock_serializer/utils.py
index 75dea641fa8..2c736ae2998 100644
--- a/openedx/core/lib/xblock_serializer/utils.py
+++ b/openedx/core/lib/xblock_serializer/utils.py
@@ -131,6 +131,58 @@ def _has_python_script(olx: str) -> bool:
return False
+def get_js_input_files_if_using(olx: str, course_id: CourseKey) -> [StaticFile]:
+ """
+ When a problem uses JSInput and references an html file uploaded to the course (i.e. uses /static/),
+ all the other related static asset files that it depends on should also be included.
+ """
+ static_files = []
+ html_file_fullpath = _extract_local_html_path(olx)
+ if html_file_fullpath:
+ html_filename = html_file_fullpath.split('/')[-1]
+ asset_key = StaticContent.get_asset_key_from_path(course_id, html_filename)
+ html_file_content = AssetManager.find(asset_key, throw_on_not_found=False)
+ if html_file_content:
+ static_assets = _extract_static_assets(str(html_file_content.data))
+ for static_asset in static_assets:
+ url = '/' + str(StaticContent.compute_location(course_id, static_asset))
+ static_files.append(StaticFile(name=static_asset, url=url, data=None))
+
+ return static_files
+
+
+def _extract_static_assets(html_file_content_data: str) -> [str]:
+ """
+ Extracts all the static assets with relative paths that are present in the html content
+ """
+ # Regular expression that looks for URLs that are inside HTML tag
+ # attributes (src or href) with relative paths.
+ # The pattern looks for either src or href, followed by an equals sign
+ # and then captures everything until it finds the closing quote (single or double)
+ assets_re = r'\b(?:src|href)\s*=\s*(?![\'"]?(?:https?://))["\']([^\'"]*?\.[^\'"]*?)["\']'
+
+ # Find all matches in the HTML code
+ matches = re.findall(assets_re, html_file_content_data)
+
+ return matches
+
+
+def _extract_local_html_path(olx: str) -> str | None:
+ """
+ Check if the given OlX block string contains a `jsinput` tag and the `html_file` attribute
+ is referencing a file in `/static/`. If so, extract the relative path of the html file in the OLX
+ """
+ if "\/static\/[^\"\']*\.html)\1'
+ matches = re.search(local_html_file_re, olx)
+ if matches:
+ return matches.group('url') # Output example: /static/question.html
+
+ return None
+
+
@contextmanager
def override_export_fs(block):
"""