Skip to content

Commit 36a20b8

Browse files
samcoferclaude
andauthored
fix(workbench): harden Workbench Job flow against write/chooser races (#528)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cef39f3 commit 36a20b8

2 files changed

Lines changed: 96 additions & 4 deletions

File tree

src/vip_tests/workbench/pages/rstudio_session.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,20 @@ class RStudioSession:
8989
"#rstudio_dlg_ok, button:text-is('Start'), button:text-is('Submit')"
9090
)
9191

92-
# Job status (shared between Background and Workbench Jobs)
92+
# Job status (shared between Background and Workbench Jobs).
93+
#
94+
# On the Workbench Jobs (Launcher) panel the completed status renders as a
95+
# single leaf DIV whose text fuses the word and a timestamp, e.g.
96+
# "Succeeded 4:25 PM", inside a GWT-obfuscated class (not "job-status").
97+
# An exact-match (:text-is('Succeeded')) or span-only selector therefore
98+
# never matches, so job completion polling timed out at the full job
99+
# timeout even though the job succeeded. ``:text('Succeeded')`` selects the
100+
# smallest element *containing* the substring on any tag, matching that
101+
# fused leaf. Verified live over CDP against Workbench 2026.07.0. The prior
102+
# exact/span selectors are kept as fallbacks for the Background Jobs panel
103+
# and older builds.
93104
JOB_STATUS_SUCCEEDED = (
105+
"*:text('Succeeded'), *:text('Completed'), "
94106
"span:text-is('Succeeded'), span:text-is('Completed'), "
95107
"[class*='job-status']:text-is('Succeeded')"
96108
)

src/vip_tests/workbench/test_jobs.py

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,42 @@ def _run_console_command(page: Page, r_cmd: str) -> None:
222222

223223
@when("the user writes a test R script file via the console")
224224
def write_test_script(page: Page):
225-
"""Write the test R script to a file using writeLines() in the R console."""
225+
"""Write the test R script to a file using writeLines() and confirm it landed.
226+
227+
The console is an Ace editor driven by real keystrokes, which can be dropped
228+
if the console has not fully settled after the IDE loads. A dropped
229+
``writeLines()`` leaves no file, and the Workbench Job file chooser then
230+
silently rejects Open (the file must exist), so the readonly script field
231+
stays empty and the failure only surfaces later as an opaque empty-field
232+
error. Verify ``file.exists()`` right here so a dropped write fails loudly at
233+
its source with the path, rather than masquerading as a chooser regression.
234+
"""
226235
escaped = _JOB_SCRIPT_CONTENT.replace('"', '\\"')
227236
# writeLines tilde-expands the path, so the file lands in the session home
228237
# directory — the same location the file chooser and cleanup step target.
229238
_run_console_command(page, f'writeLines("{escaped}", "{_JOB_SCRIPT_PATH}")')
239+
_assert_script_file_exists(page)
240+
241+
242+
def _assert_script_file_exists(page: Page) -> None:
243+
"""Fail loudly if the test script is not on disk after the write step.
244+
245+
Emits a unique sentinel alongside ``file.exists()`` and reads it back from
246+
the console output, so a dropped ``writeLines()`` (or a write to an
247+
unexpected directory) is caught at the write step — before the file chooser
248+
turns it into an empty-script-field mystery.
249+
"""
250+
marker = "VIP_JOB_SCRIPT_CHECK"
251+
_run_console_command(page, f'cat("{marker}:", file.exists("{_JOB_SCRIPT_PATH}"), "\\n")')
252+
output = page.locator(ConsolePaneSelectors.OUTPUT_ELEMENT)
253+
expect(
254+
output,
255+
(
256+
f"Test R script {_JOB_SCRIPT_PATH!r} was not created by the console write step — "
257+
f"the writeLines() keystrokes may have been dropped before the console settled, or "
258+
f"the file landed outside the session home directory"
259+
),
260+
).to_contain_text(f"{marker}: TRUE", timeout=TIMEOUT_CODE_EXEC)
230261

231262

232263
@when("the user runs the script as a Background Job")
@@ -289,7 +320,7 @@ def run_as_workbench_job(page: Page, job_context: dict):
289320
new_btn.wait_for(state="visible", timeout=TIMEOUT_DIALOG)
290321
except PlaywrightTimeoutError:
291322
pytest.skip("Run Script as Workbench Job button not found")
292-
new_btn.click()
323+
_open_workbench_job_dialog(page, new_btn)
293324

294325
# Select the script via the file chooser. Unlike the Background Job dialog,
295326
# the Workbench Job dialog's script field (#rstudio_tbb_text_pro_job_script)
@@ -306,6 +337,49 @@ def run_as_workbench_job(page: Page, job_context: dict):
306337
submit_btn.click()
307338

308339

340+
def _open_workbench_job_dialog(page: Page, new_btn) -> None:
341+
"""Click the "Start Workbench Job" toolbar button until its dialog opens.
342+
343+
The button is a GWT toolbar widget whose first click does not always open
344+
the "Run Script as Workbench Job" dialog — the click can land before the
345+
handler is armed, leaving the dialog closed and the later Browse-button
346+
wait to skip as if the feature were absent. Re-click a few times, checking
347+
for the Browse button (the dialog's first interactive control) between
348+
attempts, so a dropped first click self-heals instead of masquerading as a
349+
capability gap. Verified live over CDP against Workbench 2026.07.0.
350+
"""
351+
browse_btn = page.locator(RStudioSession.WORKBENCH_JOB_SCRIPT_BROWSE_BUTTON).first
352+
for _ in range(3):
353+
new_btn.click()
354+
try:
355+
browse_btn.wait_for(state="visible", timeout=TIMEOUT_DIALOG)
356+
return
357+
except PlaywrightTimeoutError:
358+
continue
359+
# Final attempt surfaces the real state to _select_workbench_job_script,
360+
# which skips with a precise message if the Browse button never appears.
361+
new_btn.click()
362+
363+
364+
def _fill_until_stable(locator, value: str, attempts: int = 5) -> None:
365+
"""Fill *locator* with *value*, re-filling until the value stays put.
366+
367+
The Workbench Job file chooser clears its name field ~1s after it first
368+
appears (GWT finishes initializing it), silently wiping an early fill(). Fill
369+
the field, wait past that reset window, and re-fill if it was cleared, so the
370+
value survives into the Open click regardless of when the reset lands.
371+
"""
372+
for _ in range(attempts):
373+
locator.fill(value)
374+
# Wait out the chooser's post-init reset, then check the value held.
375+
locator.page.wait_for_timeout(TIMEOUT_QUICK // 4)
376+
if locator.input_value() == value:
377+
return
378+
# One last fill; the caller's downstream check reports an empty script field
379+
# with a precise message if the value still refuses to stick.
380+
locator.fill(value)
381+
382+
309383
def _select_workbench_job_script(page: Page, script_filename: str) -> None:
310384
"""Choose *script_filename* in the Workbench Job dialog via its file chooser.
311385
@@ -330,7 +404,13 @@ def _select_workbench_job_script(page: Page, script_filename: str) -> None:
330404
name_input.wait_for(state="visible", timeout=TIMEOUT_DIALOG)
331405
except PlaywrightTimeoutError:
332406
pytest.skip("Workbench Job file chooser did not open")
333-
name_input.fill(script_filename)
407+
408+
# The chooser clears the name field ~1s after it first appears, as GWT
409+
# finishes initializing it. A fill() that lands before that reset is wiped,
410+
# so Open then submits an empty name and the readonly script field stays
411+
# empty. Fill, then re-fill through the reset until the value sticks, before
412+
# clicking Open. Verified live over CDP against Workbench 2026.07.0.
413+
_fill_until_stable(name_input, script_filename)
334414

335415
open_btn = page.locator(RStudioSession.FILE_CHOOSER_OPEN_BUTTON).first
336416
expect(open_btn).to_be_visible(timeout=TIMEOUT_QUICK)

0 commit comments

Comments
 (0)