Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add Playwright e2e tests with DeckGL canvas interaction support
- Set up Playwright with isolated JupyterLab instance (port 8889)
- Expose DeckGL instance via React ref for programmatic access
- Create test helpers for DeckGL canvas interactions (deckClick, deckHover, deckDrag)
- Create notebook helpers for JupyterLab operations
- Implement bbox selection test that verifies selected_bounds syncs to Python
- Add test scripts: test:e2e, test:e2e:ui, jupyter:test

DeckGL canvas interactions bypass DOM events and call handlers directly
via deck.pickObject() and deck.props.onClick/onHover, enabling automated
testing of map interactions.
  • Loading branch information
vgeorge committed Oct 6, 2025
commit fbac77889b6a8c5840b0978685cd0d82fb93582f
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"lint": "eslint src",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
"test:e2e:ui": "playwright test --ui",
"jupyter:test": "uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=''"
},
"volta": {
"node": "18.18.2",
Expand Down
8 changes: 8 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ function App() {

const [justClicked, setJustClicked] = useState<boolean>(false);

const deckRef = React.useRef<any>(null);
useEffect(() => {
if (deckRef.current && typeof window !== "undefined") {
(window as any).__deck = deckRef.current.deck;
}
}, [deckRef.current]);

const model = useModel();

const [mapStyle] = useModelState<string>("basemap_style");
Expand Down Expand Up @@ -238,6 +245,7 @@ function App() {
)}
<div className="bg-red-800 h-full w-full relative">
<DeckGL
ref={deckRef}
style={{ width: "100%", height: "100%" }}
initialViewState={
["longitude", "latitude", "zoom"].every((key) =>
Expand Down
32 changes: 16 additions & 16 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
# End-to-End Tests

Playwright-based end-to-end tests for Lonboard widgets in JupyterLab.
Playwright tests for Lonboard widgets in JupyterLab.

## Running Tests

```bash
# Run all e2e tests
npm run test:e2e

# Run with UI mode
npm run test:e2e:ui
npm run test:e2e # Run all tests
npm run test:e2e:ui # Run with UI mode
npm run jupyter:test # Start test JupyterLab manually (port 8889)
```

## Architecture

- **JupyterLab**: Runs on port 8889 (isolated from dev instances on 8888)
- **Working Directory**: `tests/e2e/fixtures/` (only test notebooks visible)
- **Clean State**: JupyterLab server restarts for each test run (`reuseExistingServer: false`)
- Fresh kernel state on every run
- No session persistence between test runs
- No interference with development sessions
- Tests run on port 8889 (isolated from dev on 8888)
- Fresh JupyterLab server per test run
- Fixtures in `tests/e2e/fixtures/`

## DeckGL Canvas Interactions

## Test Fixtures
Playwright mouse events don't trigger DeckGL handlers. Use helpers from `helpers/deck-interaction.ts`:

Test notebooks are stored in `tests/e2e/fixtures/` and committed to the repository. They provide scaffolding to replicate correct user workflows.
```typescript
import { deckClick, deckHover } from "./helpers/deck-interaction";

### simple-map.ipynb
await deckClick(page, x, y); // Calls deck.props.onClick()
await deckHover(page, x, y); // Calls deck.props.onHover()
```

Basic test notebook with 4 points in a grid displaying a simple scatterplot map.
See `bbox-select.spec.ts` for example usage.
45 changes: 45 additions & 0 deletions tests/e2e/bbox-select.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect } from "@playwright/test";
import { deckClick, deckHover } from "./helpers/deck-interaction";
import { openNotebook, runCells, waitForMapReady } from "./helpers/notebook";

test.describe("BBox selection", () => {
test("draws bbox and syncs selected_bounds to Python", async ({ page }) => {
const notebook = await openNotebook(page, "simple-map.ipynb");
await runCells(notebook, 0, 2);
await waitForMapReady(page);
await page.waitForTimeout(2000);

const bboxButton = page.getByRole("button", { name: "Select BBox" });
await expect(bboxButton).toBeVisible({ timeout: 10000 });
await bboxButton.click();

const cancelButton = page.getByRole("button", { name: "Cancel drawing" });
await expect(cancelButton).toBeVisible({ timeout: 5000 });

const deckCanvas = page.locator('canvas#deckgl-overlay').first();
const canvasBox = await deckCanvas.boundingBox();
if (!canvasBox) throw new Error("Canvas not found");

const startX = canvasBox.x + 200;
const startY = canvasBox.y + 200;
const endX = canvasBox.x + 400;
const endY = canvasBox.y + 400;

await deckClick(page, startX, startY);
await page.waitForTimeout(300);
await deckHover(page, endX, endY);
await page.waitForTimeout(300);
await deckClick(page, endX, endY);
await page.waitForTimeout(500);

const clearButton = page.getByRole("button", { name: "Clear bounding box" });
await expect(clearButton).toBeVisible({ timeout: 2000 });

await notebook.locator(".jp-Cell").nth(2).click();
await page.keyboard.press("Shift+Enter");

const output = page.locator(".jp-OutputArea-output").last();
await expect(output).toBeVisible({ timeout: 5000 });
await expect(output).toContainText(/Selected bounds: \([-\d\., ]+\)/);
});
});
26 changes: 24 additions & 2 deletions tests/e2e/fixtures/simple-map.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,39 @@
"# Check selected bounds (run after bbox selection)\n",
"print(f\"Selected bounds: {m.selected_bounds}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"version": "3.10.0"
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.6"
}
},
"nbformat": 4,
Expand Down
135 changes: 135 additions & 0 deletions tests/e2e/helpers/deck-interaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { Page } from "@playwright/test";
export async function deckClick(page: Page, x: number, y: number) {
await page.evaluate(([x, y]) => {
const deck = (window as any).__deck;
if (!deck) {
throw new Error('No deck instance on window');
}

let info;
if (typeof deck.pickObject === 'function') {
info = deck.pickObject({ x, y, radius: 2 });
}

if (!info) {
info = {
x,
y,
object: null,
layer: null,
index: -1,
coordinate: [0, 0],
pixel: [x, y]
};
}

const srcEvent = new PointerEvent('click', {
clientX: x,
clientY: y,
buttons: 1,
pointerType: 'mouse',
bubbles: true
});
const evt = {
type: 'click',
srcEvent,
center: [x, y],
offsetCenter: { x, y }
};

if (deck.props && deck.props.onClick) {
deck.props.onClick(info, evt);
}
}, [x, y]);
}

export async function deckHover(page: Page, x: number, y: number) {
await page.evaluate(([x, y]) => {
const deck = (window as any).__deck;
if (!deck) {
throw new Error('No deck instance on window');
}

const info = deck.pickObject({ x, y, radius: 2 }) || {
x,
y,
object: null,
layer: null,
index: -1,
coordinate: undefined
};

const srcEvent = new PointerEvent('pointermove', {
clientX: x,
clientY: y,
buttons: 0,
pointerType: 'mouse',
bubbles: true
});
const evt = {
type: 'pointermove',
srcEvent,
center: [x, y],
offsetCenter: { x, y }
};

if (deck.props.onHover) {
deck.props.onHover(info, evt);
}
}, [x, y]);
}

export async function deckDrag(
page: Page,
start: { x: number; y: number },
end: { x: number; y: number },
steps: number = 5
) {
await page.evaluate(([start, end, steps]) => {
const deck = (window as any).__deck;
if (!deck) {
throw new Error('No deck instance on window');
}

const makeEvent = (type: string, x: number, y: number, buttons: number) => {
const srcEvent = new PointerEvent(type, {
clientX: x,
clientY: y,
buttons,
pointerType: 'mouse',
bubbles: true
});
return {
type,
srcEvent,
center: [x, y],
offsetCenter: { x, y }
};
};

if (deck.props.onDragStart) {
deck.props.onDragStart(makeEvent('pointerdown', start.x, start.y, 1));
}

for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = start.x + (end.x - start.x) * t;
const y = start.y + (end.y - start.y) * t;

if (deck.props.onDrag) {
deck.props.onDrag(makeEvent('pointermove', x, y, 1));
}
}

if (deck.props.onDragEnd) {
deck.props.onDragEnd(makeEvent('pointerup', end.x, end.y, 0));
}
}, [start, end, steps]);
}

export async function waitForDeck(page: Page, timeout: number = 10000): Promise<void> {
await page.waitForFunction(
() => typeof (window as any).__deck !== 'undefined',
{ timeout }
);
}
41 changes: 41 additions & 0 deletions tests/e2e/helpers/notebook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, type Page, type Locator } from "@playwright/test";
import { waitForDeck } from "./deck-interaction";
export async function openNotebook(page: Page, notebookPath: string): Promise<Locator> {
await page.goto(`/lab/tree/${notebookPath}`);
const notebook = page.locator(".jp-Notebook");
await expect(notebook).toBeVisible({ timeout: 30000 });
return notebook;
}

export async function runCell(notebook: Locator, cellIndex: number): Promise<void> {
await notebook.locator(".jp-Cell").nth(cellIndex).click();
await notebook.page().keyboard.press("Shift+Enter");
}

export async function runCells(notebook: Locator, startIndex: number, count: number): Promise<void> {
await notebook.locator(".jp-Cell").nth(startIndex).click();
for (let i = 0; i < count; i++) {
await notebook.page().keyboard.press("Shift+Enter");
}
}

export async function waitForMapReady(page: Page): Promise<void> {
const mapRoot = page.locator("[data-jp-suppress-context-menu]");
await expect(mapRoot.first()).toBeVisible({ timeout: 30000 });

const deckCanvas = page.locator('[id^="map-"] canvas#deckgl-overlay').first();
await expect(deckCanvas).toBeVisible({ timeout: 30000 });

await expect
.poll(async () => {
const box = await deckCanvas.boundingBox();
return box && box.width > 0 && box.height > 0;
}, { timeout: 10000 })
.toBe(true);

await waitForDeck(page);
}

export function getCellOutput(notebook: Locator, cellIndex: number): Locator {
return notebook.locator(".jp-Cell").nth(cellIndex).locator(".jp-OutputArea-output").last();
}
7 changes: 2 additions & 5 deletions tests/e2e/notebook-load.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { test, expect } from '@playwright/test';
import { openNotebook } from './helpers/notebook';

test.describe('Notebook Load', () => {
test('JupyterLab starts and loads notebook', async ({ page }) => {
// Open a simple map notebook
await page.goto('/lab/tree/simple-map.ipynb');
await openNotebook(page, 'simple-map.ipynb');

// Verify the correct notebook tab is active
await expect(page.locator('.jp-mod-current[role="tab"]:has-text("simple-map.ipynb")')).toBeVisible({ timeout: 10000 });

// Verify kernel status shows in footer
await expect(page.locator('text=/Python 3.*Idle/')).toBeVisible({ timeout: 30000 });
});
});