From 695b0ad34a08add1518937bcc686b8565af16c02 Mon Sep 17 00:00:00 2001 From: Ian Flores Siaca <18703558+ian-flores@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:20:41 -0700 Subject: [PATCH 1/2] fix(workbench): make JupyterLab launch/exec resilient to SPA timing test_launch_jupyter and test_jupyterlab_extensions skipped with a false "IDE may not be installed" because they gated on .jp-Launcher, which only exists while the Launcher tab is open -- some deployments open JupyterLab without it. - Gate readiness on the JupyterLab app shell (.jp-LabShell), present on every load regardless of active view. - In the code-exec step: wait out the #jupyterlab-splash overlay (it lingers after the shell mounts and intercepts clicks), open a Launcher via the launcher:create command when none is auto-opened, target the visible notebook panel, and poll-dismiss the modal "Select Kernel" dialog before typing. Validated live against a Workbench 2026.06 deployment: both JupyterLab tests pass end-to-end (were skipping). Fixes #478 --- .../workbench/pages/jupyterlab_session.py | 18 +++++ .../workbench/test_ide_extensions.py | 4 +- src/vip_tests/workbench/test_ide_launch.py | 78 ++++++++++++++++--- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/vip_tests/workbench/pages/jupyterlab_session.py b/src/vip_tests/workbench/pages/jupyterlab_session.py index 406c2a23..38a8e75c 100644 --- a/src/vip_tests/workbench/pages/jupyterlab_session.py +++ b/src/vip_tests/workbench/pages/jupyterlab_session.py @@ -10,9 +10,21 @@ class JupyterLabSession: """Selectors for the JupyterLab IDE.""" # Core UI elements + # The JupyterLab application shell — present on every load regardless of the + # active view, so it is the reliable "JupyterLab is up" readiness signal. + # (`.jp-Launcher` only exists while the Launcher tab is open, which some + # deployments do not auto-open — see issue #478.) + SHELL = ".jp-LabShell" + # Boot splash overlay; lingers after SHELL mounts and intercepts clicks + # until the app finishes hydrating, so wait for it to clear before + # interacting (issue #478). + SPLASH = "#jupyterlab-splash" LAUNCHER = ".jp-Launcher" NOTEBOOK_PANEL = ".jp-NotebookPanel" MAIN_AREA = ".jp-MainAreaWidget" + # Modal dialog (e.g. "Select Kernel" for a new notebook) and its accept button. + DIALOG = ".jp-Dialog" + DIALOG_ACCEPT = ".jp-Dialog .jp-Dialog-button.jp-mod-accept" # Sidebar FILE_BROWSER = ".jp-FileBrowser" @@ -36,6 +48,12 @@ class JupyterLabSession: LAUNCHER_CARD = ".jp-LauncherCard" LAUNCHER_NOTEBOOK_CARD = ".jp-LauncherCard[data-category='Notebook']" + # JupyterLab's built-in "New Launcher" command control — used to open a + # Launcher tab on deployments that do not auto-open one (issue #478). The + # ``:visible`` filter picks the clickable toolbar/menu control over any + # hidden duplicate registered for the same command. + LAUNCHER_CREATE_COMMAND = '[data-command="launcher:create"]:visible' + # Extension Manager EXTENSION_MANAGER_TAB = ".jp-SideBar .lm-TabBar-tab[data-id='extensionmanager.main-view']" EXTENSION_SEARCH_INPUT = ".jp-extensionmanager-search input" diff --git a/src/vip_tests/workbench/test_ide_extensions.py b/src/vip_tests/workbench/test_ide_extensions.py index ae99cd0c..782fcf96 100644 --- a/src/vip_tests/workbench/test_ide_extensions.py +++ b/src/vip_tests/workbench/test_ide_extensions.py @@ -231,7 +231,9 @@ def vscode_displayed(page: Page): @then("the JupyterLab IDE is displayed") def jupyter_displayed(page: Page): - _expect_ide_or_skip(page, JupyterLabSession.LAUNCHER, "JupyterLab") + # Gate on the JupyterLab app shell, not the Launcher tab (which some + # deployments do not auto-open) — see issue #478. + _expect_ide_or_skip(page, JupyterLabSession.SHELL, "JupyterLab") @then("the Positron IDE is displayed") diff --git a/src/vip_tests/workbench/test_ide_launch.py b/src/vip_tests/workbench/test_ide_launch.py index db435879..516dda1e 100644 --- a/src/vip_tests/workbench/test_ide_launch.py +++ b/src/vip_tests/workbench/test_ide_launch.py @@ -294,8 +294,16 @@ def vscode_displayed(page: Page): @then("the JupyterLab IDE is displayed") def jupyter_displayed(page: Page): - """Verify JupyterLab IDE core elements are visible.""" - _expect_ide_or_skip(page, JupyterLabSession.LAUNCHER, "JupyterLab") + """Verify JupyterLab IDE core elements are visible. + + Gates on the JupyterLab application shell (``.jp-LabShell``), which is + present on every JupyterLab load regardless of the active view — NOT on + ``.jp-Launcher``, which only exists while the Launcher tab is open. On + some deployments JupyterLab opens without the Launcher tab, so gating on + the launcher produced a false "IDE may not be installed" skip even though + JupyterLab was fully loaded (issue #478). + """ + _expect_ide_or_skip(page, JupyterLabSession.SHELL, "JupyterLab") @then("the Positron IDE is displayed") @@ -336,6 +344,25 @@ def vscode_terminal_accessible(page: Page): expect(terminal_input).to_be_visible(timeout=TIMEOUT_CODE_EXEC) +def _accept_open_dialogs(page: Page, *, attempts: int = 20) -> None: + """Dismiss JupyterLab modal dialogs (e.g. "Select Kernel") by accepting the + default, polling because they can appear a moment after the triggering + action rather than immediately. Best-effort: never raises. + """ + for _ in range(attempts): + if page.locator(JupyterLabSession.DIALOG).count() == 0: + return + accept = page.locator(JupyterLabSession.DIALOG_ACCEPT).first + try: + if accept.count() > 0: + accept.click(timeout=TIMEOUT_QUICK) + else: + page.keyboard.press("Enter") + except (PlaywrightTimeoutError, PlaywrightError): + pass + page.wait_for_timeout(1000) + + @then("JupyterLab can execute code in a notebook") def jupyterlab_executes_code(page: Page): """Open a new notebook from the launcher and execute ``1 + 1``. @@ -347,20 +374,53 @@ def jupyterlab_executes_code(page: Page): Skips gracefully if no notebook kernel cards are available (e.g., when the Docker image lacks a working kernel). """ - # The launcher should already be visible (asserted in the previous step). - # Click the first notebook launcher card to open a new notebook. + # JupyterLab's shell (.jp-LabShell) mounts before the app finishes booting: + # the #jupyterlab-splash overlay lingers for a few seconds, intercepting + # clicks and detaching elements mid-hydration. Wait for it to clear before + # interacting, otherwise the launcher-open click is raced away (issue #478). + try: + page.locator(JupyterLabSession.SPLASH).wait_for(state="hidden", timeout=TIMEOUT_IDE_LOAD) + except (PlaywrightTimeoutError, PlaywrightError): + pass # splash already gone (or never rendered) + + # A fresh JupyterLab session on some deployments opens with no Launcher tab + # (issue #478), so no launcher cards are present. Open a Launcher via + # JupyterLab's built-in ``launcher:create`` command control (retrying once), + # then wait for the notebook kernel card to appear. notebook_card = page.locator(JupyterLabSession.LAUNCHER_NOTEBOOK_CARD).first + for _attempt in range(2): + if notebook_card.count() > 0: + break + opener = page.locator(JupyterLabSession.LAUNCHER_CREATE_COMMAND).first + if opener.count() == 0: + break + try: + opener.click(timeout=TIMEOUT_QUICK) + page.locator(JupyterLabSession.LAUNCHER_NOTEBOOK_CARD).first.wait_for( + state="visible", timeout=TIMEOUT_IDE_LOAD + ) + except (PlaywrightTimeoutError, PlaywrightError): + pass # fall through; re-check below and retry or skip + notebook_card = page.locator(JupyterLabSession.LAUNCHER_NOTEBOOK_CARD).first if notebook_card.count() == 0: pytest.skip("No notebook kernel cards available in JupyterLab launcher") expect(notebook_card).to_be_visible(timeout=TIMEOUT_CODE_EXEC) notebook_card.click() - # Wait for the notebook panel to appear - notebook_panel = page.locator(JupyterLabSession.NOTEBOOK_PANEL) + # Clicking the card opens a notebook as the active dock tab. Several hidden + # notebook panels can coexist, so target the *visible* (active) one rather + # than a bare selector that would match multiple, then work within it. + notebook_panel = page.locator(f"{JupyterLabSession.NOTEBOOK_PANEL}:visible").first expect(notebook_panel).to_be_visible(timeout=TIMEOUT_IDE_LOAD) - # Click into the first code cell input and type the expression - cell_input = page.locator(JupyterLabSession.CELL_INPUT).first + # A new notebook pops a modal "Select Kernel" dialog that intercepts clicks. + # It appears a moment *after* the notebook opens (once the kernel connects), + # so poll and dismiss it (accept the default kernel) rather than checking + # once — accepting the default makes the cell interactive (issue #478). + _accept_open_dialogs(page) + + # Click into the first code cell input of the active notebook and type. + cell_input = notebook_panel.locator(JupyterLabSession.CELL_INPUT).first expect(cell_input).to_be_visible(timeout=TIMEOUT_CODE_EXEC) cell_input.click() cell_input.type("1 + 1") @@ -370,7 +430,7 @@ def jupyterlab_executes_code(page: Page): # Assert the output area shows 2. The kernel may be slow to start in # Docker CI, so allow a generous timeout before skipping. - cell_output = page.locator(JupyterLabSession.CELL_OUTPUT).first + cell_output = notebook_panel.locator(JupyterLabSession.CELL_OUTPUT).first try: expect(cell_output).to_contain_text("2", timeout=TIMEOUT_CODE_EXEC) except AssertionError: From 3073024cf99f011ece49af5bb9a5ff8206532c4d Mon Sep 17 00:00:00 2001 From: Ian Flores Siaca <18703558+ian-flores@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:23:22 -0700 Subject: [PATCH 2/2] test(workbench): add JupyterLab launch/exec validation demo --- validation_docs/demo-478-jupyterlab-launch.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 validation_docs/demo-478-jupyterlab-launch.md diff --git a/validation_docs/demo-478-jupyterlab-launch.md b/validation_docs/demo-478-jupyterlab-launch.md new file mode 100644 index 00000000..10f9dd81 --- /dev/null +++ b/validation_docs/demo-478-jupyterlab-launch.md @@ -0,0 +1,42 @@ +# Fix #478: JupyterLab launch/exec resilient to SPA timing + +*2026-07-17T17:21:08Z by Showboat 0.6.1* + + +test_launch_jupyter and test_jupyterlab_extensions skipped with a false 'IDE may not be installed' because they gated on .jp-Launcher, which only exists while the Launcher tab is open -- some deployments open JupyterLab with no Launcher tab. Root causes found by live diagnosis on dev.demo: (1) gate on the wrong element; (2) the #jupyterlab-splash overlay lingers after the shell mounts and intercepts clicks; (3) no Launcher auto-opens; (4) a modal 'Select Kernel' dialog appears a moment after the notebook opens. + +Fix: gate readiness on the JupyterLab app shell (.jp-LabShell), present on every load; then in code-exec, wait out the splash, open a Launcher via launcher:create, target the visible notebook, and poll-dismiss the kernel dialog. + +```bash +grep -n "SHELL = \|SPLASH = \|LAUNCHER_CREATE_COMMAND = \|DIALOG = " src/vip_tests/workbench/pages/jupyterlab_session.py +``` + +```output +17: SHELL = ".jp-LabShell" +21: SPLASH = "#jupyterlab-splash" +26: DIALOG = ".jp-Dialog" +55: LAUNCHER_CREATE_COMMAND = '[data-command="launcher:create"]:visible' +``` + +The readiness gate now uses the app shell, not the Launcher tab: + +```bash +grep -n "JupyterLabSession.SHELL" src/vip_tests/workbench/test_ide_launch.py src/vip_tests/workbench/test_ide_extensions.py +``` + +```output +src/vip_tests/workbench/test_ide_launch.py:306: _expect_ide_or_skip(page, JupyterLabSession.SHELL, "JupyterLab") +src/vip_tests/workbench/test_ide_extensions.py:236: _expect_ide_or_skip(page, JupyterLabSession.SHELL, "JupyterLab") +``` + +Lint clean: + +```bash +env -u UV_PROJECT uv run --frozen --no-sync --project . ruff check src/vip_tests/workbench/test_ide_launch.py src/vip_tests/workbench/test_ide_extensions.py src/vip_tests/workbench/pages/jupyterlab_session.py +``` + +```output +All checks passed! +``` + +Live validation on dev.demo.posit.team (not re-runnable here -- needs a live deployment + browser auth): with JupyterLab installed but no auto-opened Launcher, both JupyterLab tests now PASS end-to-end -- test_jupyterlab_extensions PASSED (was skipping with false 'not installed') and test_launch_jupyter PASSED (opens a Launcher, creates a notebook, dismisses the Select-Kernel dialog, runs 1+1, asserts 2). Before this change both skipped claiming JupyterLab may not be installed.