From 3a08a8e8ad040b3eb0c31121090a45861d719fbb Mon Sep 17 00:00:00 2001 From: Haris Skiadas Date: Tue, 11 Aug 2026 15:47:59 -0400 Subject: [PATCH 1/5] Add is_binary to source_code Binary files (e.g. a compiled .jar or .zip) are stored base64 in source_code. A new is_binary flag records that the contents are a binary payload, so the client knows to hand them to a server (Jobe) verbatim rather than treating them as source. Text files keep it False. The manifest processor reads the data-isbinary flag when populating the database, and get_source_code returns is_binary so a program on one page can recognize a binary file stored for another. --- .../book_server_api/routers/rslogging.py | 3 ++ components/rsptx/build_tools/core.py | 2 + components/rsptx/db/crud/rsfiles.py | 18 ++++++++- components/rsptx/db/models.py | 4 ++ ...f6a7b8c9d0_add_is_binary_to_source_code.py | 40 +++++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py diff --git a/bases/rsptx/book_server_api/routers/rslogging.py b/bases/rsptx/book_server_api/routers/rslogging.py index 3c89db83e..17629eff2 100644 --- a/bases/rsptx/book_server_api/routers/rslogging.py +++ b/bases/rsptx/book_server_api/routers/rslogging.py @@ -665,13 +665,16 @@ async def get_source_code( if db_result: # Found data, unpack desired fields file_contents = db_result.main_code + is_binary = db_result.is_binary if db_result.filename: filename = db_result.filename else: file_contents = None + is_binary = None response_bundle = { "filename": filename, "file_contents": file_contents, + "is_binary": is_binary, } return make_json_response(detail=response_bundle) diff --git a/components/rsptx/build_tools/core.py b/components/rsptx/build_tools/core.py index a0820b3d3..34ae47982 100644 --- a/components/rsptx/build_tools/core.py +++ b/components/rsptx/build_tools/core.py @@ -1266,12 +1266,14 @@ def _handle_datafile(el, course_name): filename = el.attrib.get("data-filename", el.attrib["id"]) id = el.attrib["id"] + is_binary = el.attrib.get("data-isbinary") == "true" update_source_code_sync( acid=id, course_id=course_name, main_code=file_contents, filename=filename, + is_binary=is_binary, ) diff --git a/components/rsptx/db/crud/rsfiles.py b/components/rsptx/db/crud/rsfiles.py index 56c13b896..f22a8db74 100644 --- a/components/rsptx/db/crud/rsfiles.py +++ b/components/rsptx/db/crud/rsfiles.py @@ -12,7 +12,12 @@ # We need a synchronous version of this function for use in manifest_data_to_db # if/when process_manifest moves to being async we could remove this def update_source_code_sync( - acid: str, filename: str, course_id: str, main_code: str, owner: str = None + acid: str, + filename: str, + course_id: str, + main_code: str, + owner: str = None, + is_binary: bool = False, ): """ Update the source code for a given acid or filename @@ -31,6 +36,7 @@ def update_source_code_sync( source_code_obj.filename = filename if owner is not None: source_code_obj.owner = owner + source_code_obj.is_binary = is_binary session.add(source_code_obj) else: new_entry = SourceCode( @@ -39,13 +45,19 @@ def update_source_code_sync( course_id=course_id, main_code=main_code, owner=owner, + is_binary=is_binary, ) session.add(new_entry) session.commit() async def update_source_code( - acid: str, filename: str, course_id: str, main_code: str, owner: str = None + acid: str, + filename: str, + course_id: str, + main_code: str, + owner: str = None, + is_binary: bool = False, ): """ Update the source code for a given acid or filename @@ -64,6 +76,7 @@ async def update_source_code( source_code_obj.filename = filename if owner is not None: source_code_obj.owner = owner + source_code_obj.is_binary = is_binary session.add(source_code_obj) else: new_entry = SourceCode( @@ -72,6 +85,7 @@ async def update_source_code( course_id=course_id, main_code=main_code, owner=owner, + is_binary=is_binary, ) session.add(new_entry) await session.commit() diff --git a/components/rsptx/db/models.py b/components/rsptx/db/models.py index ff3e52903..8e3e316bb 100644 --- a/components/rsptx/db/models.py +++ b/components/rsptx/db/models.py @@ -427,6 +427,10 @@ class SourceCode(Base, IdMixin): # Filename to use when saving contents to Jobe or trying to include # this file in a program. It is OK to reuse the same filename for different filename = Column(String(512)) + # True when the file contents are a base64 binary payload (e.g. a + # compiled .jar or .zip), which must be handed to a server (Jobe) verbatim + # rather than treated as source. Text files leave it False. + is_binary = Column(Web2PyBoolean, nullable=False) # Owner of the datafile (username of the instructor who created it) # Used to enforce uniqueness: filename + owner + course_id should be unique owner = Column(String(512), index=True) diff --git a/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py b/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py new file mode 100644 index 000000000..6cdf22dbc --- /dev/null +++ b/migrations/versions/e5f6a7b8c9d0_add_is_binary_to_source_code.py @@ -0,0 +1,40 @@ +"""add is_binary to source_code + +Revision ID: e5f6a7b8c9d0 +Revises: c4e8a1f7b2d9 +Create Date: 2026-08-08 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from rsptx.db.models import Web2PyBoolean + + +# revision identifiers, used by Alembic. +revision: str = "e5f6a7b8c9d0" +down_revision: Union[str, None] = "c4e8a1f7b2d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add is_binary column to source_code table. True marks a base64 binary + # payload (e.g. a compiled .jar or .zip); text files keep it False. This + # lets a program on one page recognize a binary file stored for another. + op.add_column( + "source_code", + sa.Column( + "is_binary", + Web2PyBoolean(length=1), + nullable=False, + server_default=sa.text("'F'"), + ), + ) + + +def downgrade() -> None: + op.drop_column("source_code", "is_binary") From c8e814bbcaa7c01b5de38e8466d250e515b77444 Mon Sep 17 00:00:00 2001 From: Haris Skiadas Date: Tue, 11 Aug 2026 15:47:59 -0400 Subject: [PATCH 2/5] Add is_binary migration, crud, and endpoint tests The migration round-trip adds and drops the source_code.is_binary column. The crud tests store and fetch a binary file's is_binary flag and verify text files keep it False. The endpoint tests confirm get_source_code surfaces is_binary in its response, so a program on another page can recognize a binary file. --- .../book_server_api/test_get_source_code.py | 71 ++++++++++++ test/components/rsptx/db/test_rsfiles.py | 72 ++++++++++++ .../test_is_binary_to_source_code.py | 103 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 test/bases/rsptx/book_server_api/test_get_source_code.py create mode 100644 test/components/rsptx/db/test_rsfiles.py create mode 100644 test/migrations/test_is_binary_to_source_code.py diff --git a/test/bases/rsptx/book_server_api/test_get_source_code.py b/test/bases/rsptx/book_server_api/test_get_source_code.py new file mode 100644 index 000000000..707b0aefa --- /dev/null +++ b/test/bases/rsptx/book_server_api/test_get_source_code.py @@ -0,0 +1,71 @@ +""" +Functional tests for GET /logger/get_source_code returning is_binary. + +Binary files (e.g. a compiled .jar) are stored base64 in source_code with +is_binary set; text files leave it False. The endpoint must surface that so +the client knows to hand the contents to a server (Jobe) verbatim. +""" + +import pytest + +from rsptx.db.crud import update_source_code + +pytestmark = pytest.mark.asyncio(loop_scope="session") + +COURSE = "test_course_1" + + +async def test_get_source_code_returns_is_binary_for_binary( + auth_book_client, init_test_db +): + """A binary source_code row comes back with is_binary true.""" + await update_source_code( + acid="endpoint_binary_jar", + filename="helper.jar", + course_id=COURSE, + main_code="UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + is_binary=True, + ) + resp = await auth_book_client.get( + "/logger/get_source_code", + params={"course_id": COURSE, "acid": "endpoint_binary_jar"}, + ) + assert resp.status_code == 200 + detail = resp.json()["detail"] + assert detail["filename"] == "helper.jar" + assert detail["file_contents"] == "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==" + assert detail["is_binary"] is True + + +async def test_get_source_code_returns_false_is_binary_for_text( + auth_book_client, init_test_db +): + """A plain text row comes back with is_binary false.""" + await update_source_code( + acid="endpoint_text_file", + filename="notes.txt", + course_id=COURSE, + main_code="hello world", + ) + resp = await auth_book_client.get( + "/logger/get_source_code", + params={"course_id": COURSE, "acid": "endpoint_text_file"}, + ) + assert resp.status_code == 200 + detail = resp.json()["detail"] + assert detail["filename"] == "notes.txt" + assert detail["is_binary"] is False + + +async def test_get_source_code_missing_row_returns_null_is_binary( + auth_book_client, init_test_db +): + """A missing row yields nulls, including is_binary.""" + resp = await auth_book_client.get( + "/logger/get_source_code", + params={"course_id": COURSE, "acid": "endpoint_does_not_exist"}, + ) + assert resp.status_code == 200 + detail = resp.json()["detail"] + assert detail["file_contents"] is None + assert detail["is_binary"] is None diff --git a/test/components/rsptx/db/test_rsfiles.py b/test/components/rsptx/db/test_rsfiles.py new file mode 100644 index 000000000..5497b4843 --- /dev/null +++ b/test/components/rsptx/db/test_rsfiles.py @@ -0,0 +1,72 @@ +"""Tests for the is_binary round-trip through the source_code crud layer. + +Binary files (e.g. a compiled .jar) are stored base64 in ``main_code`` with +``is_binary`` set; text files leave it False. This covers the crud functions +that store and fetch those rows. +""" + +import pytest + +from rsptx.db.crud import fetch_source_code, update_source_code + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +async def test_update_source_code_stores_is_binary(init_test_db): + """A binary file's is_binary flag is stored and returned.""" + await update_source_code( + acid="test_binary_jar", + filename="helper.jar", + course_id="test_course_1", + main_code="UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + is_binary=True, + ) + row = await fetch_source_code( + base_course="test_course_1", + course_name="test_course_1", + acid="test_binary_jar", + ) + assert row is not None + assert row.is_binary is True + assert row.filename == "helper.jar" + + +async def test_update_source_code_defaults_text_to_false(init_test_db): + """A plain text file keeps is_binary False.""" + await update_source_code( + acid="test_text_file", + filename="notes.txt", + course_id="test_course_1", + main_code="hello world", + ) + row = await fetch_source_code( + base_course="test_course_1", + course_name="test_course_1", + acid="test_text_file", + ) + assert row is not None + assert row.is_binary is False + + +async def test_update_source_code_changes_is_binary_on_existing_row(init_test_db): + """Updating an existing row changes its is_binary flag.""" + await update_source_code( + acid="test_replace_jar", + filename="helper.jar", + course_id="test_course_1", + main_code="first", + ) + await update_source_code( + acid="test_replace_jar", + filename="helper.jar", + course_id="test_course_1", + main_code="second", + is_binary=True, + ) + row = await fetch_source_code( + base_course="test_course_1", + course_name="test_course_1", + acid="test_replace_jar", + ) + assert row.is_binary is True + assert row.main_code == "second" diff --git a/test/migrations/test_is_binary_to_source_code.py b/test/migrations/test_is_binary_to_source_code.py new file mode 100644 index 000000000..439292a29 --- /dev/null +++ b/test/migrations/test_is_binary_to_source_code.py @@ -0,0 +1,103 @@ +"""Round trip for the source_code is_binary migration (e5f6a7b8c9d0). + +Runs the real ``upgrade()``/``downgrade()`` bodies against the test database +inside a transaction that is always rolled back, with a stand-in for alembic's +``op``. Postgres DDL is transactional, so the added column disappears with the +rollback too. +""" + +import importlib.util +import os +import sys +from pathlib import Path + +import pytest +import sqlalchemy as sa + +MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "versions" + / "e5f6a7b8c9d0_add_is_binary_to_source_code.py" +) + + +@pytest.fixture +def conn(): + """A connection in a transaction that is never committed.""" + url = os.environ["TEST_DBURL"] + engine = sa.create_engine(url, future=True) + connection = engine.connect() + try: + yield connection + finally: + connection.rollback() + connection.close() + engine.dispose() + + +@pytest.fixture +def migration(conn): + """The migration module, with ``op`` bound to the test connection.""" + spec = importlib.util.spec_from_file_location("is_binary_mig", MIGRATION) + mod = importlib.util.module_from_spec(spec) + sys.modules["is_binary_mig"] = mod + spec.loader.exec_module(mod) + + class FakeOp: + @staticmethod + def get_bind(): + return conn + + @staticmethod + def execute(stmt): + return conn.execute(sa.text(stmt) if isinstance(stmt, str) else stmt) + + @staticmethod + def add_column(table, column): + stmt = ( + f"ALTER TABLE {table} ADD COLUMN " + f"{column.name} {str(column.type)}" + ) + conn.execute(sa.text(stmt)) + + @staticmethod + def drop_column(table, column): + name = column if isinstance(column, str) else column.name + conn.execute(sa.text(f"ALTER TABLE {table} DROP COLUMN {name}")) + + mod.op = FakeOp + return mod + + +def _columns(conn): + return { + r.column_name + for r in conn.execute( + sa.text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'source_code'" + ) + ) + } + + +def _drop_is_binary(conn): + """Restore the 'before migration' schema if a prior test run left it.""" + if "is_binary" in _columns(conn): + conn.execute(sa.text("ALTER TABLE source_code DROP COLUMN is_binary")) + + +def test_upgrade_adds_is_binary(conn, migration): + _drop_is_binary(conn) + assert "is_binary" not in _columns(conn) + migration.upgrade() + assert "is_binary" in _columns(conn) + + +def test_downgrade_drops_is_binary(conn, migration): + _drop_is_binary(conn) + migration.upgrade() + assert "is_binary" in _columns(conn) + migration.downgrade() + assert "is_binary" not in _columns(conn) From 1a53b4487eb504fb69b5b887cd3ea6e838281b17 Mon Sep 17 00:00:00 2001 From: Haris Skiadas Date: Tue, 11 Aug 2026 15:48:13 -0400 Subject: [PATCH 3/5] Deliver binary files to the server working directory A datafile carrying data-isbinary (or a DB row with is_binary set) holds base64 contents. The datafile component now preserves the data attributes when it replaces the source element, and livecode pushes the contents to Jobe verbatim instead of treating them as text. The file then lands in the program's working directory, so any language can read it. --- .../runestone/activecode/js/livecode.js | 20 ++++++++++++++++--- .../runestone/datafile/js/datafile.js | 10 ++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/bases/rsptx/interactives/runestone/activecode/js/livecode.js b/bases/rsptx/interactives/runestone/activecode/js/livecode.js index d61fa6652..e304c453e 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/livecode.js +++ b/bases/rsptx/interactives/runestone/activecode/js/livecode.js @@ -277,7 +277,7 @@ export default class LiveCode extends ActiveCode { var files = []; for (let f of allFilesRaw) { // Need to determine content and filename for each datafile - let fileName, content; + let fileName, content, isBinary = false; // Check on page to see if we have the datafile. // Datafiles are looked up via data-filename attribute while additional_files are looked up via id let fileElement; @@ -310,6 +310,9 @@ export default class LiveCode extends ActiveCode { // If the file came from an item with a data-filename attribute, use that as the filename // otherwise this must be an RST item with filename as the id fileName = fileElement.dataset.filename || f.filename; + isBinary = + fileElement.dataset.isbinary === "true" || + fileElement.dataset.isBinary === "true"; } else { // check to see if file is in db let result = null; @@ -356,10 +359,19 @@ export default class LiveCode extends ActiveCode { // favor student code if it exists content = studentCode || result.file_contents; fileName = result.filename; + // binary files (e.g. .jar) are stored base64; the is_binary + // flag tells us to hand the content to the server verbatim + isBinary = result.is_binary === true; } } - if (fileName) { + if (fileName && isBinary) { + files.push({ + name: fileName, + content: content, + isBinary: true, + }); + } else if (fileName) { let fileExtension = fileName.substring( fileName.lastIndexOf(".") + 1, ); @@ -798,7 +810,9 @@ export default class LiveCode extends ActiveCode { // File types being uploaded that come in already in base64 format var extensions = ["jar", "zip", "png", "jpg", "jpeg"]; var contentsb64; - if (extensions.indexOf(extension) === -1) { + if (file.isBinary) { + contentsb64 = contents; + } else if (extensions.indexOf(extension) === -1) { contentsb64 = base64encode(contents); } else { contentsb64 = contents; diff --git a/bases/rsptx/interactives/runestone/datafile/js/datafile.js b/bases/rsptx/interactives/runestone/datafile/js/datafile.js index 5df06e28a..71fba79b4 100644 --- a/bases/rsptx/interactives/runestone/datafile/js/datafile.js +++ b/bases/rsptx/interactives/runestone/datafile/js/datafile.js @@ -23,6 +23,7 @@ class DataFile extends RunestoneBase { this.divid = orig.id; this.dataEdit = this.parseBooleanAttribute(orig, "data-edit"); this.isImage = this.parseBooleanAttribute(orig, "data-isimage"); + this.isBinary = this.parseBooleanAttribute(orig, "data-isbinary"); this.fileName = orig.dataset.filename || null; this.displayClass = "block"; // Users can specify the non-edit component to be hidden--default is not hidden if (this.parseBooleanAttribute(orig, "data-hidden")) { @@ -60,6 +61,7 @@ class DataFile extends RunestoneBase { this.containerDiv.id = this.divid; this.containerDiv.style.display = this.displayClass; this.containerDiv.innerHTML = this.origElem.innerHTML; + this.copyDataAttributes(); this.origElem.replaceWith(this.containerDiv); } createTextArea() { @@ -69,8 +71,16 @@ class DataFile extends RunestoneBase { if (this.numberOfCols) this.containerDiv.cols = this.numberOfCols; this.containerDiv.innerHTML = this.origElem.innerHTML; this.containerDiv.classList.add("datafiletextfield"); + this.copyDataAttributes(); this.origElem.replaceWith(this.containerDiv); } + copyDataAttributes() { + for (const attr of this.origElem.attributes) { + if (attr.name.startsWith("data-")) { + this.containerDiv.setAttribute(attr.name, attr.value); + } + } + } } /*================================= From fe01236704674ff4829ee71607170af28575649e Mon Sep 17 00:00:00 2001 From: Haris Skiadas Date: Tue, 11 Aug 2026 15:48:22 -0400 Subject: [PATCH 4/5] Wire binary compile-also files into the build per language compile-also names files that are part of the build. What that means depends on the language and on whether the file is text or binary: - text source files are compiled together with the main program; - a Java/Kotlin .jar or .zip goes on the compiler's classpath (-cp), so its classes are visible to the program (the -cp also rides in the interpreterargs, which must reproduce Jobe's Java defaults); - a C/C++ .o or .a is placed on the link line, after the source; - other languages just read the delivered file from the working directory. --- .../runestone/activecode/js/livecode.js | 81 +++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/bases/rsptx/interactives/runestone/activecode/js/livecode.js b/bases/rsptx/interactives/runestone/activecode/js/livecode.js index e304c453e..9872e33b2 100644 --- a/bases/rsptx/interactives/runestone/activecode/js/livecode.js +++ b/bases/rsptx/interactives/runestone/activecode/js/livecode.js @@ -437,15 +437,82 @@ export default class LiveCode extends ActiveCode { paramobj.compileargs = [sourcefilename]; } } + // "compile-also" marks additional files as part of the build. What + // that means is language-specific and depends on whether the file is + // text or binary. The list carries filenames (PreTeXt emits + // @filename), so match them against the collected files to learn each + // one's type. These files are also always part of the file_list, so + // they are delivered to the working directory regardless. + let buildFileNames = []; if (this.compileAlso) { - // a comma separated list of files that also need to be compiled - // they also all should be part of additional_files - // we will stick them onto the end of the compilerargs - // so that jobe builds them into the string sent to the compiler - // e.g. g++ [other_compile_args] [compileAlso] sourcefile -o executable + buildFileNames = this.compileAlso + .split(",") + .map((n) => n.trim()) + .filter((n) => n !== ""); + } + let classpathNames = []; + let linkFileNames = []; + for (let name of buildFileNames) { + let f = files.find((file) => file.name === name); + let extension = name.substring(name.lastIndexOf(".") + 1); + if (f && f.isBinary) { + if ( + (this.language === "java" || + this.language === "kotlin") && + ["jar", "zip"].indexOf(extension) > -1 + ) { + // A compiled archive (.jar/.zip) must be on the classpath + // for the compiler and runtime to see the classes it holds. + classpathNames.push(name); + } else if ( + (this.language === "c" || this.language === "cpp") && + ["o", "a"].indexOf(extension) > -1 + ) { + // Binary link inputs (.o/.a) must come AFTER the source + // file on the command line, so they belong in linkargs, + // not compileargs. (.so is unsupported: the server cannot + // arrange for the runtime linker to find it.) + linkFileNames.push(name); + } + // Other languages or file types: nothing to wire; the file is + // in the working directory for the program to read. + } else { + // Text source: compile it together with the main program. + paramobj.compileargs = paramobj.compileargs || []; + if (paramobj.compileargs.indexOf(name) === -1) { + paramobj.compileargs.push(name); + } + } + } + if (classpathNames.length > 0) { + let classpath = + "." + classpathNames.map((n) => ":" + n).join(""); + // Jobe replaces interpreterargs wholesale rather than merging, + // and the client cannot discover Jobe's defaults, so reproduce + // the -X flags Jobe would otherwise supply (mirrors java_task.php). + // The -cp must precede the main class, so it rides in + // interpreterargs rather than runargs. + let interpreterArgs = ["-Xrs", "-Xss8m", "-Xmx200m"]; + if (paramobj.interpreterargs) { + interpreterArgs = paramobj.interpreterargs; + } + interpreterArgs = interpreterArgs.concat(["-cp", classpath]); + paramobj.interpreterargs = interpreterArgs; + // The default compileargs for Java/Kotlin is empty, so appending + // is safe. paramobj.compileargs = paramobj.compileargs || []; - let compileList = this.compileAlso.split(","); - paramobj.compileargs = paramobj.compileargs.concat(compileList); + paramobj.compileargs = paramobj.compileargs.concat([ + "-cp", + classpath, + ]); + } + if (linkFileNames.length > 0) { + paramobj.linkargs = paramobj.linkargs || []; + for (let name of linkFileNames) { + if (paramobj.linkargs.indexOf(name) === -1) { + paramobj.linkargs.push(name); + } + } } let runspec = { language_id: this.language, From d9a0839fb33e8b167f0c493ff1805d04bf6b87c4 Mon Sep 17 00:00:00 2001 From: Haris Skiadas Date: Tue, 11 Aug 2026 15:48:22 -0400 Subject: [PATCH 5/5] Add binary file tests for datafile and livecode The datafile tests check that a binary element keeps its data-isbinary attribute on the rendered container. The livecode tests cover binary delivery (base64 passed verbatim, never through parseJavaClasses) and the per-language compile-also wiring: jar files on the classpath, object files on the link line, text sources in compileargs, and nothing for interpreted languages. --- .../activecode/test/livecode_binary.test.js | 322 ++++++++++++++++++ .../datafile/test/datafile_binary.test.js | 44 +++ 2 files changed, 366 insertions(+) create mode 100644 bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js create mode 100644 bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js diff --git a/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js b/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js new file mode 100644 index 000000000..b479cc8f2 --- /dev/null +++ b/bases/rsptx/interactives/runestone/activecode/test/livecode_binary.test.js @@ -0,0 +1,322 @@ +// Tests for LiveCode's handling of binary files (e.g. a compiled .jar). +// +// runSetup() assembles the list of files to push to Jobe before submitting the +// run. Binary files are carried as base64 and must be passed to the server +// verbatim -- never base64-encoded again, and never fed to +// parseJavaClasses(). These tests drive runSetup directly with the network +// and CodeMirror machinery stubbed out. +import { describe, it, expect, beforeEach } from "vitest"; +import LiveCode from "../js/livecode.js"; + +// Build a bare LiveCode instance (no DOM component, no CodeMirror), wiring in +// the small set of collaborators runSetup touches, and capturing the file +// objects checkFile is fed. +function makeRunner({ + additionalFiles = "helper-jar", + compileAlso = undefined, + pageFiles = [], +} = {}) { + const lc = Object.create(LiveCode.prototype); + lc.additional_files = additionalFiles; + lc.datafiles = undefined; + lc.language = "python3"; + lc.editor = { getValue: () => "print('hi')" }; + lc.autorun = true; + lc.historyScrubber = null; + lc.trimLockedCode = (c) => c; + lc.output = { innerHTML: "" }; + lc.sourcefile = undefined; + lc.suffix = undefined; + lc.prefix = undefined; + lc.includes = undefined; + lc.compileAlso = compileAlso; + lc.jsonHeaders = {}; + lc.div2id = {}; + lc.submitted = []; + lc.checkFile = async (file, resolve) => { + lc.submitted.push(file); + resolve(); + }; + // Place the referenced files in the (jsdom) DOM. + document.body.innerHTML = pageFiles.join("\n"); + return lc; +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +// The DB fallback path in runSetup builds `new Request(relativeUrl)` and then +// `fetch(request)`. Browser fetch resolves relative URLs against the page +// URL; Node's undici does not, so install a Request that carries the URL and a +// fetch that responds from it. +function stubSourceCodeFetcher(baseCourse, derivedCourse, responsesByUrl) { + global.Request = class { + constructor(url, opts) { + this.url = String(url); + this.method = (opts && opts.method) || "GET"; + this.headers = (opts && opts.headers) || {}; + } + }; + global.fetch = async (input) => { + const url = typeof input === "string" ? input : input.url; + const response = responsesByUrl[url]; + if (!response) { + throw new Error(`unexpected fetch: ${url}`); + } + const detail = typeof response === "function" ? response() : response; + return { ok: true, json: () => Promise.resolve(detail) }; + }; + + const latest = (acid) => + `/ns/assessment/get_latest_code?acid=${encodeURIComponent(acid)}`; + const source = (acid) => + `/ns/logger/get_source_code?course_id=test_course&acid=${encodeURIComponent(acid)}`; + return { latest, source }; +} + +function resetFetchers() { + delete globalThis.Request; + delete globalThis.fetch; +} + +describe("binary files in runSetup", () => { + it("flags a data-isbinary page element and passes its base64 verbatim", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + pageFiles: [ + `
${base64}
`, + ], + }); + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0]).toEqual({ + name: "helper.jar", + content: base64, + isBinary: true, + }); + }); + + it("does not split a binary .jar with parseJavaClasses", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "helper-jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.parseJavaClasses = (text) => { + throw new Error("parseJavaClasses must not run on binary data"); + }; + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0].isBinary).toBe(true); + }); + + it("flags a DB-sourced file whose is_binary marks it binary", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ additionalFiles: "cross-page-jar", pageFiles: [] }); + const latestUrl = `/ns/assessment/get_latest_code?acid=cross-page-jar`; + const sourceUrl = `/ns/logger/get_source_code?course_id=test_course&acid=cross-page-jar`; + stubSourceCodeFetcher("test_course", "test_course", { + [latestUrl]: () => ({ + detail: { code: null }, + }), + [sourceUrl]: { + detail: { + filename: "helper.jar", + file_contents: base64, + // True here means binary; text files come back false. + is_binary: true, + }, + }, + }); + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0]).toEqual({ + name: "helper.jar", + content: base64, + isBinary: true, + }); + resetFetchers(); + }); + + it("treats a fetched file with is_binary false as plain text", async () => { + const lc = makeRunner({ additionalFiles: "cross-text-file", pageFiles: [] }); + const latest = `/ns/assessment/get_latest_code?acid=cross-text-file`; + const source = `/ns/logger/get_source_code?course_id=test_course&acid=cross-text-file`; + stubSourceCodeFetcher("test_course", "test_course", { + [latest]: () => ({ + detail: { code: null }, + }), + [source]: { + detail: { + filename: "helper.txt", + file_contents: "plain text", + // A false is_binary means text, so the client must not + // hand the contents to the server as base64. + is_binary: false, + }, + }, + }); + await lc.runSetup(); + expect(lc.submitted).toHaveLength(1); + expect(lc.submitted[0]).toEqual({ + name: "helper.txt", + content: "plain text", + }); + resetFetchers(); + }); + + it("adds a binary jar to the Java compile and runtime classpath", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + compileAlso: "helper.jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toEqual([ + "-cp", + ".:helper.jar", + ]); + // Jobe replaces interpreterargs wholesale, so we must reproduce its + // Java defaults (the -X flags from java_task.php) and append the -cp. + expect(runspec.parameters.interpreterargs).toEqual([ + "-Xrs", + "-Xss8m", + "-Xmx200m", + "-cp", + ".:helper.jar", + ]); + }); + + it("puts a binary jar on the classpath for Kotlin too", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + compileAlso: "helper.jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "kotlin"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toEqual([ + "-cp", + ".:helper.jar", + ]); + expect(runspec.parameters.interpreterargs).toEqual([ + "-Xrs", + "-Xss8m", + "-Xmx200m", + "-cp", + ".:helper.jar", + ]); + }); + + it("does not add a binary jar to the classpath unless it is compile-also", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toBeUndefined(); + expect(runspec.parameters.interpreterargs).toBeUndefined(); + }); + + it("keeps author-supplied interpreterargs when adding the jar classpath", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-jar", + compileAlso: "helper.jar", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + lc.interpreterargs = '["-Xmx512m"]'; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.interpreterargs).toEqual([ + "-Xmx512m", + "-cp", + ".:helper.jar", + ]); + }); + + it("does not touch the classpath for a non-archive binary file", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_pic-datafile", + compileAlso: "pic.png", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "java"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toBeUndefined(); + expect(runspec.parameters.interpreterargs).toBeUndefined(); + }); + + it("puts a binary object file on the C++ linkargs", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_helper-o", + compileAlso: "helper.o", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "cpp"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.linkargs).toEqual(["helper.o"]); + expect(runspec.parameters.compileargs).toBeUndefined(); + }); + + it("puts a text source on the C++ compileargs, not linkargs", async () => { + const lc = makeRunner({ + additionalFiles: "PTXSB_2_addcpp", + compileAlso: "add.cpp", + pageFiles: [ + `
int add(int a, int b) { return a + b; }
`, + ], + }); + lc.language = "cpp"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toEqual(["add.cpp"]); + expect(runspec.parameters.linkargs).toBeUndefined(); + }); + + it("does not wire binary compile-also files for Python", async () => { + const base64 = "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA=="; + const lc = makeRunner({ + additionalFiles: "PTXSB_2_pkg-whl", + compileAlso: "pkg.whl", + pageFiles: [ + `
${base64}
`, + ], + }); + lc.language = "python3"; + await lc.runSetup(); + const runspec = JSON.parse(lc.json_runspec).run_spec; + expect(runspec.parameters.compileargs).toBeUndefined(); + expect(runspec.parameters.linkargs).toBeUndefined(); + expect(runspec.parameters.interpreterargs).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js b/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js new file mode 100644 index 000000000..f38dcf6d2 --- /dev/null +++ b/bases/rsptx/interactives/runestone/datafile/test/datafile_binary.test.js @@ -0,0 +1,44 @@ +// Characterization tests for the DataFile component's handling of binary +// files. A binary file (e.g. a compiled .jar) is registered as a hidden +//
 carrying its base64 representation plus a data-isbinary attribute.
+// The component replaces that 
 with a container that must keep those
+// attributes so LiveCode can still find them.
+import { describe, it, expect, beforeEach } from "vitest";
+// datafile.js has no exports; it registers a component_factory on window.
+import "../js/datafile.js";
+
+function makeFixture(attrs = "", content = "") {
+    document.body.innerHTML = `
+      
+
${content}
+
`; + return document.querySelector("[data-component=datafile]"); +} + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +describe("DataFile", () => { + it("preserves data-isbinary on the rendered container", () => { + const orig = makeFixture( + 'data-filename="helper.jar" data-isbinary="true" ' + + 'data-mime-type="application/java-archive" data-edit="false" data-hidden=""', + "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + ); + const df = window.component_factory.datafile({ orig }); + expect(df.containerDiv.id).toBe("binary_1"); + expect(df.containerDiv.dataset.isbinary).toBe("true"); + expect(df.containerDiv.dataset.filename).toBe("helper.jar"); + expect(df.containerDiv.textContent).toBe( + "UEsDBBQAAAAIAAAAAAAAAAAAAAAAAAAAAA==", + ); + }); + + it("does not mark an ordinary text file as binary", () => { + const orig = makeFixture('data-filename="helper.txt" data-edit="false"', "hello"); + const df = window.component_factory.datafile({ orig }); + expect(df.isBinary).toBe(false); + expect(df.containerDiv.dataset.isbinary).toBeUndefined(); + }); +}); \ No newline at end of file