Skip to content

Commit 163dfb7

Browse files
vgeorgekylebarron
andauthored
feat: automated UI testing with Playwright (#906)
### Summary This PR introduces the initial **Playwright** setup for automated UI testing. ### Changes introduced * Added Playwright configuration and bbox selection spec * Updated project scripts to include `test:e2e` command * Bumped **Deck.gl** to `v9.1.15` for compatibility ### Description This sets up the foundation for end-to-end testing using Playwright. The current configuration includes a basic test that validates bounding box selection. This will help ensure UI stability and make it easier to expand automated test coverage over time. ### How to review * Run `npm run test:e2e`, all tests should pass * Review the Playwright folder structure and configuration under `tests/e2e/` * Confirm the test runs locally without additional setup ### Follow-ups * Integrate Playwright tests into CI * Add coverage for more map and UI components --------- Co-authored-by: Kyle Barron <[email protected]>
1 parent 9273319 commit 163dfb7

File tree

15 files changed

+927
-2
lines changed

15 files changed

+927
-2
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,8 @@ node_modules
3131
.env
3232
.env.local
3333
.env.*.local
34+
35+
# Playwright test artifacts
36+
test-results/
37+
playwright-report/
38+
tests/e2e/fixtures/.ipynb_checkpoints

package-lock.json

Lines changed: 81 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"@deck.gl/core": "^9.2.1",
99
"@deck.gl/extensions": "^9.2.1",
1010
"@deck.gl/layers": "^9.2.1",
11+
"@deck.gl/mapbox": "^9.2.1",
1112
"@deck.gl/react": "^9.2.1",
1213
"@geoarrow/deck.gl-layers": "^0.4.0-beta.1",
1314
"@geoarrow/geoarrow-js": "^0.3.2",
@@ -32,6 +33,7 @@
3233
"@anywidget/types": "^0.2.0",
3334
"@eslint/js": "^9.36.0",
3435
"@jupyter-widgets/base": "^6.0.10",
36+
"@playwright/test": "^1.55.1",
3537
"@statelyai/inspect": "^0.4.0",
3638
"@types/lodash.debounce": "^4.0.9",
3739
"@types/lodash.throttle": "^4.1.9",
@@ -75,7 +77,11 @@
7577
"prettier:check": "prettier './src/**/*.{ts,tsx,css}' --check",
7678
"prettier": "prettier './src/**/*.{ts,tsx,css}' --write",
7779
"lint": "eslint src",
78-
"test": "vitest run"
80+
"test": "vitest run",
81+
"pretest:e2e": "npm run build",
82+
"test:e2e": "playwright test",
83+
"test:e2e:ui": "playwright test --ui",
84+
"jupyter:test": "uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=''"
7985
},
8086
"volta": {
8187
"node": "22.20.0",

playwright.config.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests/e2e',
5+
testMatch: '**/*.spec.ts',
6+
fullyParallel: false,
7+
forbidOnly: !!process.env.CI,
8+
retries: process.env.CI ? 2 : 0,
9+
workers: 1,
10+
timeout: 60000,
11+
expect: {
12+
timeout: 30000,
13+
},
14+
reporter: 'list',
15+
16+
use: {
17+
baseURL: 'http://localhost:8889',
18+
trace: 'on-first-retry',
19+
screenshot: 'only-on-failure',
20+
navigationTimeout: 30000,
21+
browserName: 'chromium',
22+
},
23+
24+
projects: [
25+
{
26+
name: 'chromium',
27+
use: { ...devices['Desktop Chrome'] },
28+
},
29+
],
30+
31+
webServer: {
32+
command: 'uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=""',
33+
url: 'http://localhost:8889',
34+
reuseExistingServer: false, // Always restart for clean state
35+
timeout: 30000,
36+
},
37+
});

src/index.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from "react";
2-
import { useEffect, useCallback, useState } from "react";
2+
import { useEffect, useCallback, useState, useRef } from "react";
33
import { createRender, useModelState, useModel } from "@anywidget/react";
44
import type { Initialize, Render } from "@anywidget/types";
55
import Map from "react-map-gl/maplibre";
@@ -24,6 +24,7 @@ import Toolbar from "./toolbar.js";
2424
import throttle from "lodash.throttle";
2525
import SidePanel from "./sidepanel/index";
2626
import { getTooltip } from "./tooltip/index.js";
27+
import { DeckGLRef } from "@deck.gl/react";
2728

2829
await initParquetWasm();
2930

@@ -94,6 +95,15 @@ function App() {
9495

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

98+
// Expose DeckGL instance on window for Playwright e2e tests
99+
const deckRef = useRef<DeckGLRef | null>(null);
100+
useEffect(() => {
101+
if (deckRef.current && typeof window !== "undefined") {
102+
(window as unknown as Record<string, unknown>).__deck =
103+
deckRef.current.deck;
104+
}
105+
}, [deckRef.current]);
106+
97107
const model = useModel();
98108

99109
const [mapStyle] = useModelState<string>("basemap_style");
@@ -243,6 +253,7 @@ function App() {
243253
)}
244254
<div className="bg-red-800 h-full w-full relative">
245255
<DeckGL
256+
ref={deckRef}
246257
style={{ width: "100%", height: "100%" }}
247258
initialViewState={
248259
["longitude", "latitude", "zoom"].every((key) =>

tests/e2e/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# End-to-End Tests
2+
3+
Playwright tests for Lonboard widgets in JupyterLab.
4+
5+
## Running Tests
6+
7+
```bash
8+
npm run test:e2e # Run all tests
9+
npm run test:e2e:ui # Run with UI mode
10+
npm run jupyter:test # Start test JupyterLab manually (port 8889)
11+
```
12+
13+
## Architecture
14+
15+
- Tests run on port 8889 (isolated from dev on 8888)
16+
- Fresh JupyterLab server per test run
17+
- Fixtures in `tests/e2e/fixtures/`
18+
19+
## DeckGL Canvas Interactions
20+
21+
Playwright mouse events don't trigger DeckGL handlers. Use helpers from `helpers/deckgl/`:
22+
23+
```typescript
24+
import { deckPointerEvent } from "./helpers/deckgl";
25+
26+
// Use canvas-relative coordinates (pixels from canvas top-left corner)
27+
await deckPointerEvent(page, "click", 200, 300);
28+
await deckPointerEvent(page, "hover", 400, 500);
29+
```
30+
31+
The helpers automatically convert pixel coordinates to geographic coordinates and invoke DeckGL event handlers. See JSDoc comments in `helpers/deckgl/interactions.ts` for implementation details.
32+
33+
Example: `bbox-select.spec.ts`

tests/e2e/bbox-select.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { test, expect, Page } from "@playwright/test";
2+
import { deckPointerEvent } from "./helpers/deckgl";
3+
import {
4+
openNotebookFresh,
5+
runFirstNCells,
6+
executeCellAndWaitForOutput,
7+
} from "./helpers/notebook";
8+
import { validateBounds } from "./helpers/assertions";
9+
10+
/**
11+
* Draws a bounding box on the DeckGL canvas by clicking start and end positions.
12+
*/
13+
async function drawBbox(
14+
page: Page,
15+
start: { x: number; y: number },
16+
end: { x: number; y: number },
17+
) {
18+
// Click to set bbox start position
19+
await deckPointerEvent(page, "click", start.x, start.y);
20+
await page.waitForTimeout(300);
21+
22+
// Hover to preview bbox size
23+
await deckPointerEvent(page, "hover", end.x, end.y);
24+
await page.waitForTimeout(300);
25+
26+
// Click to set bbox end position and complete drawing
27+
await deckPointerEvent(page, "click", end.x, end.y);
28+
await page.waitForTimeout(500);
29+
}
30+
31+
test.describe("BBox selection", () => {
32+
test("draws bbox and syncs selected_bounds to Python", async ({ page }) => {
33+
const { notebookRoot } = await openNotebookFresh(page, "simple-map.ipynb", {
34+
workspaceId: `bbox-${Date.now()}`,
35+
});
36+
await runFirstNCells(page, notebookRoot, 2);
37+
await page.waitForTimeout(2000);
38+
39+
// Start bbox selection mode
40+
const bboxButton = page.getByRole("button", { name: "Select BBox" });
41+
await expect(bboxButton).toBeVisible({ timeout: 10000 });
42+
await bboxButton.click();
43+
44+
// Verify drawing mode is active
45+
const cancelButton = page.getByRole("button", { name: "Cancel drawing" });
46+
await expect(cancelButton).toBeVisible({ timeout: 5000 });
47+
48+
// Draw bbox using canvas-relative coordinates (pixels from canvas top-left)
49+
await drawBbox(page, { x: 200, y: 200 }, { x: 400, y: 400 });
50+
51+
// Verify bbox was drawn
52+
const clearButton = page.getByRole("button", {
53+
name: "Clear bounding box",
54+
});
55+
await expect(clearButton).toBeVisible({ timeout: 2000 });
56+
57+
// Execute cell to check selected bounds
58+
const output = await executeCellAndWaitForOutput(notebookRoot, 2);
59+
60+
// Verify bounds are valid geographic coordinates
61+
const outputText = await output.textContent();
62+
validateBounds(outputText);
63+
});
64+
});

0 commit comments

Comments
 (0)