From 5c5fffcbc228236d23b28f68ac6ae511de87d1e9 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 24 Jul 2026 13:33:51 -0600 Subject: [PATCH] fix(workbench): escape embedded newline before typing job script into console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _JOB_SCRIPT_CONTENT embeds a real newline between its two R statements. write_test_script only escaped quotes before typing the writeLines() call into the console, so the raw newline reached Playwright's type(), which aliases embedded \n/\r to the Enter key — submitting the command early and splitting it into two invalid R statements. The script file was never created, which the file.exists() check added in #528 now catches loudly as "VIP_JOB_SCRIPT_CHECK: FALSE". Escape the newline into the R string literal (like the quotes already are) so the console receives one line. Also assert in _run_console_command that no raw newline reaches type(), since its contract is a single-line command. --- selftests/test_job_script_escaping.py | 30 +++++++++++++++++++++++++++ src/vip_tests/workbench/test_jobs.py | 24 ++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 selftests/test_job_script_escaping.py diff --git a/selftests/test_job_script_escaping.py b/selftests/test_job_script_escaping.py new file mode 100644 index 00000000..d9d8f020 --- /dev/null +++ b/selftests/test_job_script_escaping.py @@ -0,0 +1,30 @@ +"""Guard against a raw newline reaching the console as a premature Enter keypress. + +``_run_console_command`` types R commands via Playwright's ``type()``, which +aliases embedded "\n"/"\r" characters to the Enter key — a raw newline in the +command submits it early, splitting it into two invalid R statements (see the +regression where ``write_test_script``'s ``writeLines()`` call never created +the test job script). +""" + +from __future__ import annotations + +import pytest + +from vip_tests.workbench.test_jobs import _JOB_SCRIPT_CONTENT, _escape_for_r_string_literal + + +def test_job_script_content_has_no_raw_newline_after_escaping(): + escaped = _escape_for_r_string_literal(_JOB_SCRIPT_CONTENT) + assert "\n" not in escaped + assert "\r" not in escaped + + +@pytest.mark.parametrize( + "content", + ["line one\nline two", 'has a "quote"\nand a newline', "carriage\rreturn"], +) +def test_escape_removes_raw_newlines_and_carriage_returns(content): + escaped = _escape_for_r_string_literal(content) + assert "\n" not in escaped + assert "\r" not in escaped diff --git a/src/vip_tests/workbench/test_jobs.py b/src/vip_tests/workbench/test_jobs.py index 55f208e5..52fa2a4d 100644 --- a/src/vip_tests/workbench/test_jobs.py +++ b/src/vip_tests/workbench/test_jobs.py @@ -207,7 +207,18 @@ def _run_console_command(page: Page, r_cmd: str) -> None: focused hidden Ace textarea (matches test_packages.py). ControlOrMeta maps select-all to Cmd+A on macOS, where Ctrl+A is "go to line start" and would not clear the input. + + Playwright's ``type()`` sends a real Enter keypress for any embedded + ``\\n``/``\\r`` (they're aliased to the Enter key in its US keyboard + layout), which would submit *r_cmd* early and mid-statement. Assert there + isn't one rather than silently mistyping it, since a caller building a + multi-line R string must already escape it into the string literal (e.g. + ``\\n``) before it reaches here. """ + assert "\n" not in r_cmd and "\r" not in r_cmd, ( + f"r_cmd contains a raw newline, which Playwright's type() would submit as " + f"a premature Enter keypress: {r_cmd!r}" + ) console_input = page.locator(ConsolePaneSelectors.INPUT) expect(console_input).to_be_visible(timeout=TIMEOUT_DIALOG) console_input.click() @@ -220,6 +231,17 @@ def _run_console_command(page: Page, r_cmd: str) -> None: expect(console_input).to_be_visible(timeout=TIMEOUT_CODE_EXEC) +def _escape_for_r_string_literal(content: str) -> str: + """Escape *content* to embed inside a double-quoted R string literal on one line. + + Quotes and real newlines both need escaping: a raw newline typed into the + console would be sent as an Enter keypress (Playwright's type() aliases + embedded "\\n"/"\\r" to the Enter key), submitting the command early and + splitting it into two invalid R statements. + """ + return content.replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r") + + @when("the user writes a test R script file via the console") def write_test_script(page: Page): """Write the test R script to a file using writeLines() and confirm it landed. @@ -232,7 +254,7 @@ def write_test_script(page: Page): error. Verify ``file.exists()`` right here so a dropped write fails loudly at its source with the path, rather than masquerading as a chooser regression. """ - escaped = _JOB_SCRIPT_CONTENT.replace('"', '\\"') + escaped = _escape_for_r_string_literal(_JOB_SCRIPT_CONTENT) # writeLines tilde-expands the path, so the file lands in the session home # directory — the same location the file chooser and cleanup step target. _run_console_command(page, f'writeLines("{escaped}", "{_JOB_SCRIPT_PATH}")')