Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/vip_tests/workbench/pages/jupyterlab_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion src/vip_tests/workbench/test_ide_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
78 changes: 69 additions & 9 deletions src/vip_tests/workbench/test_ide_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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``.
Expand All @@ -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")
Expand All @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions validation_docs/demo-478-jupyterlab-launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Fix #478: JupyterLab launch/exec resilient to SPA timing

*2026-07-17T17:21:08Z by Showboat 0.6.1*
<!-- showboat-id: f3496fab-2065-48ce-966f-7c87c1d8b98e -->

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.
Loading