diff --git a/common/test/data/uploads/simple-question.css b/common/test/data/uploads/simple-question.css new file mode 100644 index 000000000000..2bf79257d50e --- /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 000000000000..7aeb09780766 --- /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 000000000000..c214473470da --- /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 9b6e970b114f..84736a7762ff 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 2867b4ae082f..c589b9a9e32f 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 e86ce21eafce..7b011bf064a6 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 75dea641fa8a..2c736ae2998f 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): """